Skip to content

Commit ed7de30

Browse files
feat: Add IndexedDB persistence with asset:// protocol support for images (#8)
* Initial plan * Initial plan for history saving feature Co-authored-by: Mr-Python-in-China <[email protected]> * feat: Add history saving, reset, import/export features - Created JSON schema for config validation in typst-template/ - Added localStorage utilities for auto-save/load and validation - Implemented auto-save on data changes - Implemented auto-load on page initialization - Added reset button with confirmation dialog - Added export button to download config as JSON - Added import button to upload config from JSON with validation - All features tested and working correctly Co-authored-by: Mr-Python-in-China <[email protected]> * refactor: Address code review feedback - Rename serializeContestData to serializeContestDataForStorage for clarity - Add validation before type assertion in deserializeContestData - Add debouncing to auto-save (500ms) to reduce localStorage writes - Simplify loadFromLocalStorage by removing duplicate validation - Install lodash.debounce and its types for debouncing functionality Co-authored-by: Mr-Python-in-China <[email protected]> * feat: Implement IndexedDB storage for images with UUID - Replace localStorage with IndexedDB for persistent storage - Add UUID for each image with asset:// URI format in markdown - Maintain UUID to blob URL mapping for preview - Implement image persistence across sessions - Update export/import to include base64 encoded images - Images now persist correctly after page reload Co-authored-by: Mr-Python-in-China <[email protected]> * refactor: move assets fetching from the worker to the main thread * feat: Add asset:// protocol interceptor for image references - Implement asset:// protocol support in compiler fetchAsset handler - Add registerAssetBlobs() function to register UUID->Blob mappings - Intercept asset://uuid URLs and resolve to blob data - ContestEditor automatically registers image blobs with compiler - Images in markdown using asset://uuid now work in PDF compilation - Export/import already includes base64-encoded images (requirement 5) Co-authored-by: Mr-Python-in-China <[email protected]> * fix: Update config schema to match export format with base64 images - Change images schema to include uuid, base64, and mimeType fields - Remove url field as it's only used internally for display - Make uuid and name required fields - base64 and mimeType are optional (present in export, absent in internal state) Co-authored-by: Mr-Python-in-China <[email protected]> * fix: Make base64 and mimeType required in config schema - Change base64 and mimeType from optional to required fields - Export always includes these fields, so schema should validate them - Ensures imported configs contain complete image data - Prevents incomplete config files from passing validation Co-authored-by: Mr-Python-in-China <[email protected]> * remove unused functions * remove needless dependencie * refactor: Simplify asset:// protocol to use blob URLs directly - Replace assetBlobMapping (UUID → Blob) with assetUrlMapping (UUID → blob URL) - Rename registerAssetBlobs to registerAssetUrls, now accepts URL mapping - Let axios/XMLHttpRequest handle blob URL fetching natively - Remove manual blob-to-ArrayBuffer conversion step - Update ContestEditor to register imageMapping instead of imageBlobsRef - Simpler implementation leveraging browser's native blob URL support Co-authored-by: Mr-Python-in-China <[email protected]> * refactor: Simplify asset URL registration by directly assigning the map * refactor: Simplify exportConfig to use blob URLs instead of Blob objects - Change exportConfig parameter from Map<string, Blob> to Map<string, string> - Fetch blob from blob URL using native fetch API - Update ContestEditor to pass imageMapping instead of imageBlobsRef - Consistent with compiler optimization - use blob URLs throughout - Eliminates need to maintain separate Blob object reference for export Co-authored-by: Mr-Python-in-China <[email protected]> * refactor: Remove unnecessary imageBlobsRef - Remove imageBlobsRef as it's no longer needed - Blobs are saved to IndexedDB immediately on upload/import - Blob URLs in imageMapping are sufficient for all operations - Export can fetch blobs from blob URLs when needed - Simplifies code by removing redundant blob storage - Remove RefObject imports no longer needed Co-authored-by: Mr-Python-in-China <[email protected]> * refactor: Create unified image management interface - Add imageManager utility with addImage and deleteImage functions - Encapsulate both IndexedDB and state operations in single interface - Simplifies image add/delete by abstracting DB and state updates - Update configPanel to use new unified interface - Remove direct DB operation calls from configPanel - Cleaner separation of concerns Co-authored-by: Mr-Python-in-China <[email protected]> * refactor: Remove redundant contestData parameter from deleteImage - Use updater callback parameter to find image index - Remove contestData from deleteImage function signature - Simplifies function call by removing redundant parameter - updater callback already provides current state Co-authored-by: Mr-Python-in-China <[email protected]> * better eror handling for blobToBase64 * 'name' in stored image object is unused * data.images is always defined * Remove redundant parameter imageMapping from deleteImage * refactor: Eliminate data duplication between imageMapping and contestData - Remove url field from contestData.images type - Look up blob URL from imageMapping when displaying images - Update imageManager to not set url field - Pass imageMapping through component hierarchy - Use imageMapping for blob URL cleanup on unmount - Remove redundant imgsUrlRef tracking - Single source of truth for image blob URLs Co-authored-by: Mr-Python-in-China <[email protected]> * optimize imports * better way to process initial value * refactor: Store blob URLs directly in contestData.images - Add url field to ImmerContestData.images type - Remove imageMapping state entirely - Simplify state management - single source of truth in contestData - Update imageManager to set url field in contestData - Update all components to use contestData.images for blob URLs - Update export/import to use contestData.images - Update saveConfigToDB/loadConfigFromDB signatures - Eliminate dual-state synchronization complexity - Build passes successfully Co-authored-by: Mr-Python-in-China <[email protected]> * remove debug log * remove debug log * fix: Remove blob URL cleanup on unmount - Remove useEffect that revokes blob URLs on component unmount - Blob URLs need to persist across component mount/unmount cycles - Images are stored in IndexedDB, blob URLs recreated on page load - Browser will automatically clean up blob URLs when session ends - Fixes issue where remounting component would lose image access Co-authored-by: Mr-Python-in-China <[email protected]> * Improve `toImmerContestData` * move notification placement --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Mr-Python-in-China <[email protected]> Co-authored-by: Mr. Python <[email protected]>
1 parent 2d347f8 commit ed7de30

