Skip to content

Commit 5554ea7

Browse files
committed
feat: save editor presets locally
1 parent 026cde7 commit 5554ea7

6 files changed

Lines changed: 919 additions & 36 deletions

File tree

src/lib/editor/draft-storage.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { JSONContent } from "@tiptap/core";
2-
import type { DcExportStructure } from "$lib/dc/export-document";
2+
import type { DcDocumentTheme, DcExportStructure } from "$lib/dc/export-document";
33
import {
44
isSupportedLanguage,
55
isSupportedTheme,
@@ -18,6 +18,7 @@ export type DraftPreferences = {
1818
selectionFontSize: string;
1919
codeFontSize: string;
2020
showLineNumbers: boolean;
21+
documentTheme: DcDocumentTheme;
2122
structure: DcExportStructure;
2223
};
2324

@@ -82,24 +83,43 @@ function isExportStructure(value: unknown): value is DcExportStructure {
8283
return value === "modern" || value === "dcTable";
8384
}
8485

85-
function isDraftPreferences(value: unknown): value is DraftPreferences {
86+
function isDocumentTheme(value: unknown): value is DcDocumentTheme {
87+
return value === "lightLecture" || value === "darkEditorial";
88+
}
89+
90+
function normalizeDraftPreferences(value: unknown): DraftPreferences | undefined {
8691
if (!isRecord(value)) {
87-
return false;
92+
return undefined;
93+
}
94+
95+
if (
96+
typeof value.language !== "string" ||
97+
!isSupportedLanguage(value.language) ||
98+
typeof value.theme !== "string" ||
99+
!isSupportedTheme(value.theme) ||
100+
typeof value.bodyFontFamily !== "string" ||
101+
typeof value.bodyFontSize !== "string" ||
102+
typeof value.selectionFontFamily !== "string" ||
103+
typeof value.selectionFontSize !== "string" ||
104+
typeof value.codeFontSize !== "string" ||
105+
typeof value.showLineNumbers !== "boolean" ||
106+
!isExportStructure(value.structure)
107+
) {
108+
return undefined;
88109
}
89110

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-
);
111+
return {
112+
language: value.language,
113+
theme: value.theme,
114+
bodyFontFamily: value.bodyFontFamily,
115+
bodyFontSize: value.bodyFontSize,
116+
selectionFontFamily: value.selectionFontFamily,
117+
selectionFontSize: value.selectionFontSize,
118+
codeFontSize: value.codeFontSize,
119+
showLineNumbers: value.showLineNumbers,
120+
documentTheme: isDocumentTheme(value.documentTheme) ? value.documentTheme : "lightLecture",
121+
structure: value.structure,
122+
};
103123
}
104124

105125
export function createDraftSnapshot(
@@ -131,15 +151,21 @@ export function parseDraftSnapshot(value: string): DraftSnapshot | undefined {
131151
return undefined;
132152
}
133153

134-
if (!isJsonContent(parsed.document) || !isDraftPreferences(parsed.preferences)) {
154+
if (!isJsonContent(parsed.document)) {
155+
return undefined;
156+
}
157+
158+
const preferences = normalizeDraftPreferences(parsed.preferences);
159+
160+
if (!preferences) {
135161
return undefined;
136162
}
137163

138164
return {
139165
version: 1,
140166
updatedAt: parsed.updatedAt,
141167
document: parsed.document,
142-
preferences: parsed.preferences,
168+
preferences,
143169
};
144170
}
145171

src/lib/editor/preset-storage.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { JSONContent } from "@tiptap/core";
2+
import { createDraftSnapshot, parseDraftSnapshot, type DraftPreferences } from "./draft-storage";
3+
4+
export const presetStorageKey = "dc-code-paste:presets:v1";
5+
export const maxPresetCount = 30;
6+
7+
export type PresetSnapshot = {
8+
version: 1;
9+
id: string;
10+
name: string;
11+
createdAt: string;
12+
updatedAt: string;
13+
document: JSONContent;
14+
preferences: DraftPreferences;
15+
};
16+
17+
type PresetStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
18+
19+
function isRecord(value: unknown): value is Record<string, unknown> {
20+
return typeof value === "object" && value !== null && !Array.isArray(value);
21+
}
22+
23+
function normalizePresetName(value: string): string {
24+
const normalized = value.replace(/\s+/g, " ").trim();
25+
return normalized ? normalized.slice(0, 60) : "새 프리셋";
26+
}
27+
28+
function createPresetId(): string {
29+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
30+
return crypto.randomUUID();
31+
}
32+
33+
return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
34+
}
35+
36+
function parsePresetRecord(value: unknown): PresetSnapshot | undefined {
37+
if (
38+
!isRecord(value) ||
39+
value.version !== 1 ||
40+
typeof value.id !== "string" ||
41+
typeof value.name !== "string" ||
42+
typeof value.createdAt !== "string" ||
43+
typeof value.updatedAt !== "string"
44+
) {
45+
return undefined;
46+
}
47+
48+
const draft = parseDraftSnapshot(
49+
JSON.stringify({
50+
version: 1,
51+
updatedAt: value.updatedAt,
52+
document: value.document,
53+
preferences: value.preferences,
54+
}),
55+
);
56+
57+
if (!draft) {
58+
return undefined;
59+
}
60+
61+
return {
62+
version: 1,
63+
id: value.id,
64+
name: normalizePresetName(value.name),
65+
createdAt: value.createdAt,
66+
updatedAt: value.updatedAt,
67+
document: draft.document,
68+
preferences: draft.preferences,
69+
};
70+
}
71+
72+
export function createPresetSnapshot(
73+
name: string,
74+
document: JSONContent,
75+
preferences: DraftPreferences,
76+
): PresetSnapshot {
77+
const draft = createDraftSnapshot(document, preferences);
78+
79+
return {
80+
version: 1,
81+
id: createPresetId(),
82+
name: normalizePresetName(name),
83+
createdAt: draft.updatedAt,
84+
updatedAt: draft.updatedAt,
85+
document: draft.document,
86+
preferences: draft.preferences,
87+
};
88+
}
89+
90+
export function parsePresetSnapshots(value: string): PresetSnapshot[] {
91+
let parsed: unknown;
92+
93+
try {
94+
parsed = JSON.parse(value);
95+
} catch {
96+
return [];
97+
}
98+
99+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.presets)) {
100+
return [];
101+
}
102+
103+
return parsed.presets
104+
.map(parsePresetRecord)
105+
.filter((preset): preset is PresetSnapshot => Boolean(preset))
106+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
107+
.slice(0, maxPresetCount);
108+
}
109+
110+
export function readPresetSnapshots(storage: PresetStorage): PresetSnapshot[] {
111+
try {
112+
const stored = storage.getItem(presetStorageKey);
113+
return stored ? parsePresetSnapshots(stored) : [];
114+
} catch {
115+
return [];
116+
}
117+
}
118+
119+
export function writePresetSnapshots(
120+
storage: PresetStorage,
121+
presets: readonly PresetSnapshot[],
122+
): boolean {
123+
try {
124+
storage.setItem(
125+
presetStorageKey,
126+
JSON.stringify({
127+
version: 1,
128+
presets: presets.slice(0, maxPresetCount),
129+
}),
130+
);
131+
return true;
132+
} catch {
133+
return false;
134+
}
135+
}
136+
137+
export function deletePresetSnapshot(storage: PresetStorage, id: string): PresetSnapshot[] {
138+
const nextPresets = readPresetSnapshots(storage).filter((preset) => preset.id !== id);
139+
return writePresetSnapshots(storage, nextPresets) ? nextPresets : readPresetSnapshots(storage);
140+
}
141+
142+
export function clearPresetSnapshots(storage: PresetStorage): boolean {
143+
try {
144+
storage.removeItem(presetStorageKey);
145+
return true;
146+
} catch {
147+
return false;
148+
}
149+
}

0 commit comments

Comments
 (0)