Skip to content

Commit 644925c

Browse files
committed
feat: persist editor drafts
1 parent ff8f856 commit 644925c

6 files changed

Lines changed: 430 additions & 3 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ The app is built with `@sveltejs/adapter-static`.
3636
https://0disoft.github.io/dc-code-paste/
3737
```
3838

39+
The repository can stay private while the editor is still being shaped. Enable GitHub Pages after
40+
the repository is ready to become public.
41+
3942
## Paste Contract
4043

4144
The app keeps an editor document model internally, then exports inline-style HTML for rich-text
@@ -92,6 +95,19 @@ editors that expect raw HTML source.
9295
Older `calloutBox` documents with a `kind` attribute are still accepted by the exporter, but new
9396
editor content uses the explicit box node names above.
9497

98+
## Drafts
99+
100+
The editor saves the current draft in browser `localStorage` after the editor is ready:
101+
102+
- Tiptap document JSON
103+
- Code language and Shiki theme
104+
- Body, selection, and code font settings
105+
- Line number setting
106+
- Paste structure: `DC 테이블` or `기본`
107+
108+
The draft is restored on the next page load. `초기화` clears the saved draft and returns the editor
109+
to the bundled sample article.
110+
95111
## Manual DC Paste Check
96112

97113
Before treating a release as ready, check both paste paths in a browser:
@@ -102,3 +118,7 @@ Before treating a release as ready, check both paste paths in a browser:
102118
3. Switch the preview panel to `HTML`, click `원문 복사`, paste into DCInside's HTML mode, and confirm
103119
the same article renders after leaving HTML mode.
104120
4. Confirm code colors, box spacing, links, and body font size match the preview closely enough.
121+
5. Refresh the app after editing text and changing paste structure; confirm the draft comes back.
122+
6. Click `초기화`, refresh, and confirm the sample article comes back instead of the previous draft.
123+
7. In `DC 테이블`, confirm exported HTML contains `table`, `td`, and `bgcolor`.
124+
8. In `기본`, confirm exported HTML keeps the simpler root `div` structure.

src/lib/editor/draft-storage.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type { JSONContent } from "@tiptap/core";
2+
import type { DcExportStructure } from "$lib/dc/export-document";
3+
import {
4+
isSupportedLanguage,
5+
isSupportedTheme,
6+
type DcLanguageId,
7+
type DcThemeId,
8+
} from "$lib/highlighter/catalog";
9+
10+
export const draftStorageKey = "dc-code-paste:draft:v1";
11+
12+
export type DraftPreferences = {
13+
language: DcLanguageId;
14+
theme: DcThemeId;
15+
bodyFontFamily: string;
16+
bodyFontSize: string;
17+
selectionFontFamily: string;
18+
selectionFontSize: string;
19+
codeFontSize: string;
20+
showLineNumbers: boolean;
21+
structure: DcExportStructure;
22+
};
23+
24+
export type DraftSnapshot = {
25+
version: 1;
26+
updatedAt: string;
27+
document: JSONContent;
28+
preferences: DraftPreferences;
29+
};
30+
31+
type DraftStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
32+
33+
function isRecord(value: unknown): value is Record<string, unknown> {
34+
return typeof value === "object" && value !== null && !Array.isArray(value);
35+
}
36+
37+
function isJsonContent(value: unknown, depth = 0): value is JSONContent {
38+
if (!isRecord(value) || depth > 80) {
39+
return false;
40+
}
41+
42+
if ("type" in value && typeof value.type !== "string") {
43+
return false;
44+
}
45+
46+
if ("text" in value && typeof value.text !== "string") {
47+
return false;
48+
}
49+
50+
if ("attrs" in value && !isRecord(value.attrs)) {
51+
return false;
52+
}
53+
54+
if ("marks" in value) {
55+
if (!Array.isArray(value.marks)) {
56+
return false;
57+
}
58+
59+
for (const mark of value.marks) {
60+
if (!isRecord(mark) || typeof mark.type !== "string") {
61+
return false;
62+
}
63+
64+
if ("attrs" in mark && !isRecord(mark.attrs)) {
65+
return false;
66+
}
67+
}
68+
}
69+
70+
if ("content" in value) {
71+
if (!Array.isArray(value.content)) {
72+
return false;
73+
}
74+
75+
return value.content.every((child) => isJsonContent(child, depth + 1));
76+
}
77+
78+
return typeof value.type === "string" || typeof value.text === "string";
79+
}
80+
81+
function isExportStructure(value: unknown): value is DcExportStructure {
82+
return value === "modern" || value === "dcTable";
83+
}
84+
85+
function isDraftPreferences(value: unknown): value is DraftPreferences {
86+
if (!isRecord(value)) {
87+
return false;
88+
}
89+
90+
return (
91+
typeof value.language === "string" &&
92+
isSupportedLanguage(value.language) &&
93+
typeof value.theme === "string" &&
94+
isSupportedTheme(value.theme) &&
95+
typeof value.bodyFontFamily === "string" &&
96+
typeof value.bodyFontSize === "string" &&
97+
typeof value.selectionFontFamily === "string" &&
98+
typeof value.selectionFontSize === "string" &&
99+
typeof value.codeFontSize === "string" &&
100+
typeof value.showLineNumbers === "boolean" &&
101+
isExportStructure(value.structure)
102+
);
103+
}
104+
105+
export function createDraftSnapshot(
106+
document: JSONContent,
107+
preferences: DraftPreferences,
108+
): DraftSnapshot {
109+
return {
110+
version: 1,
111+
updatedAt: new Date().toISOString(),
112+
document,
113+
preferences,
114+
};
115+
}
116+
117+
export function parseDraftSnapshot(value: string): DraftSnapshot | undefined {
118+
let parsed: unknown;
119+
120+
try {
121+
parsed = JSON.parse(value);
122+
} catch {
123+
return undefined;
124+
}
125+
126+
if (!isRecord(parsed)) {
127+
return undefined;
128+
}
129+
130+
if (parsed.version !== 1 || typeof parsed.updatedAt !== "string") {
131+
return undefined;
132+
}
133+
134+
if (!isJsonContent(parsed.document) || !isDraftPreferences(parsed.preferences)) {
135+
return undefined;
136+
}
137+
138+
return {
139+
version: 1,
140+
updatedAt: parsed.updatedAt,
141+
document: parsed.document,
142+
preferences: parsed.preferences,
143+
};
144+
}
145+
146+
export function readDraftSnapshot(storage: DraftStorage): DraftSnapshot | undefined {
147+
try {
148+
const stored = storage.getItem(draftStorageKey);
149+
return stored ? parseDraftSnapshot(stored) : undefined;
150+
} catch {
151+
return undefined;
152+
}
153+
}
154+
155+
export function writeDraftSnapshot(storage: DraftStorage, snapshot: DraftSnapshot): boolean {
156+
try {
157+
storage.setItem(draftStorageKey, JSON.stringify(snapshot));
158+
return true;
159+
} catch {
160+
return false;
161+
}
162+
}
163+
164+
export function clearDraftSnapshot(storage: DraftStorage): boolean {
165+
try {
166+
storage.removeItem(draftStorageKey);
167+
return true;
168+
} catch {
169+
return false;
170+
}
171+
}

src/lib/highlighter/catalog.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ export const defaultTheme: DcThemeId = "github-dark";
3939
export function isSupportedLanguage(value: string): value is DcLanguageId {
4040
return supportedLanguages.some((language) => language.id === value);
4141
}
42+
43+
export function isSupportedTheme(value: string): value is DcThemeId {
44+
return supportedThemes.some((theme) => theme.id === value);
45+
}

0 commit comments

Comments
 (0)