File tree

13 files changed

+1139
-160
lines changed

13 files changed

+1139
-160
lines changed

package-lock.json

Lines changed: 120 additions & 45 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,18 @@
2828
"@fortawesome/free-regular-svg-icons": "^7.1.0",
2929
"@fortawesome/free-solid-svg-icons": "^7.1.0",
3030
"@fortawesome/react-fontawesome": "^3.1.0",
31+
"@mr.python/promise-worker-ts": "^1.0.0",
3132
"@myriaddreamin/typst-ts-renderer": "^0.6.1-rc4",
3233
"@myriaddreamin/typst-ts-web-compiler": "^0.6.1-rc4",
3334
"@myriaddreamin/typst.ts": "^0.6.1-rc4",
3435
"@uiw/react-codemirror": "^4.25.2",
36+
"ajv": "^8.17.1",
3537
"antd": "^5.27.6",
3638
"async-mutex": "^0.5.0",
3739
"axios": "^1.12.2",
3840
"dayjs": "^1.11.18",
3941
"lodash": "^4.17.21",
40-
"promise-worker-ts": "^0.0.1",
42+
"lodash.debounce": "^4.0.8",
4143
"react": "^19.1.1",
4244
"react-dom": "^19.1.1",
4345
"remark": "^15.0.1",
@@ -54,6 +56,7 @@
5456
"@eslint/js": "^9.36.0",
5557
"@types/fontkit": "^2.0.8",
5658
"@types/lodash": "^4.17.20",
59+
"@types/lodash.debounce": "^4.0.9",
5760
"@types/mdast": "^4.0.4",
5861
"@types/node": "^24.9.1",
5962
"@types/pngjs": "^6.0.5",

src/compiler/index.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import axiosInstance from "@/utils/axiosInstance";
22
import type { PackageSpec } from "@myriaddreamin/typst.ts/internal.types";
3-
import { send } from "promise-worker-ts";
4-
import {
5-
type CompileTypstMessage,
6-
type RenderTypstMessage,
7-
type InitMessage,
8-
} from "./typst.worker";
3+
import { listenMain, send } from "@mr.python/promise-worker-ts";
94
import remarkGfm from "remark-gfm";
105
import remarkMath from "remark-math";
116
import remarkParse from "remark-parse";
127
import { unified } from "unified";
138
import remarkTypst from "./remarkTypst";
9+
import { isAxiosError } from "axios";
1410
import type ContestData from "@/types/contestData";
11+
import {
12+
type CompileTypstMessage,
13+
type RenderTypstMessage,
14+
type InitMessage,
15+
type FetchAssetMessage,
16+
} from "./typst.worker";
1517

