Skip to content

Commit e8ab1d5

Browse files
committed
feat: expand rich paste editor tools
1 parent 5554ea7 commit e8ab1d5

20 files changed

Lines changed: 1775 additions & 48 deletions

src/lib/dc/export-document.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type { CalloutKind } from "$lib/editor/callout";
1111
import { calloutKindFromNodeName, normalizeCalloutKind } from "$lib/editor/callout";
1212
import { normalizeEditableLinkHref } from "$lib/editor/link";
1313
import { safeCodeFontFamily, safeProseFontFamily } from "./font-stacks";
14+
import { normalizeCodeFilename } from "$lib/highlighter/code-block-metadata";
15+
import { normalizeHighlightLines } from "$lib/highlighter/highlight-lines";
1416

1517
export type DcExportOptions = {
1618
theme: DcThemeId;
@@ -616,13 +618,17 @@ async function renderCodeBlock(node: JSONContent, options: DcExportOptions): Pro
616618
typeof node.attrs?.language === "string" && isSupportedLanguage(node.attrs.language)
617619
? node.attrs.language
618620
: defaultLanguage;
621+
const highlightLines = normalizeHighlightLines(node.attrs?.highlightLines);
622+
const filename = normalizeCodeFilename(node.attrs?.filename);
619623

620624
return highlightForDcHtml(textOf(node), {
621625
language,
622626
theme: options.theme || defaultTheme,
623627
showBackground: true,
624628
showLineNumbers: options.showLineNumbers,
629+
filename,
625630
fontSize: safeSize(options.codeFontSize, "14px"),
631+
highlightLines,
626632
});
627633
}
628634

src/lib/dc/render-dc-html.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,17 @@ export type DcRenderInput = {
1212
lines: readonly (readonly DcToken[])[];
1313
background: string;
1414
foreground: string;
15+
filename?: string;
1516
fontSize?: string;
1617
showBackground: boolean;
1718
showLineNumbers: boolean;
19+
lineDecorations?: readonly (DcLineDecoration | undefined)[];
20+
};
21+
22+
export type DcLineDecoration = {
23+
background?: string;
24+
foreground?: string;
25+
borderColor?: string;
1826
};
1927

