viteboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/viteboard.mjs +59 -0
- package/content/docs/assets/image-mqjpqlyw.png +0 -0
- package/content/docs/assets/image-mqjsmenr.png +0 -0
- package/content/docs/boards/product-roadmap.board.json +2130 -0
- package/content/docs/capabilities.md +89 -0
- package/content/docs/docs.md +84 -0
- package/content/docs/guide/boards-in-markdown.md +48 -0
- package/content/docs/guide/boards.md +92 -0
- package/content/docs/guide/getting-started.md +71 -0
- package/content/docs/guide/test.md +6 -0
- package/content/docs/index.md +47 -0
- package/content/docs/product-roadmap.board.json +2130 -0
- package/content/docs/test.md +219 -0
- package/content/docs/tetst-dir/test-board.board.json +15 -0
- package/content/docs/tetst-dir/test-test.md +1 -0
- package/content/docs/workspace.md +73 -0
- package/dist/assets/index-CEpxLM2o.css +1 -0
- package/dist/assets/index-D4xvJdEQ.js +60 -0
- package/dist/index.html +13 -0
- package/index.html +13 -0
- package/package.json +52 -0
- package/src/app/AppController.ts +955 -0
- package/src/app/BoardState.ts +130 -0
- package/src/app/ClipboardService.ts +159 -0
- package/src/app/CommandManager.ts +66 -0
- package/src/app/ElementActions.ts +152 -0
- package/src/app/EmbedRegionSelector.ts +103 -0
- package/src/app/ExportPng.ts +107 -0
- package/src/app/ImageInsertService.ts +141 -0
- package/src/app/KeyboardShortcuts.ts +124 -0
- package/src/app/main.ts +6 -0
- package/src/board/BoardPreview.ts +189 -0
- package/src/board/BoardService.ts +222 -0
- package/src/canvas/CanvasRenderer.ts +253 -0
- package/src/canvas/CanvasSurface.ts +70 -0
- package/src/canvas/HitTester.ts +110 -0
- package/src/canvas/ImageCache.ts +123 -0
- package/src/canvas/PerformanceMonitor.ts +31 -0
- package/src/canvas/RenderScheduler.ts +26 -0
- package/src/canvas/Viewport.ts +77 -0
- package/src/content/ContentApi.ts +258 -0
- package/src/content/Markdown.ts +431 -0
- package/src/content/MarkdownView.ts +70 -0
- package/src/editor/DocEditor.ts +799 -0
- package/src/editor/htmlToMarkdown.ts +333 -0
- package/src/elements/renderElement.ts +509 -0
- package/src/elements/types.ts +118 -0
- package/src/shell/Shell.ts +2950 -0
- package/src/shell/Sidebar.ts +1352 -0
- package/src/storage/AssetStore.ts +86 -0
- package/src/storage/BoardSerializer.ts +114 -0
- package/src/storage/ImportExportService.ts +153 -0
- package/src/storage/IndexedDbStore.ts +92 -0
- package/src/storage/LocalBoardStore.ts +104 -0
- package/src/styles.css +3257 -0
- package/src/templates/helpers.ts +124 -0
- package/src/templates/index.ts +65 -0
- package/src/templates/journeyMapTemplate.ts +52 -0
- package/src/templates/opportunityTreeTemplate.ts +45 -0
- package/src/templates/personaTemplate.ts +41 -0
- package/src/templates/prioritizationMatrixTemplate.ts +44 -0
- package/src/templates/requirementsFlowTemplate.ts +45 -0
- package/src/templates/screenshotReviewTemplate.ts +52 -0
- package/src/templates/systemDiagramTemplate.ts +41 -0
- package/src/testing/StressTestGenerator.ts +134 -0
- package/src/testing/StressTestPanel.ts +64 -0
- package/src/tools/ArrowTool.ts +87 -0
- package/src/tools/ImageTool.ts +39 -0
- package/src/tools/PanTool.ts +34 -0
- package/src/tools/SelectTool.ts +377 -0
- package/src/tools/ShapeTool.ts +106 -0
- package/src/tools/StickyTool.ts +69 -0
- package/src/tools/TaskTool.ts +52 -0
- package/src/tools/TextTool.ts +45 -0
- package/src/tools/ToolContext.ts +44 -0
- package/src/tools/ToolController.ts +178 -0
- package/src/ui/Modal.ts +58 -0
- package/src/ui/PerformanceOverlay.ts +75 -0
- package/src/ui/StorageManager.ts +94 -0
- package/src/ui/TemplatePicker.ts +67 -0
- package/src/ui/TextEditorOverlay.ts +137 -0
- package/src/ui/Toolbar.ts +151 -0
- package/src/ui/TopControls.ts +137 -0
- package/src/ui/icons.ts +50 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +694 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { ContentApi, relativePathBetween } from "../content/ContentApi";
|
|
2
|
+
import { parseBoardEmbed, renderMarkdown } from "../content/Markdown";
|
|
3
|
+
import { htmlToMarkdown } from "./htmlToMarkdown";
|
|
4
|
+
import { ICONS } from "../ui/icons";
|
|
5
|
+
|
|
6
|
+
export type DocEditorOptions = {
|
|
7
|
+
rootId: string;
|
|
8
|
+
/** Content-relative path, e.g. "docs/guide/getting-started.md". */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Called after the editor closes (host should re-render the doc). */
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type SaveState = "idle" | "saving" | "saved" | "error";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A WYSIWYG markdown editor. The document is rendered to rich HTML inside a
|
|
18
|
+
* single contenteditable surface styled exactly like the reader, so editing is
|
|
19
|
+
* truly what-you-see-is-what-you-get. On save the DOM is serialized back to
|
|
20
|
+
* clean markdown; YAML frontmatter is preserved verbatim. Pasted/dropped images
|
|
21
|
+
* are uploaded into the active docs root's `assets/` folder and referenced by
|
|
22
|
+
* relative path.
|
|
23
|
+
*/
|
|
24
|
+
export class DocEditor {
|
|
25
|
+
private opts: DocEditorOptions;
|
|
26
|
+
private overlay!: HTMLElement;
|
|
27
|
+
private surface!: HTMLElement;
|
|
28
|
+
private statusEl!: HTMLElement;
|
|
29
|
+
private fileInput!: HTMLInputElement;
|
|
30
|
+
private toolbarButtons: HTMLButtonElement[] = [];
|
|
31
|
+
private abort = new AbortController();
|
|
32
|
+
|
|
33
|
+
private frontmatter = "";
|
|
34
|
+
private saveTimer: number | null = null;
|
|
35
|
+
private closed = false;
|
|
36
|
+
private history: string[] = [];
|
|
37
|
+
private historyIndex = -1;
|
|
38
|
+
private applyingHistory = false;
|
|
39
|
+
|
|
40
|
+
static open(opts: DocEditorOptions): DocEditor {
|
|
41
|
+
return new DocEditor(opts);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private constructor(opts: DocEditorOptions) {
|
|
45
|
+
this.opts = opts;
|
|
46
|
+
this.build();
|
|
47
|
+
void this.load();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- construction ----------
|
|
51
|
+
|
|
52
|
+
private build(): void {
|
|
53
|
+
this.overlay = div("doc-editor");
|
|
54
|
+
|
|
55
|
+
const bar = div("doc-editor-bar");
|
|
56
|
+
const left = div("doc-editor-bar-left");
|
|
57
|
+
const back = button("doc-editor-done", "Done");
|
|
58
|
+
back.addEventListener("click", () => void this.close());
|
|
59
|
+
const pathLabel = div("doc-editor-path");
|
|
60
|
+
pathLabel.textContent = this.opts.path;
|
|
61
|
+
left.append(back, pathLabel);
|
|
62
|
+
|
|
63
|
+
this.statusEl = div("doc-editor-status");
|
|
64
|
+
this.setStatus("idle");
|
|
65
|
+
|
|
66
|
+
bar.append(left, this.statusEl);
|
|
67
|
+
|
|
68
|
+
const toolbar = this.buildToolbar();
|
|
69
|
+
|
|
70
|
+
const scroll = div("doc-editor-scroll");
|
|
71
|
+
this.surface = div("markdown-body doc-editor-surface");
|
|
72
|
+
this.surface.contentEditable = "true";
|
|
73
|
+
this.surface.spellcheck = true;
|
|
74
|
+
scroll.appendChild(this.surface);
|
|
75
|
+
|
|
76
|
+
this.fileInput = document.createElement("input");
|
|
77
|
+
this.fileInput.type = "file";
|
|
78
|
+
this.fileInput.accept = "image/*";
|
|
79
|
+
this.fileInput.style.display = "none";
|
|
80
|
+
this.fileInput.addEventListener("change", () => {
|
|
81
|
+
const file = this.fileInput.files?.[0];
|
|
82
|
+
if (file) void this.insertImageFile(file);
|
|
83
|
+
this.fileInput.value = "";
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.overlay.append(bar, toolbar, scroll, this.fileInput);
|
|
87
|
+
document.body.appendChild(this.overlay);
|
|
88
|
+
|
|
89
|
+
const signal = this.abort.signal;
|
|
90
|
+
this.surface.addEventListener("input", () => this.handleInput(), { signal });
|
|
91
|
+
this.surface.addEventListener("keyup", () => this.updateToolbarState(), { signal });
|
|
92
|
+
this.surface.addEventListener("mouseup", () => this.updateToolbarState(), { signal });
|
|
93
|
+
this.surface.addEventListener("click", (e) => this.handleSurfaceClick(e), { signal });
|
|
94
|
+
document.addEventListener("selectionchange", () => this.updateToolbarState(), { signal });
|
|
95
|
+
this.surface.addEventListener("paste", (e) => this.onPaste(e), { signal });
|
|
96
|
+
this.surface.addEventListener("dragover", (e) => e.preventDefault(), { signal });
|
|
97
|
+
this.surface.addEventListener("drop", (e) => this.onDrop(e), { signal });
|
|
98
|
+
window.addEventListener(
|
|
99
|
+
"keydown",
|
|
100
|
+
(e) => {
|
|
101
|
+
if (e.key === "Escape" && document.activeElement !== this.surface) void this.close();
|
|
102
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
void this.save();
|
|
105
|
+
}
|
|
106
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
if (e.shiftKey) this.redoHistory();
|
|
109
|
+
else this.undoHistory();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "y") {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
this.redoHistory();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (this.handleEmbedDeleteKey(e)) return;
|
|
118
|
+
},
|
|
119
|
+
{ signal }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private buildToolbar(): HTMLElement {
|
|
124
|
+
const tb = div("doc-editor-toolbar");
|
|
125
|
+
|
|
126
|
+
const group = (children: HTMLElement[]) => {
|
|
127
|
+
const g = div("doc-editor-tb-group");
|
|
128
|
+
g.append(...children);
|
|
129
|
+
return g;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const cmd = (label: string, title: string, fn: () => void, icon = false) => {
|
|
133
|
+
const b = button("doc-editor-tb-btn", icon ? "" : label);
|
|
134
|
+
if (icon) {
|
|
135
|
+
b.innerHTML = ICONS[label] ?? "";
|
|
136
|
+
b.classList.add("icon");
|
|
137
|
+
}
|
|
138
|
+
b.title = title;
|
|
139
|
+
// mousedown + preventDefault keeps the editor selection intact.
|
|
140
|
+
b.addEventListener("mousedown", (e) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
this.surface.focus();
|
|
143
|
+
fn();
|
|
144
|
+
this.updateToolbarState();
|
|
145
|
+
this.scheduleSave();
|
|
146
|
+
});
|
|
147
|
+
this.toolbarButtons.push(b);
|
|
148
|
+
return b;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
tb.append(
|
|
152
|
+
group([
|
|
153
|
+
cmd("¶", "Paragraph", () => this.formatBlock("P")),
|
|
154
|
+
withState(cmd("H1", "Heading 1", () => this.formatBlock("H1")), "block", "H1"),
|
|
155
|
+
withState(cmd("H2", "Heading 2", () => this.formatBlock("H2")), "block", "H2"),
|
|
156
|
+
withState(cmd("H3", "Heading 3", () => this.formatBlock("H3")), "block", "H3"),
|
|
157
|
+
]),
|
|
158
|
+
group([
|
|
159
|
+
withState(cmd("B", "Bold (Cmd+B)", () => document.execCommand("bold")), "command", "bold"),
|
|
160
|
+
withState(cmd("I", "Italic (Cmd+I)", () => document.execCommand("italic")), "command", "italic"),
|
|
161
|
+
withState(cmd("</>", "Inline code", () => this.toggleInlineCode()), "inline", "CODE"),
|
|
162
|
+
]),
|
|
163
|
+
group([
|
|
164
|
+
withState(cmd("❝", "Quote", () => this.formatBlock("BLOCKQUOTE")), "block", "BLOCKQUOTE"),
|
|
165
|
+
withState(cmd("•", "Bulleted list", () => document.execCommand("insertUnorderedList")), "command", "insertUnorderedList"),
|
|
166
|
+
withState(cmd("1.", "Numbered list", () => document.execCommand("insertOrderedList")), "command", "insertOrderedList"),
|
|
167
|
+
cmd("{ }", "Code block", () => this.insertCodeBlock()),
|
|
168
|
+
]),
|
|
169
|
+
group([
|
|
170
|
+
cmd("↗", "Link", () => this.insertLink()),
|
|
171
|
+
cmd("image", "Image", () => this.fileInput.click(), true),
|
|
172
|
+
cmd("―", "Divider", () => document.execCommand("insertHorizontalRule")),
|
|
173
|
+
])
|
|
174
|
+
);
|
|
175
|
+
return tb;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------- load / save ----------
|
|
179
|
+
|
|
180
|
+
private async load(): Promise<void> {
|
|
181
|
+
let raw = "";
|
|
182
|
+
try {
|
|
183
|
+
raw = await ContentApi.readFile(this.opts.rootId, this.opts.path);
|
|
184
|
+
} catch {
|
|
185
|
+
this.surface.innerHTML = "<p></p>";
|
|
186
|
+
this.setStatus("error", "Could not load file");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const { fm, body } = splitFrontmatter(raw);
|
|
190
|
+
this.frontmatter = fm;
|
|
191
|
+
const { html } = renderMarkdown(body, { rootId: this.opts.rootId, docPath: this.opts.path });
|
|
192
|
+
this.surface.innerHTML = html || "<p></p>";
|
|
193
|
+
this.decorateEmbeds();
|
|
194
|
+
document.execCommand("defaultParagraphSeparator", false, "p");
|
|
195
|
+
this.recordHistorySnapshot(true);
|
|
196
|
+
this.updateToolbarState();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Render board embeds as non-editable chips so they survive editing. */
|
|
200
|
+
private decorateEmbeds(): void {
|
|
201
|
+
this.normalizeEmbedSpacing();
|
|
202
|
+
this.surface.querySelectorAll<HTMLElement>("figure.board-embed").forEach((fig) => {
|
|
203
|
+
fig.contentEditable = "false";
|
|
204
|
+
fig.tabIndex = 0;
|
|
205
|
+
fig.setAttribute("role", "button");
|
|
206
|
+
fig.setAttribute("aria-label", `Board embed ${fig.dataset.src ?? ""}. Press Delete to remove.`);
|
|
207
|
+
fig.classList.add("embed-chip");
|
|
208
|
+
const region = fig.dataset.region ? " · region" : "";
|
|
209
|
+
fig.innerHTML = `<span class="embed-chip-icon">▦</span> Board embed: <strong>${
|
|
210
|
+
fig.dataset.src ?? ""
|
|
211
|
+
}</strong>${region}<span class="embed-chip-remove" aria-hidden="true">Delete</span>`;
|
|
212
|
+
this.ensureEmbedCaretSpace(fig);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private ensureEmbedCaretSpace(fig: HTMLElement): void {
|
|
217
|
+
if (!isCaretParagraph(fig.previousElementSibling)) {
|
|
218
|
+
fig.before(caretParagraph());
|
|
219
|
+
}
|
|
220
|
+
if (!isCaretParagraph(fig.nextElementSibling)) {
|
|
221
|
+
fig.after(caretParagraph());
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private normalizeEmbedSpacing(): void {
|
|
226
|
+
const figures = Array.from(this.surface.querySelectorAll<HTMLElement>("figure.board-embed"));
|
|
227
|
+
for (const fig of figures) {
|
|
228
|
+
convertContentfulSpacer(fig.previousElementSibling);
|
|
229
|
+
convertContentfulSpacer(fig.nextElementSibling);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Collapse duplicate empty spacers so multiple embeds do not create noisy,
|
|
233
|
+
// ambiguous typing zones.
|
|
234
|
+
let prevWasEmptySpacer = false;
|
|
235
|
+
for (const child of Array.from(this.surface.children)) {
|
|
236
|
+
if (!isCaretParagraph(child)) {
|
|
237
|
+
prevWasEmptySpacer = false;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (hasMeaningfulParagraphContent(child)) {
|
|
241
|
+
child.classList.remove("embed-caret-spacer");
|
|
242
|
+
prevWasEmptySpacer = false;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (prevWasEmptySpacer) {
|
|
246
|
+
child.remove();
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
prevWasEmptySpacer = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private handleSurfaceClick(e: MouseEvent): void {
|
|
254
|
+
const target = e.target as HTMLElement | null;
|
|
255
|
+
const embed = target?.closest<HTMLElement>("figure.board-embed.embed-chip");
|
|
256
|
+
if (!embed || !this.surface.contains(embed)) return;
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
this.selectEmbed(embed);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private selectEmbed(embed: HTMLElement): void {
|
|
262
|
+
this.surface.querySelectorAll(".embed-chip.selected").forEach((el) => el.classList.remove("selected"));
|
|
263
|
+
embed.classList.add("selected");
|
|
264
|
+
const range = document.createRange();
|
|
265
|
+
range.selectNode(embed);
|
|
266
|
+
const sel = window.getSelection();
|
|
267
|
+
sel?.removeAllRanges();
|
|
268
|
+
sel?.addRange(range);
|
|
269
|
+
embed.focus();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private clearEmbedSelection(): void {
|
|
273
|
+
this.surface.querySelectorAll(".embed-chip.selected").forEach((el) => el.classList.remove("selected"));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private handleEmbedDeleteKey(e: KeyboardEvent): boolean {
|
|
277
|
+
if (e.key !== "Backspace" && e.key !== "Delete") return false;
|
|
278
|
+
const active = document.activeElement as HTMLElement | null;
|
|
279
|
+
const selected =
|
|
280
|
+
active?.closest<HTMLElement>("figure.board-embed.embed-chip.selected") ??
|
|
281
|
+
this.surface.querySelector<HTMLElement>("figure.board-embed.embed-chip.selected");
|
|
282
|
+
const embedFromCaret = selected ?? this.embedAdjacentToCaret(e.key);
|
|
283
|
+
if (!embedFromCaret || !this.surface.contains(embedFromCaret)) return false;
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
const nextCaret =
|
|
286
|
+
e.key === "Backspace" ? embedFromCaret.previousElementSibling : embedFromCaret.nextElementSibling;
|
|
287
|
+
embedFromCaret.remove();
|
|
288
|
+
this.clearEmbedSelection();
|
|
289
|
+
this.placeCaretInBlock(isCaretParagraph(nextCaret) ? nextCaret : this.surface);
|
|
290
|
+
this.recordHistorySnapshot();
|
|
291
|
+
this.scheduleSave();
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private embedAdjacentToCaret(key: "Backspace" | "Delete"): HTMLElement | null {
|
|
296
|
+
const sel = window.getSelection();
|
|
297
|
+
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
|
298
|
+
const node = sel.anchorNode;
|
|
299
|
+
const block = closestElement(node, "P");
|
|
300
|
+
if (!block || !this.surface.contains(block) || !isCaretParagraph(block)) return null;
|
|
301
|
+
const candidate = key === "Backspace" ? block.previousElementSibling : block.nextElementSibling;
|
|
302
|
+
return candidate instanceof HTMLElement && candidate.matches("figure.board-embed.embed-chip")
|
|
303
|
+
? candidate
|
|
304
|
+
: null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private handleInput(): void {
|
|
308
|
+
if (this.applyingHistory) return;
|
|
309
|
+
this.clearEmbedSelection();
|
|
310
|
+
this.recordHistorySnapshot();
|
|
311
|
+
this.scheduleSave();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private placeCaretInBlock(block: Element): void {
|
|
315
|
+
const range = document.createRange();
|
|
316
|
+
range.selectNodeContents(block);
|
|
317
|
+
range.collapse(false);
|
|
318
|
+
const sel = window.getSelection();
|
|
319
|
+
sel?.removeAllRanges();
|
|
320
|
+
sel?.addRange(range);
|
|
321
|
+
this.surface.focus();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private scheduleSave(): void {
|
|
325
|
+
this.normalizeEmbedSpacing();
|
|
326
|
+
if (this.saveTimer !== null) clearTimeout(this.saveTimer);
|
|
327
|
+
this.setStatus("saving");
|
|
328
|
+
this.saveTimer = window.setTimeout(() => {
|
|
329
|
+
this.saveTimer = null;
|
|
330
|
+
void this.save();
|
|
331
|
+
}, 700);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async save(): Promise<void> {
|
|
335
|
+
if (this.saveTimer !== null) {
|
|
336
|
+
clearTimeout(this.saveTimer);
|
|
337
|
+
this.saveTimer = null;
|
|
338
|
+
}
|
|
339
|
+
this.normalizeEmbedSpacing();
|
|
340
|
+
const body = htmlToMarkdown(this.surface);
|
|
341
|
+
const content = this.frontmatter ? `${this.frontmatter}\n\n${body}` : body;
|
|
342
|
+
try {
|
|
343
|
+
this.setStatus("saving");
|
|
344
|
+
await ContentApi.writeFile(this.opts.rootId, this.opts.path, content);
|
|
345
|
+
this.setStatus("saved");
|
|
346
|
+
} catch {
|
|
347
|
+
this.setStatus("error", "Save failed");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async close(): Promise<void> {
|
|
352
|
+
if (this.closed) return;
|
|
353
|
+
this.closed = true;
|
|
354
|
+
await this.save();
|
|
355
|
+
this.abort.abort();
|
|
356
|
+
this.overlay.remove();
|
|
357
|
+
this.opts.onClose();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------- formatting helpers ----------
|
|
361
|
+
|
|
362
|
+
private formatBlock(tag: string): void {
|
|
363
|
+
// Angle-bracket form is required by Safari and accepted by Chrome/Firefox.
|
|
364
|
+
document.execCommand("formatBlock", false, `<${tag}>`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private insertLink(): void {
|
|
368
|
+
const sel = window.getSelection();
|
|
369
|
+
const selectedText = sel && !sel.isCollapsed ? sel.toString().trim() : "";
|
|
370
|
+
const url = window.prompt("Link URL", "https://");
|
|
371
|
+
if (!url) return;
|
|
372
|
+
const safeUrl = url.trim();
|
|
373
|
+
if (!safeUrl) return;
|
|
374
|
+
if (selectedText) {
|
|
375
|
+
document.execCommand("createLink", false, safeUrl);
|
|
376
|
+
} else {
|
|
377
|
+
document.execCommand("insertHTML", false, `<a href="${escapeAttr(safeUrl)}">${escapeHtml(safeUrl)}</a>`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private toggleInlineCode(): void {
|
|
382
|
+
const sel = window.getSelection();
|
|
383
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
|
|
384
|
+
const range = sel.getRangeAt(0);
|
|
385
|
+
// If already inside a <code>, unwrap it.
|
|
386
|
+
const existing = closestTag(range.commonAncestorContainer, "CODE");
|
|
387
|
+
if (existing) {
|
|
388
|
+
const parent = existing.parentNode!;
|
|
389
|
+
while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
|
|
390
|
+
parent.removeChild(existing);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const code = document.createElement("code");
|
|
394
|
+
code.appendChild(range.extractContents());
|
|
395
|
+
range.insertNode(code);
|
|
396
|
+
range.setStartAfter(code);
|
|
397
|
+
range.collapse(true);
|
|
398
|
+
sel.removeAllRanges();
|
|
399
|
+
sel.addRange(range);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private insertCodeBlock(): void {
|
|
403
|
+
const pre = `<pre class="code-block"><code>code</code></pre><p><br></p>`;
|
|
404
|
+
document.execCommand("insertHTML", false, pre);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private updateToolbarState(): void {
|
|
408
|
+
const sel = window.getSelection();
|
|
409
|
+
const anchor = sel?.anchorNode ?? null;
|
|
410
|
+
const insideEditor = !!anchor && this.surface.contains(anchor);
|
|
411
|
+
for (const btn of this.toolbarButtons) {
|
|
412
|
+
const type = btn.dataset.stateType;
|
|
413
|
+
const value = btn.dataset.stateValue;
|
|
414
|
+
let active = false;
|
|
415
|
+
if (insideEditor && type === "command" && value) {
|
|
416
|
+
try {
|
|
417
|
+
active = document.queryCommandState(value);
|
|
418
|
+
} catch {
|
|
419
|
+
active = false;
|
|
420
|
+
}
|
|
421
|
+
} else if (insideEditor && type === "block" && value) {
|
|
422
|
+
active = !!closestTag(anchor!, value);
|
|
423
|
+
} else if (insideEditor && type === "inline" && value) {
|
|
424
|
+
active = !!closestTag(anchor!, value);
|
|
425
|
+
}
|
|
426
|
+
btn.classList.toggle("active", active);
|
|
427
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------- images ----------
|
|
432
|
+
|
|
433
|
+
private onPaste(e: ClipboardEvent): void {
|
|
434
|
+
const specialBlock = parseSpecialFenceBlock(e.clipboardData?.getData("text/plain") ?? "");
|
|
435
|
+
if (specialBlock) {
|
|
436
|
+
e.preventDefault();
|
|
437
|
+
this.insertSpecialBlock(specialBlock);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const items = e.clipboardData?.items;
|
|
442
|
+
if (!items) return;
|
|
443
|
+
for (const item of items) {
|
|
444
|
+
if (item.type.startsWith("image/")) {
|
|
445
|
+
const file = item.getAsFile();
|
|
446
|
+
if (file) {
|
|
447
|
+
e.preventDefault();
|
|
448
|
+
void this.insertImageFile(file);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private insertSpecialBlock(block: EditorSpecialBlock): void {
|
|
456
|
+
this.surface.focus();
|
|
457
|
+
const embed = renderSpecialBlock(block);
|
|
458
|
+
this.insertBlockNode(embed);
|
|
459
|
+
this.decorateEmbeds();
|
|
460
|
+
this.selectEmbed(embed);
|
|
461
|
+
this.recordHistorySnapshot();
|
|
462
|
+
this.updateToolbarState();
|
|
463
|
+
this.scheduleSave();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private insertBlockNode(node: HTMLElement): void {
|
|
467
|
+
const sel = window.getSelection();
|
|
468
|
+
const range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
|
|
469
|
+
if (!range || !this.surface.contains(range.commonAncestorContainer)) {
|
|
470
|
+
this.surface.append(node, caretParagraph());
|
|
471
|
+
this.placeCaretInBlock(this.surface.lastElementChild ?? this.surface);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const hostBlock = directChildOf(this.surface, range.commonAncestorContainer);
|
|
476
|
+
if (hostBlock && isSplittableInlineBlock(hostBlock) && !isCaretParagraph(hostBlock)) {
|
|
477
|
+
this.replaceBlockWithInsertedNode(hostBlock, range, node);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
range.deleteContents();
|
|
482
|
+
const trailingCaret = caretParagraph();
|
|
483
|
+
range.insertNode(trailingCaret);
|
|
484
|
+
range.insertNode(node);
|
|
485
|
+
|
|
486
|
+
// Keep a paragraph after inserted widgets so the user can continue typing.
|
|
487
|
+
this.placeCaretInBlock(trailingCaret);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private replaceBlockWithInsertedNode(block: HTMLElement, range: Range, node: HTMLElement): void {
|
|
491
|
+
const beforeRange = document.createRange();
|
|
492
|
+
beforeRange.selectNodeContents(block);
|
|
493
|
+
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
494
|
+
|
|
495
|
+
const afterRange = document.createRange();
|
|
496
|
+
afterRange.selectNodeContents(block);
|
|
497
|
+
afterRange.setStart(range.endContainer, range.endOffset);
|
|
498
|
+
|
|
499
|
+
const beforeBlock = cloneBlockWithContent(block, beforeRange.cloneContents());
|
|
500
|
+
const afterBlock = cloneBlockWithContent(block, afterRange.cloneContents());
|
|
501
|
+
|
|
502
|
+
const parent = block.parentNode;
|
|
503
|
+
if (!parent) return;
|
|
504
|
+
if (beforeBlock) parent.insertBefore(beforeBlock, block);
|
|
505
|
+
parent.insertBefore(node, block);
|
|
506
|
+
if (afterBlock) parent.insertBefore(afterBlock, block);
|
|
507
|
+
parent.removeChild(block);
|
|
508
|
+
|
|
509
|
+
const nextCaret = afterBlock ?? caretParagraph();
|
|
510
|
+
if (!afterBlock) parent.insertBefore(nextCaret, node.nextSibling);
|
|
511
|
+
this.placeCaretInBlock(nextCaret);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private onDrop(e: DragEvent): void {
|
|
515
|
+
const file = e.dataTransfer?.files?.[0];
|
|
516
|
+
if (file && file.type.startsWith("image/")) {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
void this.insertImageFile(file);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async insertImageFile(file: File): Promise<void> {
|
|
523
|
+
this.setStatus("saving", "Uploading image…");
|
|
524
|
+
try {
|
|
525
|
+
const base64 = await fileToBase64(file);
|
|
526
|
+
const path = await ContentApi.uploadAsset(this.opts.rootId, file.name, base64);
|
|
527
|
+
const mdPath = relativePathBetween(this.opts.path, path);
|
|
528
|
+
const url = ContentApi.assetUrl(this.opts.rootId, path);
|
|
529
|
+
const alt = file.name.replace(/\.[^.]+$/, "");
|
|
530
|
+
this.surface.focus();
|
|
531
|
+
document.execCommand(
|
|
532
|
+
"insertHTML",
|
|
533
|
+
false,
|
|
534
|
+
`<img src="${url}" data-md-src="${mdPath}" alt="${escapeAttr(alt)}" /><p><br></p>`
|
|
535
|
+
);
|
|
536
|
+
this.recordHistorySnapshot();
|
|
537
|
+
await this.save();
|
|
538
|
+
} catch {
|
|
539
|
+
this.setStatus("error", "Image upload failed");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private recordHistorySnapshot(reset = false): void {
|
|
544
|
+
if (this.applyingHistory) return;
|
|
545
|
+
this.normalizeEmbedSpacing();
|
|
546
|
+
const snapshot = this.surface.innerHTML;
|
|
547
|
+
if (reset) {
|
|
548
|
+
this.history = [snapshot];
|
|
549
|
+
this.historyIndex = 0;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (this.history[this.historyIndex] === snapshot) return;
|
|
553
|
+
this.history = this.history.slice(0, this.historyIndex + 1);
|
|
554
|
+
this.history.push(snapshot);
|
|
555
|
+
if (this.history.length > 100) this.history.shift();
|
|
556
|
+
this.historyIndex = this.history.length - 1;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private undoHistory(): void {
|
|
560
|
+
if (this.historyIndex <= 0) return;
|
|
561
|
+
this.applyHistorySnapshot(this.historyIndex - 1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private redoHistory(): void {
|
|
565
|
+
if (this.historyIndex >= this.history.length - 1) return;
|
|
566
|
+
this.applyHistorySnapshot(this.historyIndex + 1);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private applyHistorySnapshot(index: number): void {
|
|
570
|
+
const snapshot = this.history[index];
|
|
571
|
+
if (snapshot === undefined) return;
|
|
572
|
+
this.applyingHistory = true;
|
|
573
|
+
this.historyIndex = index;
|
|
574
|
+
this.surface.innerHTML = snapshot;
|
|
575
|
+
this.decorateEmbeds();
|
|
576
|
+
this.clearEmbedSelection();
|
|
577
|
+
this.placeCaretInBlock(this.surface.lastElementChild ?? this.surface);
|
|
578
|
+
this.applyingHistory = false;
|
|
579
|
+
this.updateToolbarState();
|
|
580
|
+
this.scheduleSave();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------- status ----------
|
|
584
|
+
|
|
585
|
+
private setStatus(state: SaveState, message?: string): void {
|
|
586
|
+
const labels: Record<SaveState, string> = {
|
|
587
|
+
idle: "",
|
|
588
|
+
saving: message ?? "Saving…",
|
|
589
|
+
saved: "Saved",
|
|
590
|
+
error: message ?? "Couldn't save",
|
|
591
|
+
};
|
|
592
|
+
this.statusEl.textContent = labels[state];
|
|
593
|
+
this.statusEl.dataset.state = state;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---------- module helpers ----------
|
|
598
|
+
|
|
599
|
+
function div(cls: string): HTMLElement {
|
|
600
|
+
const el = document.createElement("div");
|
|
601
|
+
el.className = cls;
|
|
602
|
+
return el;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function button(cls: string, text: string): HTMLButtonElement {
|
|
606
|
+
const b = document.createElement("button");
|
|
607
|
+
b.className = cls;
|
|
608
|
+
b.textContent = text;
|
|
609
|
+
return b;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function withState(
|
|
613
|
+
button: HTMLButtonElement,
|
|
614
|
+
type: "block" | "command" | "inline",
|
|
615
|
+
value: string
|
|
616
|
+
): HTMLButtonElement {
|
|
617
|
+
button.dataset.stateType = type;
|
|
618
|
+
button.dataset.stateValue = value;
|
|
619
|
+
button.setAttribute("aria-pressed", "false");
|
|
620
|
+
return button;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function caretParagraph(): HTMLParagraphElement {
|
|
624
|
+
const p = document.createElement("p");
|
|
625
|
+
p.className = "embed-caret-spacer";
|
|
626
|
+
p.appendChild(document.createElement("br"));
|
|
627
|
+
return p;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function isCaretParagraph(node: Element | null): node is HTMLParagraphElement {
|
|
631
|
+
return !!node && node.tagName === "P" && node.classList.contains("embed-caret-spacer");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function convertContentfulSpacer(node: Element | null): void {
|
|
635
|
+
if (!isCaretParagraph(node)) return;
|
|
636
|
+
if (!hasMeaningfulParagraphContent(node)) return;
|
|
637
|
+
node.classList.remove("embed-caret-spacer");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function hasMeaningfulParagraphContent(node: Element): boolean {
|
|
641
|
+
for (const child of Array.from(node.childNodes)) {
|
|
642
|
+
if (child.nodeType === Node.TEXT_NODE && (child.textContent ?? "").trim()) return true;
|
|
643
|
+
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
|
644
|
+
const el = child as HTMLElement;
|
|
645
|
+
if (el.tagName === "BR") continue;
|
|
646
|
+
if ((el.textContent ?? "").trim()) return true;
|
|
647
|
+
if (["IMG", "FIGURE", "HR"].includes(el.tagName)) return true;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function splitFrontmatter(raw: string): { fm: string; body: string } {
|
|
653
|
+
if (!raw.startsWith("---")) return { fm: "", body: raw };
|
|
654
|
+
const end = raw.indexOf("\n---", 3);
|
|
655
|
+
if (end === -1) return { fm: "", body: raw };
|
|
656
|
+
const fm = raw.slice(0, end + 4); // include the closing ---
|
|
657
|
+
const body = raw.slice(end + 4).replace(/^\n+/, "");
|
|
658
|
+
return { fm, body };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function closestTag(node: Node, tag: string): HTMLElement | null {
|
|
662
|
+
let cur: Node | null = node;
|
|
663
|
+
while (cur) {
|
|
664
|
+
if (cur.nodeType === Node.ELEMENT_NODE && (cur as HTMLElement).tagName === tag) {
|
|
665
|
+
return cur as HTMLElement;
|
|
666
|
+
}
|
|
667
|
+
cur = cur.parentNode;
|
|
668
|
+
}
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function closestElement(node: Node | null, selector: string): HTMLElement | null {
|
|
673
|
+
let cur: Node | null = node;
|
|
674
|
+
while (cur) {
|
|
675
|
+
if (cur.nodeType === Node.ELEMENT_NODE && (cur as HTMLElement).matches(selector)) {
|
|
676
|
+
return cur as HTMLElement;
|
|
677
|
+
}
|
|
678
|
+
cur = cur.parentNode;
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function directChildOf(root: HTMLElement, node: Node | null): HTMLElement | null {
|
|
684
|
+
let cur: Node | null = node;
|
|
685
|
+
while (cur && cur.parentNode !== root) {
|
|
686
|
+
cur = cur.parentNode;
|
|
687
|
+
}
|
|
688
|
+
return cur instanceof HTMLElement ? cur : null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isSplittableInlineBlock(node: HTMLElement): boolean {
|
|
692
|
+
return node.tagName === "P" || node.tagName === "DIV";
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function cloneBlockWithContent(source: HTMLElement, fragment: DocumentFragment): HTMLElement | null {
|
|
696
|
+
if (!fragmentHasMeaningfulContent(fragment)) return null;
|
|
697
|
+
const clone = document.createElement(source.tagName);
|
|
698
|
+
if (source.tagName === "P" && source.classList.contains("embed-caret-spacer")) {
|
|
699
|
+
clone.className = "embed-caret-spacer";
|
|
700
|
+
}
|
|
701
|
+
clone.appendChild(fragment);
|
|
702
|
+
return clone;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function fragmentHasMeaningfulContent(fragment: DocumentFragment): boolean {
|
|
706
|
+
for (const node of Array.from(fragment.childNodes)) {
|
|
707
|
+
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true;
|
|
708
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
709
|
+
const el = node as HTMLElement;
|
|
710
|
+
if (el.tagName === "BR") continue;
|
|
711
|
+
if (el.tagName === "P" && el.classList.contains("embed-caret-spacer")) continue;
|
|
712
|
+
if ((el.textContent ?? "").trim()) return true;
|
|
713
|
+
if (["IMG", "FIGURE", "HR"].includes(el.tagName)) return true;
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function fileToBase64(file: File): Promise<string> {
|
|
719
|
+
return new Promise((resolve, reject) => {
|
|
720
|
+
const reader = new FileReader();
|
|
721
|
+
reader.onload = () => {
|
|
722
|
+
const result = reader.result as string;
|
|
723
|
+
resolve(result.slice(result.indexOf(",") + 1));
|
|
724
|
+
};
|
|
725
|
+
reader.onerror = () => reject(reader.error);
|
|
726
|
+
reader.readAsDataURL(file);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function escapeAttr(s: string): string {
|
|
731
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function escapeHtml(s: string): string {
|
|
735
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
type ParsedFenceBlock = {
|
|
739
|
+
language: string;
|
|
740
|
+
body: string;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
type EditorSpecialBlock =
|
|
744
|
+
| {
|
|
745
|
+
kind: "board";
|
|
746
|
+
src: string;
|
|
747
|
+
region: [number, number, number, number] | null;
|
|
748
|
+
height: number;
|
|
749
|
+
title: string | null;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const SPECIAL_FENCE_PARSERS: Record<string, (body: string) => EditorSpecialBlock | null> = {
|
|
753
|
+
board: (body) => {
|
|
754
|
+
const embed = parseBoardEmbed(body);
|
|
755
|
+
return embed.src
|
|
756
|
+
? {
|
|
757
|
+
kind: "board",
|
|
758
|
+
src: embed.src,
|
|
759
|
+
region: embed.region,
|
|
760
|
+
height: embed.height,
|
|
761
|
+
title: embed.title,
|
|
762
|
+
}
|
|
763
|
+
: null;
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
function parseSpecialFenceBlock(text: string): EditorSpecialBlock | null {
|
|
768
|
+
const fence = parseSingleFenceBlock(text);
|
|
769
|
+
if (!fence) return null;
|
|
770
|
+
const parse = SPECIAL_FENCE_PARSERS[fence.language];
|
|
771
|
+
return parse ? parse(fence.body) : null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function parseSingleFenceBlock(text: string): ParsedFenceBlock | null {
|
|
775
|
+
const trimmed = text.trim();
|
|
776
|
+
const match = trimmed.match(/^(`{3,})\s*([^\s`]+)?\s*\n([\s\S]*?)\n\1\s*$/);
|
|
777
|
+
if (!match) return null;
|
|
778
|
+
return {
|
|
779
|
+
language: (match[2] ?? "").toLowerCase(),
|
|
780
|
+
body: match[3],
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function renderSpecialBlock(block: EditorSpecialBlock): HTMLElement {
|
|
785
|
+
switch (block.kind) {
|
|
786
|
+
case "board":
|
|
787
|
+
return createBoardEmbedFigure(block);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function createBoardEmbedFigure(block: Extract<EditorSpecialBlock, { kind: "board" }>): HTMLElement {
|
|
792
|
+
const figure = document.createElement("figure");
|
|
793
|
+
figure.className = "board-embed";
|
|
794
|
+
figure.dataset.src = block.src;
|
|
795
|
+
figure.dataset.height = String(block.height);
|
|
796
|
+
if (block.region) figure.dataset.region = block.region.join(",");
|
|
797
|
+
if (block.title) figure.dataset.title = block.title;
|
|
798
|
+
return figure;
|
|
799
|
+
}
|