1618
import fontUrlEntries from "virtual:typst-font-url-entries";
1719
import TypstCompilerWasmUrl from "@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm?url";
@@ -352,3 +354,50 @@ export const compileToSvgDebounced = (() => {
352354
return nextDeferred.promise;
353355
};
354356
})();
357+
358+
// Global mapping for asset:// protocol - maps UUID to blob URL
359+
let assetUrlMapping = new Map<string, string>();
360+
361+
/**
362+
* Register image blob URLs for asset:// protocol resolution
363+
* Called from ContestEditor when images are loaded/updated
364+
*/
365+
export function registerAssetUrls(uuidToUrlMap: Map<string, string>): void {
366+
assetUrlMapping = new Map(uuidToUrlMap);
367+
}
368+
369+
/**
370+
* Fetch asset with support for asset:// protocol
371+
* asset://uuid -> map to blob URL and fetch via axios
372+
* other URLs -> fetch via axios
373+
*/
374+
async function fetchAsset(url: string): Promise<ArrayBuffer> {
375+
// Handle asset:// protocol
376+
if (url.startsWith("asset://")) {
377+
const uuid = url.substring(8); // Remove "asset://" prefix
378+
const blobUrl = assetUrlMapping.get(uuid);
379+
380+
if (!blobUrl) {
381+
throw new Error(`Asset not found: ${uuid}`);
382+
}
383+
384+
// Fetch the blob URL using axios
385+
url = blobUrl;
386+
}
387+
388+
// Handle regular URLs (including mapped blob URLs)
389+
try {
390+
const response = await axiosInstance.get<ArrayBuffer>(url);
391+
return response.data;
392+
} catch (e) {
393+
if (isAxiosError(e)) {
394+
console.error("Failed to download assets.", e);
395+
throw new Error(
396+
"下载资源失败。这或许是因为浏览器的跨域限制。你可以尝试手动上传图片。",
397+
);
398+
}
399+
throw e;
400+
}
401+
}
402+
403+
listenMain<FetchAssetMessage>("fetchAsset", worker, fetchAsset);

src/compiler/typst.worker.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ import {
1212
withAccessModel,
1313
withPackageRegistry,
1414
} from "@myriaddreamin/typst.ts/options.init";
15-
import { listen } from "promise-worker-ts";
15+
import { listen, sendToMain } from "@mr.python/promise-worker-ts";
1616
import { Mutex } from "async-mutex";
17-
import axiosInstance from "@/utils/axiosInstance";
1817
import type ContestData from "../types/contestData";
1918

2019
import TypstDocMain from "typst-template/main.typ?raw";
@@ -71,19 +70,13 @@ async function typstPrepare(
7170
await Promise.all(
7271
assets.map(async ([filename, assetUrl]) => {
7372
const cached = shadowCacheTime.get(filename);
74-
if (cached === undefined) {
75-
const res = await axiosInstance
76-
.get<ArrayBuffer>(assetUrl)
77-
.catch((e) => {
78-
if (isAxiosError(e)) {
79-
console.error("Failed to download assets.", e);
80-
throw new Error(
81-
"下载资源失败。这或许是因为浏览器的跨域限制。你可以尝试手动上传图片。",
82-
);
83-
} else throw e;
84-
});
85-
$typst.mapShadow("/" + filename, new Uint8Array(res.data));
86-
}
73+
if (cached === undefined)
74+
$typst.mapShadow(
75+
"/" + filename,
76+
new Uint8Array(
77+
await sendToMain<FetchAssetMessage>("fetchAsset", assetUrl),
78+
),
79+
);
8780
shadowCacheTime.set(filename, -1);
8881
}),
8982
);
@@ -118,8 +111,7 @@ listen<RenderTypstMessage>("renderTypst", async ([data, assets]) =>
118111
),
119112
);
120113