2028
const fallbackBackground = "oklch(18.22% 0.017 258.21)";
@@ -66,13 +74,15 @@ function renderLineNumber(index: number, foreground: string, width: string): str
6674
export function renderDcHtml(input: DcRenderInput): string {
6775
const background = sanitizeColor(input.background, fallbackBackground);
6876
const foreground = sanitizeColor(input.foreground, fallbackForeground);
77+
const filename = input.filename?.trim();
78+
const hasDecorations = input.lineDecorations?.some(Boolean) ?? false;
6979
const preStyle = joinStyle({
7080
"background-color": input.showBackground ? background : undefined,
7181
color: foreground,
7282
"font-family": safeCodeFontFamily(),
7383
"font-size": input.fontSize ?? "14px",
7484
"line-height": "1.58",
75-
margin: "0 0 16px",
85+
margin: filename ? 0 : "0 0 16px",
7686
padding: input.showBackground ? "14px 16px" : 0,
7787
"white-space": "pre-wrap",
7888
"word-break": "normal",
@@ -82,12 +92,69 @@ export function renderDcHtml(input: DcRenderInput): string {
8292

8393
const lineNumberWidth = `${Math.max(2, String(input.lines.length).length)}ch`;
8494
const renderedLines = input.lines.map((line, index) => {
95+
const decoration = input.lineDecorations?.[index];
96+
const lineForeground = sanitizeColor(decoration?.foreground, foreground);
8597
const prefix = input.showLineNumbers
86-
? renderLineNumber(index, foreground, lineNumberWidth)
98+
? renderLineNumber(index, lineForeground, lineNumberWidth)
8799
: "";
88-
const body = line.map((token) => renderToken(token, foreground)).join("");
89-
return `${prefix}${body || " "}`;
100+
const body = line.map((token) => renderToken(token, lineForeground)).join("");
101+
const content = `${prefix}${body || " "}`;
102+
103+
if (!decoration) {
104+
if (!hasDecorations) {
105+
return content;
106+
}
107+
108+
const neutralLineStyle = joinStyle({
109+
display: "block",
110+
margin: input.showBackground ? "0 -16px" : undefined,
111+
padding: input.showBackground ? "0 16px" : undefined,
112+
"box-sizing": "border-box",
113+
});
114+
115+
return `<span style="${neutralLineStyle}">${content}</span>`;
116+
}
117+
118+
const lineStyle = joinStyle({
119+
display: "block",
120+
margin: input.showBackground ? "0 -16px" : undefined,
121+
padding: input.showBackground ? "0 16px 0 12px" : "0 0 0 8px",
122+
"background-color": sanitizeColor(decoration.background, "transparent"),
123+
color: lineForeground,
124+
"border-left": `4px solid ${sanitizeColor(decoration.borderColor, lineForeground)}`,
125+
"box-sizing": "border-box",
126+
});
127+
128+
return `<span style="${lineStyle}">${content}</span>`;
129+
});
130+
131+
const code = `<pre style="${preStyle}"><code>${renderedLines.join("\n")}</code></pre>`;
132+
133+
if (!filename) {
134+
return code;
135+
}
136+
137+
const wrapperStyle = joinStyle({
138+
margin: "0 0 16px",
139+
"border-radius": "7px",
140+
overflow: "hidden",
141+
"background-color": input.showBackground ? background : undefined,
142+
});
143+
const headerStyle = joinStyle({
144+
display: "block",
145+
"background-color": "oklch(20.16% 0.018 257.49)",
146+
color: "oklch(89.72% 0.019 247.91)",
147+
"font-family": safeCodeFontFamily(),
148+
"font-size": "12px",
149+
"font-weight": 800,
150+
"line-height": 1.2,
151+
padding: "8px 12px",
152+
"border-bottom": "1px solid oklch(32.15% 0.026 257.34)",
153+
"box-sizing": "border-box",
154+
"white-space": "nowrap",
155+
overflow: "hidden",
156+
"text-overflow": "ellipsis",
90157
});
91158

92-
return `<pre style="${preStyle}"><code>${renderedLines.join("\n")}</code></pre>`;
159+
return `<div style="${wrapperStyle}"><div style="${headerStyle}">${escapeHtml(filename)}</div>${code}</div>`;
93160
}

src/lib/editor/draft-storage.ts

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
} from "$lib/highlighter/catalog";
99

1010
export const draftStorageKey = "dc-code-paste:draft:v1";
11+
export const draftHistoryStorageKey = "dc-code-paste:draft-history:v1";
12+
export const maxDraftHistoryCount = 10;
1113

1214
export type DraftPreferences = {
1315
language: DcLanguageId;
@@ -29,6 +31,11 @@ export type DraftSnapshot = {
2931
preferences: DraftPreferences;
3032
};
3133

34+
export type DraftHistorySnapshot = DraftSnapshot & {
35+
id: string;
36+
createdAt: string;
37+
};
38+
3239
type DraftStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
3340

3441
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -122,6 +129,59 @@ function normalizeDraftPreferences(value: unknown): DraftPreferences | undefined
122129
};
123130
}
124131

132+
function createSnapshotId() {
133+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
134+
return crypto.randomUUID();
135+
}
136+
137+
return `draft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
138+
}
139+
140+
function normalizeDraftSnapshot(value: unknown): DraftSnapshot | undefined {
141+
if (!isRecord(value)) {
142+
return undefined;
143+
}
144+
145+
if (value.version !== 1 || typeof value.updatedAt !== "string") {
146+
return undefined;
147+
}
148+
149+
if (!isJsonContent(value.document)) {
150+
return undefined;
151+
}
152+
153+
const preferences = normalizeDraftPreferences(value.preferences);
154+
155+
if (!preferences) {
156+
return undefined;
157+
}
158+
159+
return {
160+
version: 1,
161+
updatedAt: value.updatedAt,
162+
document: value.document,
163+
preferences,
164+
};
165+
}
166+
167+
function normalizeDraftHistorySnapshot(value: unknown): DraftHistorySnapshot | undefined {
168+
if (!isRecord(value) || typeof value.id !== "string" || typeof value.createdAt !== "string") {
169+
return undefined;
170+
}
171+
172+
const snapshot = normalizeDraftSnapshot(value);
173+
174+
if (!snapshot) {
175+
return undefined;
176+
}
177+
178+
return {
179+
...snapshot,
180+
id: value.id,
181+
createdAt: value.createdAt,
182+
};
183+
}
184+
125185
export function createDraftSnapshot(
126186
document: JSONContent,
127187
preferences: DraftPreferences,
@@ -134,6 +194,22 @@ export function createDraftSnapshot(
134194
};
135195
}
136196

197+
export function createDraftHistorySnapshot(
198+
document: JSONContent,
199+
preferences: DraftPreferences,
200+
): DraftHistorySnapshot {
201+
const timestamp = new Date().toISOString();
202+
203+
return {
204+
version: 1,
205+
id: createSnapshotId(),
206+
createdAt: timestamp,
207+
updatedAt: timestamp,
208+
document,
209+
preferences,
210+
};
211+
}
212+
137213
export function parseDraftSnapshot(value: string): DraftSnapshot | undefined {
138214
let parsed: unknown;
139215

@@ -143,30 +219,26 @@ export function parseDraftSnapshot(value: string): DraftSnapshot | undefined {
143219
return undefined;
144220
}
145221

146-
if (!isRecord(parsed)) {
147-
return undefined;
148-
}
222+
return normalizeDraftSnapshot(parsed);
223+
}
149224

150-
if (parsed.version !== 1 || typeof parsed.updatedAt !== "string") {
151-
return undefined;
152-
}
225+
export function parseDraftHistorySnapshots(value: string): DraftHistorySnapshot[] {
226+
let parsed: unknown;
153227

154-
if (!isJsonContent(parsed.document)) {
155-
return undefined;
228+
try {
229+
parsed = JSON.parse(value);
230+
} catch {
231+
return [];
156232
}
157233

158-
const preferences = normalizeDraftPreferences(parsed.preferences);
159-
160-
if (!preferences) {
161-
return undefined;
234+
if (!Array.isArray(parsed)) {
235+
return [];
162236
}
163237

164-
return {
165-
version: 1,
166-
updatedAt: parsed.updatedAt,
167-
document: parsed.document,
168-
preferences,
169-
};
238+
return parsed
239+
.map((item) => normalizeDraftHistorySnapshot(item))
240+
.filter((item): item is DraftHistorySnapshot => Boolean(item))
241+
.slice(0, maxDraftHistoryCount);
170242
}
171243

172244
export function readDraftSnapshot(storage: DraftStorage): DraftSnapshot | undefined {
@@ -178,6 +250,15 @@ export function readDraftSnapshot(storage: DraftStorage): DraftSnapshot | undefi
178250
}
179251
}
180252

253+
export function readDraftHistorySnapshots(storage: DraftStorage): DraftHistorySnapshot[] {
254+
try {
255+
const stored = storage.getItem(draftHistoryStorageKey);
256+
return stored ? parseDraftHistorySnapshots(stored) : [];
257+
} catch {
258+
return [];
259+
}
260+
}
261+
181262
export function writeDraftSnapshot(storage: DraftStorage, snapshot: DraftSnapshot): boolean {
182263
try {
183264
storage.setItem(draftStorageKey, JSON.stringify(snapshot));
@@ -187,6 +268,44 @@ export function writeDraftSnapshot(storage: DraftStorage, snapshot: DraftSnapsho
187268
}
188269
}
189270

271+
export function writeDraftHistorySnapshots(
272+
storage: DraftStorage,
273+
snapshots: DraftHistorySnapshot[],
274+
): boolean {
275+
try {
276+
storage.setItem(
277+
draftHistoryStorageKey,
278+
JSON.stringify(snapshots.slice(0, maxDraftHistoryCount)),
279+
);
280+
return true;
281+
} catch {
282+
return false;
283+
}
284+
}
285+
286+
export function appendDraftHistorySnapshot(
287+
storage: DraftStorage,
288+
snapshot: DraftHistorySnapshot,
289+
): DraftHistorySnapshot[] {
290+
const current = readDraftHistorySnapshots(storage);
291+
const next = [snapshot, ...current.filter((item) => item.id !== snapshot.id)].slice(
292+
0,
293+
maxDraftHistoryCount,
294+
);
295+
296+
return writeDraftHistorySnapshots(storage, next) ? next : current;
297+
}
298+
299+
export function deleteDraftHistorySnapshot(
300+
storage: DraftStorage,
301+
id: string,
302+
): DraftHistorySnapshot[] {
303+
const current = readDraftHistorySnapshots(storage);
304+
const next = current.filter((item) => item.id !== id);
305+
306+
return writeDraftHistorySnapshots(storage, next) ? next : current;
307+
}
308+
190309
export function clearDraftSnapshot(storage: DraftStorage): boolean {
191310
try {
192311
storage.removeItem(draftStorageKey);
@@ -195,3 +314,12 @@ export function clearDraftSnapshot(storage: DraftStorage): boolean {
195314
return false;
196315
}
197316
}
317+
318+
export function clearDraftHistorySnapshots(storage: DraftStorage): boolean {
319+
try {
320+
storage.removeItem(draftHistoryStorageKey);
321+
return true;
322+
} catch {
323+
return false;
324+
}
325+
}

0 commit comments

Comments
 (0)