121-
import type { PromiseWorkerTagged } from "promise-worker-ts";
122-
import { isAxiosError } from "axios";
114+
import type { PromiseWorkerTagged } from "@mr.python/promise-worker-ts";
123115
export type InitMessage = PromiseWorkerTagged<
124116
"init",
125117
{
@@ -140,3 +132,8 @@ export type RenderTypstMessage = PromiseWorkerTagged<
140132
[ContestData<{ withTypst: true }>, [string, string][]],
141133
string | undefined
142134
>;
135+
export type FetchAssetMessage = PromiseWorkerTagged<
136+
"fetchAsset",
137+
string,
138+
ArrayBuffer
139+
>;

src/contestEditor/body.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ const Body: FC<{
2020
updateContestData: Updater<ImmerContestData>;
2121
panel: string;
2222
setPanel: Dispatch<SetStateAction<string>>;
23-
}> = ({ panel, contestData, updateContestData, setPanel }) => {
23+
}> = ({
24+
panel,
25+
contestData,
26+
updateContestData,
27+
setPanel,
28+
}) => {
2429
const divRef = useRef<HTMLDivElement>(null);
2530
const [sizes, setSizes] = useState<number[] | undefined>(undefined);
2631

@@ -58,7 +63,13 @@ const Body: FC<{
5863
size={sizes === undefined ? "50%" : sizes[0]}
5964
>
6065
{panel === "config" ? (
61-
<ConfigPanel {...{ contestData, updateContestData, setPanel }} />
66+
<ConfigPanel
67+
{...{
68+
contestData,
69+
updateContestData,
70+
setPanel,
71+
}}
72+
/>
6273
) : (
6374
<MarkdownPanel
6475
{...(panel === "precaution"
@@ -73,7 +84,7 @@ const Body: FC<{
7384
: {
7485
code: (() => {
7586
const p = contestData.problems.find(
76-
(y) => y.key === panel,
87+
(y) => y.key === panel
7788
);
7889
if (!p) throw new Error("Target panel not found");
7990
return p.statementMarkdown;

src/contestEditor/configPanel.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
faXmark,
3333
} from "@fortawesome/free-solid-svg-icons";
3434
import { newProblem, removeProblemCallback } from "@/utils/contestDataUtils";
35+
import { addImage, deleteImage } from "@/utils/imageManager";
3536

3637
import "./configPanel.css";
3738
import { faMarkdown } from "@fortawesome/free-brands-svg-icons";
@@ -48,7 +49,11 @@ const ConfigPanel: FC<{
4849
contestData: ImmerContestData;
4950
updateContestData: Updater<ImmerContestData>;
5051
setPanel: Dispatch<SetStateAction<string>>;
51-
}> = ({ contestData, updateContestData, setPanel }) => {
52+
}> = ({
53+
contestData,
54+
updateContestData,
55+
setPanel,
56+
}) => {
5257
const { modal, message } = App.useApp();
5358
const formRef = useRef<HTMLFormElement>(null);
5459
const [width, setWidth] = useState(220);
@@ -571,9 +576,10 @@ const ConfigPanel: FC<{
571576
<div className="contest-editor-config-label contest-editor-config-image">
572577
<div>本地图片</div>
573578
<div>
574-
{contestData.images.map((img, index) => (
579+
{contestData.images.map((img, index) => {
580+
return (
575581
<Card
576-
key={img.url}
582+
key={img.uuid}
577583
classNames={{ body: "contest-editor-config-image-card" }}
578584
>
579585
<Image src={img.url} alt={img.name} height={150} />
@@ -607,7 +613,7 @@ const ConfigPanel: FC<{
607613
icon={<FontAwesomeIcon icon={faMarkdown} />}
608614
onClick={() =>
609615
navigator.clipboard
610-
.writeText(`![${img.name}](${img.url})`)
616+
.writeText(`![${img.name}](asset://${img.uuid})`)
611617
.then(
612618
() => message.success("复制成功"),
613619
(e) => {
@@ -623,15 +629,18 @@ const ConfigPanel: FC<{
623629
<Button
624630
type="text"
625631
icon={<FontAwesomeIcon icon={faTrashCan} />}
626-
onClick={() =>
627-
updateContestData((x) => {
628-
x.images.splice(index, 1);
629-
})
630-
}
632+
onClick={async () => {
633+
const imageToDelete = contestData.images[index];
634+
await deleteImage({
635+
uuid: imageToDelete.uuid,
636+
updateContestData,
637+
});
638+
}}
631639
/>
632640
</div>
633641
</Card>
634-
))}
642+
);
643+
})}
635644
<Upload.Dragger
636645
name="add image"
637646
beforeUpload={(file) => {
@@ -649,14 +658,12 @@ const ConfigPanel: FC<{
649658
return true;
650659
}}
651660
customRequest={async (options) => {
652-
console.log(options);
653661
const file = options.file;
654662
if (!(file instanceof File)) throw new Error("Invalid file");
655-
updateContestData((x) => {
656-
x.images.push({
657-
name: file.name,
658-
url: URL.createObjectURL(file),
659-
});
663+
664+
await addImage({
665+
file,
666+
updateContestData,
660667
});
661668
}}
662669
showUploadList={false}

0 commit comments

Comments
 (0)