@owomark/view 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/README.md +296 -0
- package/dist/chunk-Z7WJR3XO.js +1663 -0
- package/dist/index.d.ts +228 -0
- package/dist/index.js +2047 -0
- package/dist/static.d.ts +220 -0
- package/dist/static.js +40 -0
- package/package.json +85 -0
- package/src/mdx-components/mdx-components.css +336 -0
- package/src/style.css +15 -0
- package/src/theme/dark.css +79 -0
- package/src/theme/light.css +79 -0
- package/src/theme/owomark.css +159 -0
- package/src/theme/side-annotation.css +232 -0
- package/src/theme/slash-menu.css +88 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2047 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Callout,
|
|
3
|
+
CodeDemo,
|
|
4
|
+
DEFAULT_MDX_COMPONENTS,
|
|
5
|
+
FileTree,
|
|
6
|
+
Kbd,
|
|
7
|
+
Step,
|
|
8
|
+
Steps,
|
|
9
|
+
THEME_DARK_CLASS,
|
|
10
|
+
THEME_LIGHT_CLASS,
|
|
11
|
+
Tab,
|
|
12
|
+
Tabs,
|
|
13
|
+
createOwoMarkProcessor,
|
|
14
|
+
getOwoMarkPlugins,
|
|
15
|
+
getThemeClassName,
|
|
16
|
+
rehypeMathDisplayFix,
|
|
17
|
+
rehypeSideAnnotation,
|
|
18
|
+
remarkConvertSoftBreaksToHardBreaks,
|
|
19
|
+
remarkSideAnnotation
|
|
20
|
+
} from "./chunk-Z7WJR3XO.js";
|
|
21
|
+
|
|
22
|
+
// src/editor.ts
|
|
23
|
+
import { createDomAdapter } from "@owomark/core/internal/dom-adapter";
|
|
24
|
+
|
|
25
|
+
// src/view-engine.ts
|
|
26
|
+
import {
|
|
27
|
+
readSelection,
|
|
28
|
+
restoreSelection,
|
|
29
|
+
invalidateBlockCache,
|
|
30
|
+
linearToVirtual,
|
|
31
|
+
virtualToLinear,
|
|
32
|
+
getBlockStartOffset,
|
|
33
|
+
tokenizeBlock
|
|
34
|
+
} from "@owomark/core";
|
|
35
|
+
import { createBlockElement, updateBlockElement } from "@owomark/core";
|
|
36
|
+
|
|
37
|
+
// src/dom/slash-menu.ts
|
|
38
|
+
var MENU_GAP = 4;
|
|
39
|
+
var MENU_MAX_HEIGHT = 320;
|
|
40
|
+
var MENU_WIDTH = 280;
|
|
41
|
+
function createSlashMenuOverlay(root, core) {
|
|
42
|
+
const doc = root.ownerDocument;
|
|
43
|
+
const maybeWin = doc.defaultView;
|
|
44
|
+
if (!maybeWin) {
|
|
45
|
+
return { destroy() {
|
|
46
|
+
} };
|
|
47
|
+
}
|
|
48
|
+
const win = maybeWin;
|
|
49
|
+
let menuEl = null;
|
|
50
|
+
let state = core.getSlashState();
|
|
51
|
+
let position = null;
|
|
52
|
+
let frameId = 0;
|
|
53
|
+
function getCaretRect() {
|
|
54
|
+
const sel = win.getSelection();
|
|
55
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
56
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
57
|
+
range.collapse(true);
|
|
58
|
+
const rect = range.getBoundingClientRect();
|
|
59
|
+
if (rect.width === 0 && rect.height === 0 && rect.top === 0) return null;
|
|
60
|
+
return { top: rect.top, left: rect.left, bottom: rect.bottom };
|
|
61
|
+
}
|
|
62
|
+
function computeMenuPosition(caret) {
|
|
63
|
+
const viewportHeight = win.innerHeight;
|
|
64
|
+
const viewportWidth = win.innerWidth;
|
|
65
|
+
let top;
|
|
66
|
+
if (caret.bottom + MENU_GAP + MENU_MAX_HEIGHT <= viewportHeight) {
|
|
67
|
+
top = caret.bottom + MENU_GAP;
|
|
68
|
+
} else {
|
|
69
|
+
top = caret.top - MENU_GAP - MENU_MAX_HEIGHT;
|
|
70
|
+
}
|
|
71
|
+
let left = caret.left;
|
|
72
|
+
if (left + MENU_WIDTH > viewportWidth - 8) {
|
|
73
|
+
left = viewportWidth - MENU_WIDTH - 8;
|
|
74
|
+
}
|
|
75
|
+
if (left < 8) left = 8;
|
|
76
|
+
return { top: Math.max(0, top), left };
|
|
77
|
+
}
|
|
78
|
+
function groupByCategory(commands) {
|
|
79
|
+
const groups = [];
|
|
80
|
+
const seen = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const cmd of commands) {
|
|
82
|
+
const cat = cmd.category ?? "";
|
|
83
|
+
let group = seen.get(cat);
|
|
84
|
+
if (!group) {
|
|
85
|
+
group = { category: cat, commands: [] };
|
|
86
|
+
seen.set(cat, group);
|
|
87
|
+
groups.push(group);
|
|
88
|
+
}
|
|
89
|
+
group.commands.push(cmd);
|
|
90
|
+
}
|
|
91
|
+
return groups;
|
|
92
|
+
}
|
|
93
|
+
function ensureMenu() {
|
|
94
|
+
if (menuEl) return menuEl;
|
|
95
|
+
menuEl = doc.createElement("div");
|
|
96
|
+
menuEl.className = "owo-slash-menu";
|
|
97
|
+
menuEl.setAttribute("role", "listbox");
|
|
98
|
+
menuEl.style.position = "fixed";
|
|
99
|
+
menuEl.style.width = `${MENU_WIDTH}px`;
|
|
100
|
+
menuEl.style.maxHeight = `${MENU_MAX_HEIGHT}px`;
|
|
101
|
+
menuEl.style.overflow = "auto";
|
|
102
|
+
menuEl.style.zIndex = "9999";
|
|
103
|
+
doc.body.appendChild(menuEl);
|
|
104
|
+
return menuEl;
|
|
105
|
+
}
|
|
106
|
+
function removeMenu() {
|
|
107
|
+
if (menuEl) {
|
|
108
|
+
menuEl.remove();
|
|
109
|
+
menuEl = null;
|
|
110
|
+
}
|
|
111
|
+
position = null;
|
|
112
|
+
}
|
|
113
|
+
function updatePosition() {
|
|
114
|
+
const caret = getCaretRect();
|
|
115
|
+
if (!caret) return false;
|
|
116
|
+
position = computeMenuPosition(caret);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function schedulePositionUpdate() {
|
|
120
|
+
if (!state.active && !state.pending) {
|
|
121
|
+
position = null;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (updatePosition()) {
|
|
125
|
+
render();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (frameId) win.cancelAnimationFrame(frameId);
|
|
129
|
+
frameId = win.requestAnimationFrame(() => {
|
|
130
|
+
frameId = 0;
|
|
131
|
+
if (updatePosition()) {
|
|
132
|
+
render();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const itemPool = /* @__PURE__ */ new Map();
|
|
137
|
+
let lastRenderedIds = [];
|
|
138
|
+
function createItemEl(cmd, globalIndex, isSelected) {
|
|
139
|
+
const itemEl = doc.createElement("div");
|
|
140
|
+
itemEl.setAttribute("role", "option");
|
|
141
|
+
itemEl.addEventListener("pointerdown", (e) => {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
const idx = Number(itemEl.dataset.globalIndex);
|
|
145
|
+
core.executeSlashCommand(idx);
|
|
146
|
+
root.focus();
|
|
147
|
+
});
|
|
148
|
+
if (cmd.icon) {
|
|
149
|
+
const iconEl = doc.createElement("span");
|
|
150
|
+
iconEl.className = "owo-slash-menu__icon";
|
|
151
|
+
iconEl.textContent = cmd.icon;
|
|
152
|
+
itemEl.appendChild(iconEl);
|
|
153
|
+
}
|
|
154
|
+
const contentEl = doc.createElement("div");
|
|
155
|
+
contentEl.className = "owo-slash-menu__content";
|
|
156
|
+
const labelEl = doc.createElement("span");
|
|
157
|
+
labelEl.className = "owo-slash-menu__label";
|
|
158
|
+
contentEl.appendChild(labelEl);
|
|
159
|
+
const shortcutEl = doc.createElement("span");
|
|
160
|
+
shortcutEl.className = "owo-slash-menu__shortcut";
|
|
161
|
+
contentEl.appendChild(shortcutEl);
|
|
162
|
+
const descEl = doc.createElement("span");
|
|
163
|
+
descEl.className = "owo-slash-menu__desc";
|
|
164
|
+
contentEl.appendChild(descEl);
|
|
165
|
+
itemEl.appendChild(contentEl);
|
|
166
|
+
updateItemEl(itemEl, cmd, globalIndex, isSelected);
|
|
167
|
+
return itemEl;
|
|
168
|
+
}
|
|
169
|
+
function updateItemEl(itemEl, cmd, globalIndex, isSelected) {
|
|
170
|
+
itemEl.dataset.globalIndex = String(globalIndex);
|
|
171
|
+
itemEl.className = `owo-slash-menu__item${isSelected ? " owo-slash-menu__item--selected" : ""}`;
|
|
172
|
+
itemEl.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
173
|
+
if (isSelected) {
|
|
174
|
+
itemEl.setAttribute("data-selected", "true");
|
|
175
|
+
} else {
|
|
176
|
+
itemEl.removeAttribute("data-selected");
|
|
177
|
+
}
|
|
178
|
+
const contentEl = itemEl.querySelector(".owo-slash-menu__content");
|
|
179
|
+
if (contentEl) {
|
|
180
|
+
const labelEl = contentEl.querySelector(".owo-slash-menu__label");
|
|
181
|
+
if (labelEl) labelEl.textContent = cmd.label;
|
|
182
|
+
const shortcutEl = contentEl.querySelector(".owo-slash-menu__shortcut");
|
|
183
|
+
if (shortcutEl) shortcutEl.textContent = cmd.shortcut ?? "";
|
|
184
|
+
const descEl = contentEl.querySelector(".owo-slash-menu__desc");
|
|
185
|
+
if (descEl) descEl.textContent = cmd.description ?? "";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function render() {
|
|
189
|
+
if (!state.active && !state.pending || !position) {
|
|
190
|
+
removeMenu();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const el = ensureMenu();
|
|
194
|
+
el.style.top = `${position.top}px`;
|
|
195
|
+
el.style.left = `${position.left}px`;
|
|
196
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
197
|
+
state.filteredCommands.forEach((cmd, i) => indexMap.set(cmd, i));
|
|
198
|
+
const currentIds = [];
|
|
199
|
+
el.textContent = "";
|
|
200
|
+
if (state.pending) {
|
|
201
|
+
const pending = doc.createElement("div");
|
|
202
|
+
pending.className = "owo-slash-menu__pending";
|
|
203
|
+
pending.textContent = "Executing...";
|
|
204
|
+
el.appendChild(pending);
|
|
205
|
+
}
|
|
206
|
+
for (const group of groupByCategory(state.filteredCommands)) {
|
|
207
|
+
const groupEl = doc.createElement("div");
|
|
208
|
+
groupEl.className = "owo-slash-menu__group";
|
|
209
|
+
if (group.category) {
|
|
210
|
+
const categoryEl = doc.createElement("div");
|
|
211
|
+
categoryEl.className = "owo-slash-menu__category";
|
|
212
|
+
categoryEl.textContent = group.category;
|
|
213
|
+
groupEl.appendChild(categoryEl);
|
|
214
|
+
}
|
|
215
|
+
for (const cmd of group.commands) {
|
|
216
|
+
const globalIndex = indexMap.get(cmd) ?? -1;
|
|
217
|
+
const isSelected = globalIndex === state.selectedIndex;
|
|
218
|
+
currentIds.push(cmd.id);
|
|
219
|
+
let itemEl = itemPool.get(cmd.id);
|
|
220
|
+
if (itemEl) {
|
|
221
|
+
updateItemEl(itemEl, cmd, globalIndex, isSelected);
|
|
222
|
+
} else {
|
|
223
|
+
itemEl = createItemEl(cmd, globalIndex, isSelected);
|
|
224
|
+
itemPool.set(cmd.id, itemEl);
|
|
225
|
+
}
|
|
226
|
+
groupEl.appendChild(itemEl);
|
|
227
|
+
}
|
|
228
|
+
el.appendChild(groupEl);
|
|
229
|
+
}
|
|
230
|
+
for (const oldId of lastRenderedIds) {
|
|
231
|
+
if (!currentIds.includes(oldId)) {
|
|
232
|
+
itemPool.delete(oldId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
lastRenderedIds = currentIds;
|
|
236
|
+
const selected = el.querySelector('[data-selected="true"]');
|
|
237
|
+
if (selected && typeof selected.scrollIntoView === "function") {
|
|
238
|
+
selected.scrollIntoView({ block: "nearest" });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function handlePointerDown(e) {
|
|
242
|
+
if (!state.active) return;
|
|
243
|
+
const target = e.target;
|
|
244
|
+
if (menuEl && target && menuEl.contains(target)) return;
|
|
245
|
+
core.dismissSlashMenu();
|
|
246
|
+
}
|
|
247
|
+
function handleViewportChange() {
|
|
248
|
+
if (!state.active && !state.pending) return;
|
|
249
|
+
schedulePositionUpdate();
|
|
250
|
+
}
|
|
251
|
+
const unsubSlash = core.onSlashStateChange((nextState) => {
|
|
252
|
+
state = nextState;
|
|
253
|
+
if (!state.active && !state.pending) {
|
|
254
|
+
removeMenu();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
render();
|
|
258
|
+
schedulePositionUpdate();
|
|
259
|
+
});
|
|
260
|
+
doc.addEventListener("pointerdown", handlePointerDown, true);
|
|
261
|
+
win.addEventListener("resize", handleViewportChange);
|
|
262
|
+
win.addEventListener("scroll", handleViewportChange, true);
|
|
263
|
+
if (state.active || state.pending) {
|
|
264
|
+
render();
|
|
265
|
+
schedulePositionUpdate();
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
destroy() {
|
|
269
|
+
unsubSlash();
|
|
270
|
+
if (frameId) {
|
|
271
|
+
win.cancelAnimationFrame(frameId);
|
|
272
|
+
frameId = 0;
|
|
273
|
+
}
|
|
274
|
+
doc.removeEventListener("pointerdown", handlePointerDown, true);
|
|
275
|
+
win.removeEventListener("resize", handleViewportChange);
|
|
276
|
+
win.removeEventListener("scroll", handleViewportChange, true);
|
|
277
|
+
removeMenu();
|
|
278
|
+
itemPool.clear();
|
|
279
|
+
lastRenderedIds = [];
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/view-engine.ts
|
|
285
|
+
function createViewEngine(options) {
|
|
286
|
+
const { core } = options;
|
|
287
|
+
let root = null;
|
|
288
|
+
let activeBlockId = null;
|
|
289
|
+
let compositionJustEnded = false;
|
|
290
|
+
let pendingNativeSync = false;
|
|
291
|
+
let slashMenu = null;
|
|
292
|
+
const unsubs = [];
|
|
293
|
+
function getBlockElements() {
|
|
294
|
+
if (!root) return /* @__PURE__ */ new Map();
|
|
295
|
+
const map = /* @__PURE__ */ new Map();
|
|
296
|
+
for (const child of root.children) {
|
|
297
|
+
if (child instanceof HTMLElement && child.hasAttribute("data-owo-block")) {
|
|
298
|
+
const id = child.getAttribute("data-block-id");
|
|
299
|
+
if (id) map.set(id, child);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return map;
|
|
303
|
+
}
|
|
304
|
+
function activateBlock(blockId) {
|
|
305
|
+
if (activeBlockId === blockId) return;
|
|
306
|
+
const blocks = getBlockElements();
|
|
307
|
+
if (activeBlockId) {
|
|
308
|
+
const oldEl = blocks.get(activeBlockId);
|
|
309
|
+
if (oldEl) {
|
|
310
|
+
oldEl.contentEditable = "false";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const newEl = blocks.get(blockId);
|
|
314
|
+
if (newEl) {
|
|
315
|
+
newEl.contentEditable = "true";
|
|
316
|
+
activeBlockId = blockId;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function deactivateAll() {
|
|
320
|
+
const blocks = getBlockElements();
|
|
321
|
+
for (const el of blocks.values()) {
|
|
322
|
+
el.contentEditable = "false";
|
|
323
|
+
}
|
|
324
|
+
activeBlockId = null;
|
|
325
|
+
}
|
|
326
|
+
function renderFull(doc) {
|
|
327
|
+
if (!root) return;
|
|
328
|
+
const ownerDoc = root.ownerDocument;
|
|
329
|
+
root.textContent = "";
|
|
330
|
+
let offset = 0;
|
|
331
|
+
for (let i = 0; i < doc.blocks.length; i++) {
|
|
332
|
+
const block = doc.blocks[i];
|
|
333
|
+
const tokens = tokenizeBlock(block.raw, block.type, offset);
|
|
334
|
+
const el = createBlockElement(
|
|
335
|
+
ownerDoc,
|
|
336
|
+
tokens,
|
|
337
|
+
i,
|
|
338
|
+
block.type,
|
|
339
|
+
block.type === "heading" ? block.headingLevel : void 0,
|
|
340
|
+
block.depth
|
|
341
|
+
);
|
|
342
|
+
el.setAttribute("data-block-id", block.id);
|
|
343
|
+
el.contentEditable = "false";
|
|
344
|
+
root.appendChild(el);
|
|
345
|
+
offset += block.raw.length + 1;
|
|
346
|
+
}
|
|
347
|
+
if (activeBlockId) {
|
|
348
|
+
const blocks = getBlockElements();
|
|
349
|
+
const activeEl = blocks.get(activeBlockId);
|
|
350
|
+
if (activeEl) {
|
|
351
|
+
activeEl.contentEditable = "true";
|
|
352
|
+
} else {
|
|
353
|
+
activeBlockId = null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
invalidateBlockCache(root);
|
|
357
|
+
}
|
|
358
|
+
function renderDirty(oldDoc, newDoc) {
|
|
359
|
+
if (!root) return;
|
|
360
|
+
if (oldDoc.blocks.length !== newDoc.blocks.length) {
|
|
361
|
+
renderFull(newDoc);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const ownerDoc = root.ownerDocument;
|
|
365
|
+
const blockEls = [...root.querySelectorAll("[data-owo-block]")];
|
|
366
|
+
for (let i = 0; i < newDoc.blocks.length; i++) {
|
|
367
|
+
const oldBlock = oldDoc.blocks[i];
|
|
368
|
+
const newBlock = newDoc.blocks[i];
|
|
369
|
+
if (oldBlock === newBlock) continue;
|
|
370
|
+
if (core.getComposition().active && newBlock.id === activeBlockId) continue;
|
|
371
|
+
const offset = getBlockStartOffset(newDoc, i);
|
|
372
|
+
const tokens = tokenizeBlock(newBlock.raw, newBlock.type, offset);
|
|
373
|
+
const el = blockEls[i];
|
|
374
|
+
if (el) {
|
|
375
|
+
updateBlockElement(
|
|
376
|
+
ownerDoc,
|
|
377
|
+
el,
|
|
378
|
+
tokens,
|
|
379
|
+
newBlock.type,
|
|
380
|
+
newBlock.type === "heading" ? newBlock.headingLevel : void 0,
|
|
381
|
+
newBlock.depth
|
|
382
|
+
);
|
|
383
|
+
el.setAttribute("data-block-id", newBlock.id);
|
|
384
|
+
if (newBlock.id === activeBlockId) {
|
|
385
|
+
el.contentEditable = "true";
|
|
386
|
+
} else {
|
|
387
|
+
el.contentEditable = "false";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function getActiveBlockFromSelection(vsel) {
|
|
393
|
+
if (!vsel) return null;
|
|
394
|
+
return vsel.focus.blockId;
|
|
395
|
+
}
|
|
396
|
+
function syncActiveBlockFromDOM() {
|
|
397
|
+
if (!root || !activeBlockId) return;
|
|
398
|
+
const blocks = getBlockElements();
|
|
399
|
+
const activeEl = blocks.get(activeBlockId);
|
|
400
|
+
if (!activeEl) return;
|
|
401
|
+
const textContent = activeEl.textContent ?? "";
|
|
402
|
+
const doc = core.getDocument();
|
|
403
|
+
const blockIndex = doc.blocks.findIndex((b) => b.id === activeBlockId);
|
|
404
|
+
if (blockIndex === -1) return;
|
|
405
|
+
const block = doc.blocks[blockIndex];
|
|
406
|
+
if (textContent === block.raw) return;
|
|
407
|
+
const parts = doc.blocks.map((b, i) => i === blockIndex ? textContent : b.raw);
|
|
408
|
+
const newMarkdown = parts.join("\n");
|
|
409
|
+
const sel = readSelection(root);
|
|
410
|
+
core.syncText(newMarkdown, sel);
|
|
411
|
+
}
|
|
412
|
+
function getCaretFromPoint(x, y) {
|
|
413
|
+
const ownerDoc = root?.ownerDocument;
|
|
414
|
+
if (!ownerDoc) return null;
|
|
415
|
+
if ("caretPositionFromPoint" in ownerDoc) {
|
|
416
|
+
const pos = ownerDoc.caretPositionFromPoint(x, y);
|
|
417
|
+
if (pos) return { node: pos.offsetNode, offset: pos.offset };
|
|
418
|
+
}
|
|
419
|
+
if ("caretRangeFromPoint" in ownerDoc) {
|
|
420
|
+
const range = ownerDoc.caretRangeFromPoint(x, y);
|
|
421
|
+
if (range) return { node: range.startContainer, offset: range.startOffset };
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
function findBlockIdFromElement(el) {
|
|
426
|
+
let current = el;
|
|
427
|
+
while (current && current !== root) {
|
|
428
|
+
if (current.hasAttribute("data-block-id")) {
|
|
429
|
+
return current.getAttribute("data-block-id");
|
|
430
|
+
}
|
|
431
|
+
current = current.parentElement;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
function onBeforeInput(e) {
|
|
436
|
+
if (core.getComposition().active) return;
|
|
437
|
+
const sel = readSelection(root);
|
|
438
|
+
const oldDoc = core.getDocument();
|
|
439
|
+
const result = core.applyBeforeInput(
|
|
440
|
+
{ inputType: e.inputType, data: e.data },
|
|
441
|
+
sel
|
|
442
|
+
);
|
|
443
|
+
if (result.action === "handled") {
|
|
444
|
+
e.preventDefault();
|
|
445
|
+
syncEmptyAttr();
|
|
446
|
+
} else if (result.action === "allow-native") {
|
|
447
|
+
pendingNativeSync = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function onInput() {
|
|
451
|
+
if (core.getComposition().active) return;
|
|
452
|
+
if (compositionJustEnded) {
|
|
453
|
+
compositionJustEnded = false;
|
|
454
|
+
syncActiveBlockFromDOM();
|
|
455
|
+
} else if (pendingNativeSync) {
|
|
456
|
+
pendingNativeSync = false;
|
|
457
|
+
syncActiveBlockFromDOM();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function onKeyDown(e) {
|
|
461
|
+
if (core.getComposition().active) return;
|
|
462
|
+
const sel = readSelection(root);
|
|
463
|
+
const result = core.applyKeyDown(
|
|
464
|
+
{
|
|
465
|
+
key: e.key,
|
|
466
|
+
ctrlKey: e.ctrlKey,
|
|
467
|
+
metaKey: e.metaKey,
|
|
468
|
+
altKey: e.altKey,
|
|
469
|
+
shiftKey: e.shiftKey
|
|
470
|
+
},
|
|
471
|
+
sel
|
|
472
|
+
);
|
|
473
|
+
if (result.action === "handled") {
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
syncEmptyAttr();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function onPaste(e) {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
const raw = e.clipboardData?.getData("text/plain") ?? "";
|
|
481
|
+
const sel = readSelection(root);
|
|
482
|
+
core.applyPaste({ text: raw }, sel);
|
|
483
|
+
syncEmptyAttr();
|
|
484
|
+
}
|
|
485
|
+
function onCompositionStart() {
|
|
486
|
+
const sel = readSelection(root);
|
|
487
|
+
core.handleCompositionStart(sel);
|
|
488
|
+
}
|
|
489
|
+
function onCompositionUpdate() {
|
|
490
|
+
core.handleCompositionUpdate();
|
|
491
|
+
}
|
|
492
|
+
function onCompositionEnd() {
|
|
493
|
+
const sel = readSelection(root);
|
|
494
|
+
core.handleCompositionEnd(sel);
|
|
495
|
+
compositionJustEnded = true;
|
|
496
|
+
requestAnimationFrame(() => {
|
|
497
|
+
if (compositionJustEnded) {
|
|
498
|
+
compositionJustEnded = false;
|
|
499
|
+
syncActiveBlockFromDOM();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
function onSelectionChange() {
|
|
504
|
+
if (!root || core.getComposition().active) return;
|
|
505
|
+
const sel = readSelection(root);
|
|
506
|
+
if (sel) {
|
|
507
|
+
core.updateSelection(sel);
|
|
508
|
+
const vsel = linearToVirtual(core.getDocument(), sel);
|
|
509
|
+
const targetBlockId = getActiveBlockFromSelection(vsel);
|
|
510
|
+
if (targetBlockId && targetBlockId !== activeBlockId) {
|
|
511
|
+
activateBlock(targetBlockId);
|
|
512
|
+
const linearSel = virtualToLinear(core.getDocument(), vsel);
|
|
513
|
+
invalidateBlockCache(root);
|
|
514
|
+
restoreSelection(root, linearSel);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function onMouseDown(e) {
|
|
519
|
+
if (!root) return;
|
|
520
|
+
const target = e.target;
|
|
521
|
+
const blockId = findBlockIdFromElement(target);
|
|
522
|
+
if (blockId && blockId !== activeBlockId) {
|
|
523
|
+
activateBlock(blockId);
|
|
524
|
+
const caret = getCaretFromPoint(e.clientX, e.clientY);
|
|
525
|
+
if (caret) {
|
|
526
|
+
} else {
|
|
527
|
+
const doc = core.getDocument();
|
|
528
|
+
const blockIndex = doc.blocks.findIndex((b) => b.id === blockId);
|
|
529
|
+
if (blockIndex >= 0) {
|
|
530
|
+
const offset = getBlockStartOffset(doc, blockIndex);
|
|
531
|
+
const sel = { anchor: offset, focus: offset };
|
|
532
|
+
core.updateSelection(sel);
|
|
533
|
+
invalidateBlockCache(root);
|
|
534
|
+
restoreSelection(root, sel);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function syncEmptyAttr() {
|
|
540
|
+
if (!root) return;
|
|
541
|
+
const doc = core.getDocument();
|
|
542
|
+
const empty = doc.blocks.length === 0 || doc.blocks.length === 1 && doc.blocks[0].raw === "";
|
|
543
|
+
if (empty) root.setAttribute("data-owo-empty", "true");
|
|
544
|
+
else root.removeAttribute("data-owo-empty");
|
|
545
|
+
}
|
|
546
|
+
let lastDoc = null;
|
|
547
|
+
let pendingSelection = null;
|
|
548
|
+
function onDocumentChange(doc, vsel) {
|
|
549
|
+
if (!root) return;
|
|
550
|
+
const oldDoc = lastDoc;
|
|
551
|
+
lastDoc = doc;
|
|
552
|
+
pendingSelection = vsel;
|
|
553
|
+
if (oldDoc) {
|
|
554
|
+
renderDirty(oldDoc, doc);
|
|
555
|
+
} else {
|
|
556
|
+
renderFull(doc);
|
|
557
|
+
}
|
|
558
|
+
const targetBlockId = getActiveBlockFromSelection(vsel);
|
|
559
|
+
if (targetBlockId && targetBlockId !== activeBlockId) {
|
|
560
|
+
activateBlock(targetBlockId);
|
|
561
|
+
}
|
|
562
|
+
if (pendingSelection) {
|
|
563
|
+
const linearSel = virtualToLinear(doc, pendingSelection);
|
|
564
|
+
invalidateBlockCache(root);
|
|
565
|
+
restoreSelection(root, linearSel);
|
|
566
|
+
pendingSelection = null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function mount(element) {
|
|
570
|
+
root = element;
|
|
571
|
+
root.setAttribute("role", "textbox");
|
|
572
|
+
root.setAttribute("aria-multiline", "true");
|
|
573
|
+
root.setAttribute("aria-label", "Markdown editor");
|
|
574
|
+
root.classList.add("owo-editor-root");
|
|
575
|
+
root.style.whiteSpace = "pre-wrap";
|
|
576
|
+
root.style.wordBreak = "break-word";
|
|
577
|
+
root.style.outline = "none";
|
|
578
|
+
root.contentEditable = "false";
|
|
579
|
+
const unsubDoc = core.onDocumentChange(onDocumentChange);
|
|
580
|
+
unsubs.push(unsubDoc);
|
|
581
|
+
lastDoc = core.getDocument();
|
|
582
|
+
renderFull(lastDoc);
|
|
583
|
+
syncEmptyAttr();
|
|
584
|
+
const doc = core.getDocument();
|
|
585
|
+
if (doc.blocks.length > 0) {
|
|
586
|
+
const vsel = core.getVirtualSelection();
|
|
587
|
+
const targetBlockId = getActiveBlockFromSelection(vsel);
|
|
588
|
+
if (targetBlockId) {
|
|
589
|
+
activateBlock(targetBlockId);
|
|
590
|
+
const sel = core.getSelection();
|
|
591
|
+
if (sel) {
|
|
592
|
+
invalidateBlockCache(root);
|
|
593
|
+
restoreSelection(root, sel);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
root.addEventListener("beforeinput", onBeforeInput);
|
|
598
|
+
root.addEventListener("input", onInput);
|
|
599
|
+
root.addEventListener("compositionstart", onCompositionStart);
|
|
600
|
+
root.addEventListener("compositionupdate", onCompositionUpdate);
|
|
601
|
+
root.addEventListener("compositionend", onCompositionEnd);
|
|
602
|
+
root.addEventListener("keydown", onKeyDown);
|
|
603
|
+
root.addEventListener("paste", onPaste);
|
|
604
|
+
root.addEventListener("mousedown", onMouseDown);
|
|
605
|
+
const onDocSelectionChange = () => onSelectionChange();
|
|
606
|
+
root.ownerDocument.addEventListener("selectionchange", onDocSelectionChange);
|
|
607
|
+
unsubs.push(() => {
|
|
608
|
+
root?.ownerDocument.removeEventListener("selectionchange", onDocSelectionChange);
|
|
609
|
+
});
|
|
610
|
+
slashMenu = createSlashMenuOverlay(root, core);
|
|
611
|
+
}
|
|
612
|
+
function destroy() {
|
|
613
|
+
if (!root) return;
|
|
614
|
+
root.removeEventListener("beforeinput", onBeforeInput);
|
|
615
|
+
root.removeEventListener("input", onInput);
|
|
616
|
+
root.removeEventListener("compositionstart", onCompositionStart);
|
|
617
|
+
root.removeEventListener("compositionupdate", onCompositionUpdate);
|
|
618
|
+
root.removeEventListener("compositionend", onCompositionEnd);
|
|
619
|
+
root.removeEventListener("keydown", onKeyDown);
|
|
620
|
+
root.removeEventListener("paste", onPaste);
|
|
621
|
+
root.removeEventListener("mousedown", onMouseDown);
|
|
622
|
+
for (const unsub of unsubs) unsub();
|
|
623
|
+
unsubs.length = 0;
|
|
624
|
+
slashMenu?.destroy();
|
|
625
|
+
slashMenu = null;
|
|
626
|
+
root = null;
|
|
627
|
+
activeBlockId = null;
|
|
628
|
+
lastDoc = null;
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
mount,
|
|
632
|
+
destroy,
|
|
633
|
+
getActiveBlockId: () => activeBlockId
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/editor.ts
|
|
638
|
+
function createOwoMarkView(core, element) {
|
|
639
|
+
const engine = createViewEngine({ core });
|
|
640
|
+
engine.mount(element);
|
|
641
|
+
return engine;
|
|
642
|
+
}
|
|
643
|
+
function createOwoMarkVanillaEditor() {
|
|
644
|
+
const adapter = createDomAdapter();
|
|
645
|
+
let slashMenu = null;
|
|
646
|
+
return {
|
|
647
|
+
...adapter,
|
|
648
|
+
mount(element) {
|
|
649
|
+
adapter.mount(element);
|
|
650
|
+
slashMenu = createSlashMenuOverlay(element, adapter.getCore());
|
|
651
|
+
},
|
|
652
|
+
destroy() {
|
|
653
|
+
slashMenu?.destroy();
|
|
654
|
+
slashMenu = null;
|
|
655
|
+
adapter.destroy();
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/dom/patcher.ts
|
|
661
|
+
var BLOCK_ATTR = "data-preview-block";
|
|
662
|
+
var BLOCK_ID_ATTR = "data-block-id";
|
|
663
|
+
var SOURCE_START_ATTR = "data-source-line-start";
|
|
664
|
+
var SOURCE_END_ATTR = "data-source-line-end";
|
|
665
|
+
var PreviewDomPatcher = class {
|
|
666
|
+
root = null;
|
|
667
|
+
blockElements = /* @__PURE__ */ new Map();
|
|
668
|
+
/** Tracked unmount callbacks for DOM-mounted renderers. */
|
|
669
|
+
domUnmounts = /* @__PURE__ */ new Map();
|
|
670
|
+
mount(root) {
|
|
671
|
+
this.root = root;
|
|
672
|
+
this.blockElements.clear();
|
|
673
|
+
}
|
|
674
|
+
destroy() {
|
|
675
|
+
for (const unmount of this.domUnmounts.values()) {
|
|
676
|
+
try {
|
|
677
|
+
unmount();
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
this.domUnmounts.clear();
|
|
682
|
+
this.root = null;
|
|
683
|
+
this.blockElements.clear();
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Full render: replace all content with the given blocks.
|
|
687
|
+
*/
|
|
688
|
+
fullRender(blocks, htmlMap) {
|
|
689
|
+
if (!this.root) return;
|
|
690
|
+
for (const unmount of this.domUnmounts.values()) {
|
|
691
|
+
try {
|
|
692
|
+
unmount();
|
|
693
|
+
} catch {
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
this.domUnmounts.clear();
|
|
697
|
+
this.root.textContent = "";
|
|
698
|
+
this.blockElements.clear();
|
|
699
|
+
const doc = this.root.ownerDocument;
|
|
700
|
+
for (const block of blocks) {
|
|
701
|
+
const wrapper = this.createBlockWrapper(doc, block);
|
|
702
|
+
wrapper.innerHTML = htmlMap.get(block.blockId) ?? "";
|
|
703
|
+
this.root.appendChild(wrapper);
|
|
704
|
+
this.blockElements.set(block.blockId, wrapper);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Incremental patch: update only dirty blocks.
|
|
709
|
+
* Handles additions, removals, and content changes.
|
|
710
|
+
*/
|
|
711
|
+
patch(blocks, htmlMap, dirtyBlockIds) {
|
|
712
|
+
if (!this.root) return;
|
|
713
|
+
const doc = this.root.ownerDocument;
|
|
714
|
+
const newBlockIds = new Set(blocks.map((b) => b.blockId));
|
|
715
|
+
const oldBlockIds = new Set(this.blockElements.keys());
|
|
716
|
+
const structureChanged = blocks.length !== oldBlockIds.size || blocks.some((b) => !oldBlockIds.has(b.blockId));
|
|
717
|
+
if (structureChanged) {
|
|
718
|
+
this.rebuildWithReuse(blocks, htmlMap, dirtyBlockIds, doc);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
for (const blockId of dirtyBlockIds) {
|
|
722
|
+
const el = this.blockElements.get(blockId);
|
|
723
|
+
if (el) {
|
|
724
|
+
const block = blocks.find((b) => b.blockId === blockId);
|
|
725
|
+
if (block) {
|
|
726
|
+
this.cleanupDomMount(blockId);
|
|
727
|
+
this.updateBlockAttributes(el, block);
|
|
728
|
+
el.innerHTML = htmlMap.get(blockId) ?? "";
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Rebuild DOM while reusing non-dirty block wrappers.
|
|
735
|
+
*/
|
|
736
|
+
rebuildWithReuse(blocks, htmlMap, dirtyBlockIds, doc) {
|
|
737
|
+
if (!this.root) return;
|
|
738
|
+
const newBlockIds = new Set(blocks.map((b) => b.blockId));
|
|
739
|
+
for (const blockId of this.domUnmounts.keys()) {
|
|
740
|
+
if (dirtyBlockIds.has(blockId) || !newBlockIds.has(blockId)) {
|
|
741
|
+
this.cleanupDomMount(blockId);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const existingElements = new Map(this.blockElements);
|
|
745
|
+
this.root.textContent = "";
|
|
746
|
+
this.blockElements.clear();
|
|
747
|
+
for (const block of blocks) {
|
|
748
|
+
const existing = existingElements.get(block.blockId);
|
|
749
|
+
if (existing && !dirtyBlockIds.has(block.blockId)) {
|
|
750
|
+
this.updateBlockAttributes(existing, block);
|
|
751
|
+
this.root.appendChild(existing);
|
|
752
|
+
this.blockElements.set(block.blockId, existing);
|
|
753
|
+
} else {
|
|
754
|
+
const wrapper = this.createBlockWrapper(doc, block);
|
|
755
|
+
wrapper.innerHTML = htmlMap.get(block.blockId) ?? "";
|
|
756
|
+
this.root.appendChild(wrapper);
|
|
757
|
+
this.blockElements.set(block.blockId, wrapper);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Update a single block's HTML content in place.
|
|
763
|
+
* Used by idle-scheduled backfill and deferred renders.
|
|
764
|
+
*/
|
|
765
|
+
patchBlockHtml(blockId, html) {
|
|
766
|
+
const el = this.blockElements.get(blockId);
|
|
767
|
+
if (!el) return;
|
|
768
|
+
this.cleanupDomMount(blockId);
|
|
769
|
+
el.innerHTML = html;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Mount a DOM renderer into a block's wrapper element.
|
|
773
|
+
* The renderer controls the wrapper's DOM directly.
|
|
774
|
+
*/
|
|
775
|
+
mountDomContent(blockId, mountFn, unmountFn) {
|
|
776
|
+
const el = this.blockElements.get(blockId);
|
|
777
|
+
if (!el) return;
|
|
778
|
+
this.cleanupDomMount(blockId);
|
|
779
|
+
el.innerHTML = "";
|
|
780
|
+
mountFn(el);
|
|
781
|
+
if (unmountFn) this.domUnmounts.set(blockId, unmountFn);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Cleanup a previous DOM mount for a block, if any.
|
|
785
|
+
*/
|
|
786
|
+
cleanupDomMount(blockId) {
|
|
787
|
+
const unmount = this.domUnmounts.get(blockId);
|
|
788
|
+
if (unmount) {
|
|
789
|
+
try {
|
|
790
|
+
unmount();
|
|
791
|
+
} catch (e) {
|
|
792
|
+
if (process.env.NODE_ENV !== "production") {
|
|
793
|
+
console.warn(`[PreviewDomPatcher] cleanupDomMount failed for block "${blockId}":`, e);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
this.domUnmounts.delete(blockId);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
createBlockWrapper(doc, block) {
|
|
800
|
+
const wrapper = doc.createElement("div");
|
|
801
|
+
wrapper.setAttribute(BLOCK_ATTR, block.blockId);
|
|
802
|
+
this.updateBlockAttributes(wrapper, block);
|
|
803
|
+
return wrapper;
|
|
804
|
+
}
|
|
805
|
+
updateBlockAttributes(el, block) {
|
|
806
|
+
el.setAttribute(BLOCK_ID_ATTR, block.blockId);
|
|
807
|
+
el.setAttribute(SOURCE_START_ATTR, String(block.startLine));
|
|
808
|
+
el.setAttribute(SOURCE_END_ATTR, String(block.endLine));
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Get the DOM element for a given blockId.
|
|
812
|
+
*/
|
|
813
|
+
getBlockElement(blockId) {
|
|
814
|
+
return this.blockElements.get(blockId) ?? null;
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// src/renderer/registry.ts
|
|
819
|
+
function createRendererRegistry() {
|
|
820
|
+
const renderers = /* @__PURE__ */ new Map();
|
|
821
|
+
return {
|
|
822
|
+
get(kind) {
|
|
823
|
+
return renderers.get(kind) ?? null;
|
|
824
|
+
},
|
|
825
|
+
register(kind, renderer) {
|
|
826
|
+
renderers.set(kind, renderer);
|
|
827
|
+
},
|
|
828
|
+
unregister(kind) {
|
|
829
|
+
renderers.delete(kind);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/renderer/default-renderer.ts
|
|
835
|
+
function escapeHtml(text) {
|
|
836
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
837
|
+
}
|
|
838
|
+
var SAFE_URL_PATTERN = /^(?:https?:|mailto:|#|\/)/i;
|
|
839
|
+
function sanitizeUrl(url) {
|
|
840
|
+
return SAFE_URL_PATTERN.test(url) ? url : "";
|
|
841
|
+
}
|
|
842
|
+
function renderInline(text) {
|
|
843
|
+
let html = escapeHtml(text);
|
|
844
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
845
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
846
|
+
html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
847
|
+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
848
|
+
html = html.replace(/_(.+?)_/g, "<em>$1</em>");
|
|
849
|
+
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => `<img src="${sanitizeUrl(src)}" alt="${alt}" />`);
|
|
850
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${sanitizeUrl(href)}">${label}</a>`);
|
|
851
|
+
return html;
|
|
852
|
+
}
|
|
853
|
+
function renderBlockDefault(block) {
|
|
854
|
+
switch (block.kind) {
|
|
855
|
+
case "paragraph": {
|
|
856
|
+
const content = renderInline(block.raw);
|
|
857
|
+
if (block.raw.trim() === "") return "";
|
|
858
|
+
return `<p>${content}</p>`;
|
|
859
|
+
}
|
|
860
|
+
case "heading": {
|
|
861
|
+
const level = block.headingLevel ?? 1;
|
|
862
|
+
const text = block.raw.replace(/^#{1,6}\s*/, "");
|
|
863
|
+
return `<h${level}>${renderInline(text)}</h${level}>`;
|
|
864
|
+
}
|
|
865
|
+
case "code-fence": {
|
|
866
|
+
const lines = block.raw.split("\n");
|
|
867
|
+
const openMatch = lines[0]?.match(/^[`~]{3,}(.*)/);
|
|
868
|
+
const lang = openMatch?.[1]?.trim().split(/\s+/)[0] || "";
|
|
869
|
+
const closeFenceIdx = lines.length - 1;
|
|
870
|
+
const hasCloseFence = closeFenceIdx > 0 && /^[`~]{3,}\s*$/.test(lines[closeFenceIdx]);
|
|
871
|
+
const codeLines = lines.slice(1, hasCloseFence ? closeFenceIdx : void 0);
|
|
872
|
+
const code = escapeHtml(codeLines.join("\n"));
|
|
873
|
+
const langAttr = lang ? ` class="language-${lang}"` : "";
|
|
874
|
+
return `<pre data-source-line-start="${block.startLine}" data-source-line-end="${block.endLine}" data-block-id="${block.blockId}"><code${langAttr}>${code}</code></pre>`;
|
|
875
|
+
}
|
|
876
|
+
case "unordered-list": {
|
|
877
|
+
const items = block.raw.split("\n").filter((line) => line.trim()).map((line) => {
|
|
878
|
+
const text = line.replace(/^\s*[-*+]\s+/, "");
|
|
879
|
+
return `<li>${renderInline(text)}</li>`;
|
|
880
|
+
});
|
|
881
|
+
return `<ul>${items.join("")}</ul>`;
|
|
882
|
+
}
|
|
883
|
+
case "ordered-list": {
|
|
884
|
+
const items = block.raw.split("\n").filter((line) => line.trim()).map((line) => {
|
|
885
|
+
const text = line.replace(/^\s*\d+\.\s+/, "");
|
|
886
|
+
return `<li>${renderInline(text)}</li>`;
|
|
887
|
+
});
|
|
888
|
+
return `<ol>${items.join("")}</ol>`;
|
|
889
|
+
}
|
|
890
|
+
case "blockquote": {
|
|
891
|
+
const lines = block.raw.split("\n").map((line) => line.replace(/^\s*>\s?/, ""));
|
|
892
|
+
const content = lines.map((line) => `<p>${renderInline(line)}</p>`).join("");
|
|
893
|
+
return `<blockquote>${content}</blockquote>`;
|
|
894
|
+
}
|
|
895
|
+
case "thematic-break":
|
|
896
|
+
return "<hr />";
|
|
897
|
+
case "table":
|
|
898
|
+
case "math-block":
|
|
899
|
+
case "html-block":
|
|
900
|
+
case "custom":
|
|
901
|
+
return `<div>${escapeHtml(block.raw)}</div>`;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/strategies/shared.ts
|
|
906
|
+
var scheduleIdle = typeof globalThis.requestIdleCallback === "function" ? (fn) => globalThis.requestIdleCallback(fn) : (fn) => setTimeout(fn, 1);
|
|
907
|
+
var cancelIdle = typeof globalThis.cancelIdleCallback === "function" ? (id) => globalThis.cancelIdleCallback(id) : (id) => clearTimeout(id);
|
|
908
|
+
function isCacheInvalidation(state) {
|
|
909
|
+
return state.dirtyReason === "theme-change" || state.dirtyReason === "renderer-change" || state.dirtyReason === "custom-block-change";
|
|
910
|
+
}
|
|
911
|
+
function collectDirtyBlockIds(state, oldBlocks) {
|
|
912
|
+
const dirty = /* @__PURE__ */ new Set();
|
|
913
|
+
const newBlocks = state.previewBlocks;
|
|
914
|
+
if (!state.dirtyRange) return dirty;
|
|
915
|
+
const { startBlock, endBlock } = state.dirtyRange;
|
|
916
|
+
for (let i = startBlock; i <= endBlock && i < newBlocks.length; i++) {
|
|
917
|
+
dirty.add(newBlocks[i].blockId);
|
|
918
|
+
}
|
|
919
|
+
const newIds = new Set(newBlocks.map((b) => b.blockId));
|
|
920
|
+
for (const old of oldBlocks) {
|
|
921
|
+
if (!newIds.has(old.blockId)) {
|
|
922
|
+
dirty.add(old.blockId);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return dirty;
|
|
926
|
+
}
|
|
927
|
+
function makeContext(version, themeKey, signal) {
|
|
928
|
+
return { version, themeKey, sourceLineOffset: 0, abortSignal: signal };
|
|
929
|
+
}
|
|
930
|
+
function cancelAllIdle(pendingIdleIds) {
|
|
931
|
+
for (const id of pendingIdleIds) cancelIdle(id);
|
|
932
|
+
pendingIdleIds.length = 0;
|
|
933
|
+
}
|
|
934
|
+
function isDeferred(block, registry) {
|
|
935
|
+
const def = registry.get(block.kind);
|
|
936
|
+
return def?.priority === "deferred";
|
|
937
|
+
}
|
|
938
|
+
function createRenderBlockFull(registry, renderCache, externalRenderBlock) {
|
|
939
|
+
return async function renderBlockFull(block, baseContext) {
|
|
940
|
+
const context = {
|
|
941
|
+
...baseContext,
|
|
942
|
+
sourceLineOffset: block.startLine - 1
|
|
943
|
+
};
|
|
944
|
+
const customRenderer = registry.get(block.kind);
|
|
945
|
+
if (customRenderer) {
|
|
946
|
+
const result = await customRenderer.render(block, context);
|
|
947
|
+
if (result.kind === "html") {
|
|
948
|
+
renderCache.set(block.renderKey, result.html);
|
|
949
|
+
}
|
|
950
|
+
return result;
|
|
951
|
+
}
|
|
952
|
+
const cached = renderCache.get(block.renderKey);
|
|
953
|
+
if (cached !== void 0) return { kind: "html", html: cached };
|
|
954
|
+
if (externalRenderBlock) {
|
|
955
|
+
const html2 = await externalRenderBlock(block, context);
|
|
956
|
+
renderCache.set(block.renderKey, html2);
|
|
957
|
+
return { kind: "html", html: html2 };
|
|
958
|
+
}
|
|
959
|
+
const html = renderBlockDefault(block);
|
|
960
|
+
renderCache.set(block.renderKey, html);
|
|
961
|
+
return { kind: "html", html };
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/strategies/incremental.ts
|
|
966
|
+
var COLD_START_SYNC_LIMIT = 20;
|
|
967
|
+
var COLD_START_BLOCK_THRESHOLD = 50;
|
|
968
|
+
function createIncrementalEngine(options) {
|
|
969
|
+
const themeKey = options?.themeKey ?? "";
|
|
970
|
+
const viewportFirst = options?.viewportFirst ?? false;
|
|
971
|
+
const registry = options?.registry ?? createRendererRegistry();
|
|
972
|
+
const externalRenderBlock = options?.renderBlock;
|
|
973
|
+
const onContentUpdate = options?.onContentUpdate;
|
|
974
|
+
const patcher = new PreviewDomPatcher();
|
|
975
|
+
const renderCache = /* @__PURE__ */ new Map();
|
|
976
|
+
let renderedVersion = 0;
|
|
977
|
+
let lastBlocks = [];
|
|
978
|
+
let mounted = false;
|
|
979
|
+
let pendingAbort = null;
|
|
980
|
+
let pendingIdleIds = [];
|
|
981
|
+
let pendingBackfill = /* @__PURE__ */ new Set();
|
|
982
|
+
const renderBlockFull = createRenderBlockFull(registry, renderCache, externalRenderBlock);
|
|
983
|
+
function applyResult(blockId, result) {
|
|
984
|
+
if (result.kind === "html") {
|
|
985
|
+
patcher.patchBlockHtml(blockId, result.html);
|
|
986
|
+
} else {
|
|
987
|
+
patcher.mountDomContent(blockId, result.mount, result.unmount);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
function scheduleBlockRender(block, baseContext, signal, targetVersion) {
|
|
991
|
+
const id = scheduleIdle(() => {
|
|
992
|
+
if (signal.aborted || renderedVersion > targetVersion) return;
|
|
993
|
+
renderBlockFull(block, baseContext).then((result) => {
|
|
994
|
+
if (signal.aborted || renderedVersion > targetVersion) return;
|
|
995
|
+
applyResult(block.blockId, result);
|
|
996
|
+
pendingBackfill.delete(block.blockId);
|
|
997
|
+
onContentUpdate?.();
|
|
998
|
+
}).catch((err) => {
|
|
999
|
+
if (!signal.aborted) {
|
|
1000
|
+
console.error(`Preview deferred render error (${block.blockId}):`, err);
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
pendingIdleIds.push(id);
|
|
1005
|
+
}
|
|
1006
|
+
async function rescheduleBackfill(blocks, state, baseContext, signal) {
|
|
1007
|
+
cancelAllIdle(pendingIdleIds);
|
|
1008
|
+
const visibleSet = new Set(state.visibleBlockIds);
|
|
1009
|
+
const pendingBlocks = blocks.filter((b) => pendingBackfill.has(b.blockId));
|
|
1010
|
+
for (const block of pendingBlocks) {
|
|
1011
|
+
if (signal.aborted) return;
|
|
1012
|
+
if (!visibleSet.has(block.blockId)) continue;
|
|
1013
|
+
const result = await renderBlockFull(block, baseContext);
|
|
1014
|
+
if (signal.aborted) return;
|
|
1015
|
+
applyResult(block.blockId, result);
|
|
1016
|
+
pendingBackfill.delete(block.blockId);
|
|
1017
|
+
onContentUpdate?.();
|
|
1018
|
+
}
|
|
1019
|
+
for (const block of pendingBlocks) {
|
|
1020
|
+
if (signal.aborted) return;
|
|
1021
|
+
if (!pendingBackfill.has(block.blockId)) continue;
|
|
1022
|
+
scheduleBlockRender(block, baseContext, signal, state.version);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
mount(root) {
|
|
1027
|
+
patcher.mount(root);
|
|
1028
|
+
mounted = true;
|
|
1029
|
+
},
|
|
1030
|
+
destroy() {
|
|
1031
|
+
pendingAbort?.abort();
|
|
1032
|
+
pendingAbort = null;
|
|
1033
|
+
cancelAllIdle(pendingIdleIds);
|
|
1034
|
+
patcher.destroy();
|
|
1035
|
+
renderCache.clear();
|
|
1036
|
+
pendingBackfill.clear();
|
|
1037
|
+
mounted = false;
|
|
1038
|
+
lastBlocks = [];
|
|
1039
|
+
renderedVersion = 0;
|
|
1040
|
+
},
|
|
1041
|
+
async update(state) {
|
|
1042
|
+
if (state.version <= renderedVersion) return;
|
|
1043
|
+
if (!mounted) return;
|
|
1044
|
+
pendingAbort?.abort();
|
|
1045
|
+
cancelAllIdle(pendingIdleIds);
|
|
1046
|
+
const abortController = new AbortController();
|
|
1047
|
+
pendingAbort = abortController;
|
|
1048
|
+
const signal = abortController.signal;
|
|
1049
|
+
const baseContext = makeContext(state.version, themeKey, signal);
|
|
1050
|
+
const blocks = state.previewBlocks;
|
|
1051
|
+
try {
|
|
1052
|
+
if (isCacheInvalidation(state)) {
|
|
1053
|
+
renderCache.clear();
|
|
1054
|
+
}
|
|
1055
|
+
const isInitial = lastBlocks.length === 0 || state.dirtyReason === "full-rebuild" || isCacheInvalidation(state);
|
|
1056
|
+
if (isInitial) {
|
|
1057
|
+
await handleFullRender(blocks, state, baseContext, signal);
|
|
1058
|
+
} else {
|
|
1059
|
+
await handleIncrementalUpdate(blocks, state, baseContext, signal);
|
|
1060
|
+
}
|
|
1061
|
+
if (signal.aborted) return;
|
|
1062
|
+
lastBlocks = blocks;
|
|
1063
|
+
renderedVersion = state.version;
|
|
1064
|
+
onContentUpdate?.();
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
if (!signal.aborted) {
|
|
1067
|
+
console.error("Preview engine render error:", err);
|
|
1068
|
+
}
|
|
1069
|
+
} finally {
|
|
1070
|
+
if (pendingAbort === abortController) {
|
|
1071
|
+
pendingAbort = null;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
getRenderedVersion() {
|
|
1076
|
+
return renderedVersion;
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
async function handleFullRender(blocks, state, baseContext, signal) {
|
|
1080
|
+
const visibleSet = new Set(state.visibleBlockIds);
|
|
1081
|
+
const useViewportFirst = viewportFirst && visibleSet.size > 0;
|
|
1082
|
+
const useColdStartLimit = !useViewportFirst && blocks.length > COLD_START_BLOCK_THRESHOLD;
|
|
1083
|
+
const realtimeVisible = [];
|
|
1084
|
+
const realtimeOffscreen = [];
|
|
1085
|
+
const deferredAll = [];
|
|
1086
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
1087
|
+
const block = blocks[i];
|
|
1088
|
+
if (isDeferred(block, registry)) {
|
|
1089
|
+
deferredAll.push(block);
|
|
1090
|
+
} else if (useViewportFirst && !visibleSet.has(block.blockId)) {
|
|
1091
|
+
realtimeOffscreen.push(block);
|
|
1092
|
+
} else if (useColdStartLimit && i >= COLD_START_SYNC_LIMIT) {
|
|
1093
|
+
realtimeOffscreen.push(block);
|
|
1094
|
+
} else {
|
|
1095
|
+
realtimeVisible.push(block);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
const htmlMap = /* @__PURE__ */ new Map();
|
|
1099
|
+
const domMounts = [];
|
|
1100
|
+
for (const block of realtimeVisible) {
|
|
1101
|
+
if (signal.aborted) return;
|
|
1102
|
+
const result = await renderBlockFull(block, baseContext);
|
|
1103
|
+
if (result.kind === "html") {
|
|
1104
|
+
htmlMap.set(block.blockId, result.html);
|
|
1105
|
+
} else {
|
|
1106
|
+
htmlMap.set(block.blockId, "");
|
|
1107
|
+
domMounts.push({ blockId: block.blockId, result });
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (signal.aborted) return;
|
|
1111
|
+
patcher.fullRender(blocks, htmlMap);
|
|
1112
|
+
for (const { blockId, result } of domMounts) {
|
|
1113
|
+
if (signal.aborted) return;
|
|
1114
|
+
applyResult(blockId, result);
|
|
1115
|
+
}
|
|
1116
|
+
pendingBackfill = /* @__PURE__ */ new Set();
|
|
1117
|
+
for (const block of realtimeOffscreen) {
|
|
1118
|
+
pendingBackfill.add(block.blockId);
|
|
1119
|
+
scheduleBlockRender(block, baseContext, signal, state.version);
|
|
1120
|
+
}
|
|
1121
|
+
for (const block of deferredAll) {
|
|
1122
|
+
pendingBackfill.add(block.blockId);
|
|
1123
|
+
scheduleBlockRender(block, baseContext, signal, state.version);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
async function handleIncrementalUpdate(blocks, state, baseContext, signal) {
|
|
1127
|
+
const dirtyBlockIds = collectDirtyBlockIds(state, lastBlocks);
|
|
1128
|
+
if (dirtyBlockIds.size === 0) {
|
|
1129
|
+
if (viewportFirst && pendingBackfill.size > 0 && state.visibleBlockIds.length > 0) {
|
|
1130
|
+
await rescheduleBackfill(blocks, state, baseContext, signal);
|
|
1131
|
+
}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const realtimeDirty = [];
|
|
1135
|
+
const deferredDirty = [];
|
|
1136
|
+
for (const block of blocks) {
|
|
1137
|
+
if (!dirtyBlockIds.has(block.blockId)) continue;
|
|
1138
|
+
if (isDeferred(block, registry)) {
|
|
1139
|
+
deferredDirty.push(block);
|
|
1140
|
+
} else {
|
|
1141
|
+
realtimeDirty.push(block);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const htmlMap = /* @__PURE__ */ new Map();
|
|
1145
|
+
const realtimeDirtyIds = /* @__PURE__ */ new Set();
|
|
1146
|
+
const domMountResults = /* @__PURE__ */ new Map();
|
|
1147
|
+
for (const block of realtimeDirty) {
|
|
1148
|
+
if (signal.aborted) return;
|
|
1149
|
+
const result = await renderBlockFull(block, baseContext);
|
|
1150
|
+
realtimeDirtyIds.add(block.blockId);
|
|
1151
|
+
if (result.kind === "html") {
|
|
1152
|
+
htmlMap.set(block.blockId, result.html);
|
|
1153
|
+
} else {
|
|
1154
|
+
htmlMap.set(block.blockId, "");
|
|
1155
|
+
domMountResults.set(block.blockId, result);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
for (const block of blocks) {
|
|
1159
|
+
if (realtimeDirtyIds.has(block.blockId)) continue;
|
|
1160
|
+
if (dirtyBlockIds.has(block.blockId)) continue;
|
|
1161
|
+
const cached = renderCache.get(block.renderKey);
|
|
1162
|
+
if (cached !== void 0) {
|
|
1163
|
+
htmlMap.set(block.blockId, cached);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (signal.aborted) return;
|
|
1167
|
+
patcher.patch(blocks, htmlMap, realtimeDirtyIds);
|
|
1168
|
+
for (const [blockId, result] of domMountResults) {
|
|
1169
|
+
if (signal.aborted) return;
|
|
1170
|
+
applyResult(blockId, result);
|
|
1171
|
+
}
|
|
1172
|
+
for (const block of deferredDirty) {
|
|
1173
|
+
scheduleBlockRender(block, baseContext, signal, state.version);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/virtual/block-layout-map.ts
|
|
1179
|
+
var BlockLayoutMap = class {
|
|
1180
|
+
layouts = [];
|
|
1181
|
+
indexMap = /* @__PURE__ */ new Map();
|
|
1182
|
+
_totalHeight = 0;
|
|
1183
|
+
get totalHeight() {
|
|
1184
|
+
return this._totalHeight;
|
|
1185
|
+
}
|
|
1186
|
+
get size() {
|
|
1187
|
+
return this.layouts.length;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Rebuild the layout table from a new block list.
|
|
1191
|
+
* Blocks that already have measured heights retain them;
|
|
1192
|
+
* new blocks use the provided estimated height.
|
|
1193
|
+
*/
|
|
1194
|
+
rebuild(blockIds, getEstimatedHeight) {
|
|
1195
|
+
const oldHeights = /* @__PURE__ */ new Map();
|
|
1196
|
+
for (const layout of this.layouts) {
|
|
1197
|
+
oldHeights.set(layout.blockId, { height: layout.height, measured: layout.measured });
|
|
1198
|
+
}
|
|
1199
|
+
this.layouts = [];
|
|
1200
|
+
this.indexMap.clear();
|
|
1201
|
+
let offsetY = 0;
|
|
1202
|
+
for (let i = 0; i < blockIds.length; i++) {
|
|
1203
|
+
const blockId = blockIds[i];
|
|
1204
|
+
const existing = oldHeights.get(blockId);
|
|
1205
|
+
const height = existing ? existing.height : getEstimatedHeight(blockId);
|
|
1206
|
+
const measured = existing ? existing.measured : false;
|
|
1207
|
+
this.layouts.push({ blockId, offsetY, height, measured });
|
|
1208
|
+
this.indexMap.set(blockId, i);
|
|
1209
|
+
offsetY += height;
|
|
1210
|
+
}
|
|
1211
|
+
this._totalHeight = offsetY;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Update a single block's height. Returns true if the height actually changed.
|
|
1215
|
+
* Recomputes offsetY for all subsequent blocks.
|
|
1216
|
+
*/
|
|
1217
|
+
updateHeight(blockId, height, measured) {
|
|
1218
|
+
const idx = this.indexMap.get(blockId);
|
|
1219
|
+
if (idx === void 0) return false;
|
|
1220
|
+
const layout = this.layouts[idx];
|
|
1221
|
+
if (layout.height === height && layout.measured === measured) return false;
|
|
1222
|
+
layout.height = height;
|
|
1223
|
+
layout.measured = measured;
|
|
1224
|
+
this.recomputeFrom(idx);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Batch-update multiple block heights with a single recompute pass.
|
|
1229
|
+
* Avoids O(k·n) when k blocks change in the same frame (e.g. ResizeObserver).
|
|
1230
|
+
*/
|
|
1231
|
+
updateHeightBatch(updates) {
|
|
1232
|
+
let minChangedIndex = Infinity;
|
|
1233
|
+
for (const { blockId, height, measured } of updates) {
|
|
1234
|
+
const idx = this.indexMap.get(blockId);
|
|
1235
|
+
if (idx === void 0) continue;
|
|
1236
|
+
const layout = this.layouts[idx];
|
|
1237
|
+
if (layout.height === height && layout.measured === measured) continue;
|
|
1238
|
+
layout.height = height;
|
|
1239
|
+
layout.measured = measured;
|
|
1240
|
+
if (idx < minChangedIndex) minChangedIndex = idx;
|
|
1241
|
+
}
|
|
1242
|
+
if (minChangedIndex === Infinity) return false;
|
|
1243
|
+
this.recomputeFrom(minChangedIndex);
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Invalidate all measured heights (e.g., on container width change).
|
|
1248
|
+
* Heights revert to estimated values via the provided estimator.
|
|
1249
|
+
*/
|
|
1250
|
+
invalidateAll(getEstimatedHeight) {
|
|
1251
|
+
let offsetY = 0;
|
|
1252
|
+
for (const layout of this.layouts) {
|
|
1253
|
+
layout.height = getEstimatedHeight(layout.blockId);
|
|
1254
|
+
layout.measured = false;
|
|
1255
|
+
layout.offsetY = offsetY;
|
|
1256
|
+
offsetY += layout.height;
|
|
1257
|
+
}
|
|
1258
|
+
this._totalHeight = offsetY;
|
|
1259
|
+
}
|
|
1260
|
+
getLayout(blockId) {
|
|
1261
|
+
const idx = this.indexMap.get(blockId);
|
|
1262
|
+
return idx !== void 0 ? this.layouts[idx] : null;
|
|
1263
|
+
}
|
|
1264
|
+
getLayoutAt(index) {
|
|
1265
|
+
return this.layouts[index] ?? null;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Find the range of block indices visible within [scrollTop, scrollTop + viewportHeight],
|
|
1269
|
+
* expanded by bufferPx on each side.
|
|
1270
|
+
*/
|
|
1271
|
+
getVisibleRange(scrollTop, viewportHeight, bufferPx) {
|
|
1272
|
+
const top = Math.max(0, scrollTop - bufferPx);
|
|
1273
|
+
const bottom = scrollTop + viewportHeight + bufferPx;
|
|
1274
|
+
if (this.layouts.length === 0) {
|
|
1275
|
+
return { startIndex: 0, endIndex: -1 };
|
|
1276
|
+
}
|
|
1277
|
+
const startIndex = this.findIndexAtOffset(top);
|
|
1278
|
+
const endIndex = this.findIndexAtOffset(bottom);
|
|
1279
|
+
return {
|
|
1280
|
+
startIndex: Math.max(0, startIndex),
|
|
1281
|
+
endIndex: Math.min(this.layouts.length - 1, endIndex)
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
getAllLayouts() {
|
|
1285
|
+
return this.layouts;
|
|
1286
|
+
}
|
|
1287
|
+
// -------------------------------------------------------------------------
|
|
1288
|
+
// Private
|
|
1289
|
+
// -------------------------------------------------------------------------
|
|
1290
|
+
recomputeFrom(fromIndex) {
|
|
1291
|
+
let offsetY = fromIndex > 0 ? this.layouts[fromIndex - 1].offsetY + this.layouts[fromIndex - 1].height : 0;
|
|
1292
|
+
for (let i = fromIndex; i < this.layouts.length; i++) {
|
|
1293
|
+
this.layouts[i].offsetY = offsetY;
|
|
1294
|
+
offsetY += this.layouts[i].height;
|
|
1295
|
+
}
|
|
1296
|
+
this._totalHeight = offsetY;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Binary search: find the index of the block that contains the given Y offset.
|
|
1300
|
+
*/
|
|
1301
|
+
findIndexAtOffset(y) {
|
|
1302
|
+
let lo = 0;
|
|
1303
|
+
let hi = this.layouts.length - 1;
|
|
1304
|
+
while (lo <= hi) {
|
|
1305
|
+
const mid = lo + hi >>> 1;
|
|
1306
|
+
const layout = this.layouts[mid];
|
|
1307
|
+
if (layout.offsetY + layout.height <= y) {
|
|
1308
|
+
lo = mid + 1;
|
|
1309
|
+
} else if (layout.offsetY > y) {
|
|
1310
|
+
hi = mid - 1;
|
|
1311
|
+
} else {
|
|
1312
|
+
return mid;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return Math.min(lo, this.layouts.length - 1);
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
// src/virtual/height-cache.ts
|
|
1320
|
+
var HeightProjectionCache = class {
|
|
1321
|
+
cache = /* @__PURE__ */ new Map();
|
|
1322
|
+
get(renderKey) {
|
|
1323
|
+
return this.cache.get(renderKey);
|
|
1324
|
+
}
|
|
1325
|
+
set(renderKey, height) {
|
|
1326
|
+
this.cache.set(renderKey, height);
|
|
1327
|
+
}
|
|
1328
|
+
has(renderKey) {
|
|
1329
|
+
return this.cache.has(renderKey);
|
|
1330
|
+
}
|
|
1331
|
+
clear() {
|
|
1332
|
+
this.cache.clear();
|
|
1333
|
+
}
|
|
1334
|
+
get size() {
|
|
1335
|
+
return this.cache.size;
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// src/virtual/height-estimator.ts
|
|
1340
|
+
var DEFAULT_PARAGRAPH_HEIGHT = 40;
|
|
1341
|
+
var FALLBACK_BLOCK_HEIGHT = 40;
|
|
1342
|
+
var CODE_LINE_HEIGHT = 22;
|
|
1343
|
+
var CODE_MIN_HEIGHT = 44;
|
|
1344
|
+
var TEXT_LINE_HEIGHT = 24;
|
|
1345
|
+
var THEMATIC_BREAK_HEIGHT = 24;
|
|
1346
|
+
var HEAVY_SMALL_THRESHOLD = 3;
|
|
1347
|
+
var HEAVY_MEDIUM_THRESHOLD = 10;
|
|
1348
|
+
var HEAVY_LARGE_THRESHOLD = 50;
|
|
1349
|
+
var HEAVY_SMALL_HEIGHT = 60;
|
|
1350
|
+
var HEAVY_MEDIUM_HEIGHT = 200;
|
|
1351
|
+
var HEAVY_LARGE_HEIGHT = 500;
|
|
1352
|
+
var HEAVY_KINDS = /* @__PURE__ */ new Set(["mermaid", "katex", "math-block", "chart", "embed"]);
|
|
1353
|
+
function estimateBlockHeight(block) {
|
|
1354
|
+
const lineCount = block.endLine - block.startLine + 1;
|
|
1355
|
+
if (HEAVY_KINDS.has(block.kind)) {
|
|
1356
|
+
return estimateHeavyBlockHeight(lineCount);
|
|
1357
|
+
}
|
|
1358
|
+
if (block.kind === "code-fence") {
|
|
1359
|
+
return Math.max(CODE_MIN_HEIGHT, lineCount * CODE_LINE_HEIGHT);
|
|
1360
|
+
}
|
|
1361
|
+
if (block.kind === "thematic-break") {
|
|
1362
|
+
return THEMATIC_BREAK_HEIGHT;
|
|
1363
|
+
}
|
|
1364
|
+
if (lineCount <= 1) return DEFAULT_PARAGRAPH_HEIGHT;
|
|
1365
|
+
return DEFAULT_PARAGRAPH_HEIGHT + (lineCount - 1) * TEXT_LINE_HEIGHT;
|
|
1366
|
+
}
|
|
1367
|
+
function estimateHeavyBlockHeight(lineCount) {
|
|
1368
|
+
if (lineCount <= HEAVY_SMALL_THRESHOLD) return HEAVY_SMALL_HEIGHT;
|
|
1369
|
+
if (lineCount <= HEAVY_MEDIUM_THRESHOLD) {
|
|
1370
|
+
const t = (lineCount - HEAVY_SMALL_THRESHOLD) / (HEAVY_MEDIUM_THRESHOLD - HEAVY_SMALL_THRESHOLD);
|
|
1371
|
+
return HEAVY_SMALL_HEIGHT + t * (HEAVY_MEDIUM_HEIGHT - HEAVY_SMALL_HEIGHT);
|
|
1372
|
+
}
|
|
1373
|
+
if (lineCount <= HEAVY_LARGE_THRESHOLD) {
|
|
1374
|
+
const t = (lineCount - HEAVY_MEDIUM_THRESHOLD) / (HEAVY_LARGE_THRESHOLD - HEAVY_MEDIUM_THRESHOLD);
|
|
1375
|
+
return HEAVY_MEDIUM_HEIGHT + t * (HEAVY_LARGE_HEIGHT - HEAVY_MEDIUM_HEIGHT);
|
|
1376
|
+
}
|
|
1377
|
+
return HEAVY_LARGE_HEIGHT;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/virtual/offscreen-measurer.ts
|
|
1381
|
+
var OffscreenMeasurer = class {
|
|
1382
|
+
container = null;
|
|
1383
|
+
widthSource = null;
|
|
1384
|
+
resizeObserver = null;
|
|
1385
|
+
currentWidth = 0;
|
|
1386
|
+
/**
|
|
1387
|
+
* Initialize the measurer. Creates the offscreen container and attaches
|
|
1388
|
+
* it to the document, syncing width with the source element.
|
|
1389
|
+
*/
|
|
1390
|
+
init(widthSource) {
|
|
1391
|
+
this.widthSource = widthSource;
|
|
1392
|
+
const doc = widthSource.ownerDocument;
|
|
1393
|
+
this.container = doc.createElement("div");
|
|
1394
|
+
this.container.setAttribute("aria-hidden", "true");
|
|
1395
|
+
this.container.style.cssText = [
|
|
1396
|
+
"visibility: hidden",
|
|
1397
|
+
"position: absolute",
|
|
1398
|
+
"top: -9999px",
|
|
1399
|
+
"left: 0",
|
|
1400
|
+
"contain: layout paint style",
|
|
1401
|
+
"overflow: hidden",
|
|
1402
|
+
"pointer-events: none"
|
|
1403
|
+
].join(";");
|
|
1404
|
+
this.currentWidth = widthSource.clientWidth;
|
|
1405
|
+
this.container.style.width = `${this.currentWidth}px`;
|
|
1406
|
+
doc.body.appendChild(this.container);
|
|
1407
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
1408
|
+
for (const entry of entries) {
|
|
1409
|
+
const newWidth = entry.contentRect.width;
|
|
1410
|
+
if (newWidth !== this.currentWidth && this.container) {
|
|
1411
|
+
this.currentWidth = newWidth;
|
|
1412
|
+
this.container.style.width = `${newWidth}px`;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
this.resizeObserver.observe(widthSource);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Get the current width of the measurer container.
|
|
1420
|
+
*/
|
|
1421
|
+
getWidth() {
|
|
1422
|
+
return this.currentWidth;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Cleanup: disconnect observer, remove container from DOM.
|
|
1426
|
+
*/
|
|
1427
|
+
destroy() {
|
|
1428
|
+
this.resizeObserver?.disconnect();
|
|
1429
|
+
this.resizeObserver = null;
|
|
1430
|
+
this.container?.remove();
|
|
1431
|
+
this.container = null;
|
|
1432
|
+
this.widthSource = null;
|
|
1433
|
+
this.currentWidth = 0;
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
// src/virtual/viewport-manager.ts
|
|
1438
|
+
var BLOCK_ID_ATTR2 = "data-block-id";
|
|
1439
|
+
var SOURCE_START_ATTR2 = "data-source-line-start";
|
|
1440
|
+
var SOURCE_END_ATTR2 = "data-source-line-end";
|
|
1441
|
+
var HEIGHT_TRANSITION = "top 0.15s ease-out";
|
|
1442
|
+
var TOTAL_HEIGHT_TRANSITION = "height 0.15s ease-out";
|
|
1443
|
+
var SKELETON_GAP = 8;
|
|
1444
|
+
function createSkeletonHtml(height) {
|
|
1445
|
+
const innerHeight = Math.max(height - SKELETON_GAP, 8);
|
|
1446
|
+
return `<div class="owo-skeleton" style="height:${innerHeight}px;margin-bottom:${SKELETON_GAP}px;border-radius:4px;background:linear-gradient(90deg,#e2e2e2 25%,#efefef 50%,#e2e2e2 75%);background-size:200% 100%;animation:zm-skeleton-pulse 1.5s ease-in-out infinite" aria-hidden="true"></div>`;
|
|
1447
|
+
}
|
|
1448
|
+
function ensureSkeletonStyles(doc) {
|
|
1449
|
+
try {
|
|
1450
|
+
if (doc.getElementById?.("owo-skeleton-styles")) return;
|
|
1451
|
+
const style = doc.createElement("style");
|
|
1452
|
+
style.id = "owo-skeleton-styles";
|
|
1453
|
+
style.textContent = `@keyframes zm-skeleton-pulse{0%{background-position:200% 0}100%{background-position:-200% 0}}`;
|
|
1454
|
+
const target = doc.head ?? doc.body;
|
|
1455
|
+
target?.appendChild(style);
|
|
1456
|
+
} catch {
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
var VirtualViewportManager = class {
|
|
1460
|
+
root = null;
|
|
1461
|
+
scrollContainer = null;
|
|
1462
|
+
contentLayer = null;
|
|
1463
|
+
/** Currently mounted block wrappers, keyed by blockId. */
|
|
1464
|
+
mounted = /* @__PURE__ */ new Map();
|
|
1465
|
+
/** Unmount callbacks for DOM-mounted renderers. */
|
|
1466
|
+
domUnmounts = /* @__PURE__ */ new Map();
|
|
1467
|
+
/** Buffer size as fraction of viewport height (each side). */
|
|
1468
|
+
bufferFraction = 0.5;
|
|
1469
|
+
/**
|
|
1470
|
+
* Mount the viewport manager onto a root element.
|
|
1471
|
+
* Creates an inner scroll container with a content layer for absolute positioning.
|
|
1472
|
+
*/
|
|
1473
|
+
mount(root) {
|
|
1474
|
+
this.root = root;
|
|
1475
|
+
this.scrollContainer = root;
|
|
1476
|
+
root.style.overflow = "auto";
|
|
1477
|
+
root.style.position = "relative";
|
|
1478
|
+
const doc = root.ownerDocument;
|
|
1479
|
+
ensureSkeletonStyles(doc);
|
|
1480
|
+
this.contentLayer = doc.createElement("div");
|
|
1481
|
+
this.contentLayer.style.position = "relative";
|
|
1482
|
+
this.contentLayer.style.width = "100%";
|
|
1483
|
+
this.contentLayer.style.transition = TOTAL_HEIGHT_TRANSITION;
|
|
1484
|
+
root.appendChild(this.contentLayer);
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Update which blocks are mounted based on scroll position and layout map.
|
|
1488
|
+
* Returns the set of blockIds that are newly mounted (need rendering).
|
|
1489
|
+
*/
|
|
1490
|
+
reconcile(blocks, layoutMap) {
|
|
1491
|
+
if (!this.scrollContainer || !this.contentLayer) {
|
|
1492
|
+
return { mounted: /* @__PURE__ */ new Set(), unmounted: /* @__PURE__ */ new Set(), newlyMounted: /* @__PURE__ */ new Set() };
|
|
1493
|
+
}
|
|
1494
|
+
const scrollTop = this.scrollContainer.scrollTop;
|
|
1495
|
+
const viewportHeight = this.scrollContainer.clientHeight;
|
|
1496
|
+
const bufferPx = viewportHeight * this.bufferFraction;
|
|
1497
|
+
this.contentLayer.style.height = `${layoutMap.totalHeight}px`;
|
|
1498
|
+
const { startIndex, endIndex } = layoutMap.getVisibleRange(
|
|
1499
|
+
scrollTop,
|
|
1500
|
+
viewportHeight,
|
|
1501
|
+
bufferPx
|
|
1502
|
+
);
|
|
1503
|
+
const shouldMount = /* @__PURE__ */ new Set();
|
|
1504
|
+
for (let i = startIndex; i <= endIndex && i < blocks.length; i++) {
|
|
1505
|
+
shouldMount.add(blocks[i].blockId);
|
|
1506
|
+
}
|
|
1507
|
+
const unmounted = /* @__PURE__ */ new Set();
|
|
1508
|
+
for (const [blockId, wrapper] of this.mounted) {
|
|
1509
|
+
if (!shouldMount.has(blockId)) {
|
|
1510
|
+
this.cleanupDomMount(blockId);
|
|
1511
|
+
wrapper.remove();
|
|
1512
|
+
this.mounted.delete(blockId);
|
|
1513
|
+
unmounted.add(blockId);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const newlyMounted = /* @__PURE__ */ new Set();
|
|
1517
|
+
const doc = this.contentLayer.ownerDocument;
|
|
1518
|
+
for (let i = startIndex; i <= endIndex && i < blocks.length; i++) {
|
|
1519
|
+
const block = blocks[i];
|
|
1520
|
+
const layout = layoutMap.getLayoutAt(i);
|
|
1521
|
+
if (!layout) continue;
|
|
1522
|
+
let wrapper = this.mounted.get(block.blockId);
|
|
1523
|
+
if (!wrapper) {
|
|
1524
|
+
wrapper = doc.createElement("div");
|
|
1525
|
+
wrapper.setAttribute(BLOCK_ID_ATTR2, block.blockId);
|
|
1526
|
+
wrapper.setAttribute(SOURCE_START_ATTR2, String(block.startLine));
|
|
1527
|
+
wrapper.setAttribute(SOURCE_END_ATTR2, String(block.endLine));
|
|
1528
|
+
wrapper.style.position = "absolute";
|
|
1529
|
+
wrapper.style.left = "0";
|
|
1530
|
+
wrapper.style.width = "100%";
|
|
1531
|
+
wrapper.style.transition = HEIGHT_TRANSITION;
|
|
1532
|
+
wrapper.innerHTML = createSkeletonHtml(layout.height);
|
|
1533
|
+
this.contentLayer.appendChild(wrapper);
|
|
1534
|
+
this.mounted.set(block.blockId, wrapper);
|
|
1535
|
+
newlyMounted.add(block.blockId);
|
|
1536
|
+
} else {
|
|
1537
|
+
wrapper.setAttribute(SOURCE_START_ATTR2, String(block.startLine));
|
|
1538
|
+
wrapper.setAttribute(SOURCE_END_ATTR2, String(block.endLine));
|
|
1539
|
+
}
|
|
1540
|
+
wrapper.style.top = `${layout.offsetY}px`;
|
|
1541
|
+
}
|
|
1542
|
+
return {
|
|
1543
|
+
mounted: shouldMount,
|
|
1544
|
+
unmounted,
|
|
1545
|
+
newlyMounted
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Update all mounted block positions from the layout map.
|
|
1550
|
+
*/
|
|
1551
|
+
updateAllPositions(layoutMap) {
|
|
1552
|
+
if (this.contentLayer) {
|
|
1553
|
+
this.contentLayer.style.height = `${layoutMap.totalHeight}px`;
|
|
1554
|
+
}
|
|
1555
|
+
for (const [blockId, wrapper] of this.mounted) {
|
|
1556
|
+
const layout = layoutMap.getLayout(blockId);
|
|
1557
|
+
if (layout) {
|
|
1558
|
+
wrapper.style.top = `${layout.offsetY}px`;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Get the wrapper element for a mounted block.
|
|
1564
|
+
*/
|
|
1565
|
+
getWrapper(blockId) {
|
|
1566
|
+
return this.mounted.get(blockId) ?? null;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Set HTML content for a block wrapper.
|
|
1570
|
+
*/
|
|
1571
|
+
setBlockHtml(blockId, html) {
|
|
1572
|
+
const wrapper = this.mounted.get(blockId);
|
|
1573
|
+
if (!wrapper) return;
|
|
1574
|
+
this.cleanupDomMount(blockId);
|
|
1575
|
+
wrapper.innerHTML = html;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Mount a DOM renderer into a block wrapper.
|
|
1579
|
+
*/
|
|
1580
|
+
mountDomContent(blockId, mountFn, unmountFn) {
|
|
1581
|
+
const wrapper = this.mounted.get(blockId);
|
|
1582
|
+
if (!wrapper) return;
|
|
1583
|
+
this.cleanupDomMount(blockId);
|
|
1584
|
+
wrapper.innerHTML = "";
|
|
1585
|
+
mountFn(wrapper);
|
|
1586
|
+
if (unmountFn) this.domUnmounts.set(blockId, unmountFn);
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Get currently mounted block IDs.
|
|
1590
|
+
*/
|
|
1591
|
+
getMountedBlockIds() {
|
|
1592
|
+
return Array.from(this.mounted.keys());
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Destroy: cleanup all mounted blocks and DOM.
|
|
1596
|
+
*/
|
|
1597
|
+
destroy() {
|
|
1598
|
+
for (const unmount of this.domUnmounts.values()) {
|
|
1599
|
+
try {
|
|
1600
|
+
unmount();
|
|
1601
|
+
} catch {
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
this.domUnmounts.clear();
|
|
1605
|
+
for (const wrapper of this.mounted.values()) {
|
|
1606
|
+
wrapper.remove();
|
|
1607
|
+
}
|
|
1608
|
+
this.mounted.clear();
|
|
1609
|
+
this.contentLayer?.remove();
|
|
1610
|
+
this.contentLayer = null;
|
|
1611
|
+
this.scrollContainer = null;
|
|
1612
|
+
this.root = null;
|
|
1613
|
+
}
|
|
1614
|
+
cleanupDomMount(blockId) {
|
|
1615
|
+
const unmount = this.domUnmounts.get(blockId);
|
|
1616
|
+
if (unmount) {
|
|
1617
|
+
try {
|
|
1618
|
+
unmount();
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
this.domUnmounts.delete(blockId);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
// src/strategies/virtual.ts
|
|
1627
|
+
function createVirtualEngine(options) {
|
|
1628
|
+
const themeKey = options?.themeKey ?? "";
|
|
1629
|
+
const registry = options?.registry ?? createRendererRegistry();
|
|
1630
|
+
const externalRenderBlock = options?.renderBlock;
|
|
1631
|
+
const onContentUpdate = options?.onContentUpdate;
|
|
1632
|
+
const layoutMap = new BlockLayoutMap();
|
|
1633
|
+
const heightCache = new HeightProjectionCache();
|
|
1634
|
+
const measurer = new OffscreenMeasurer();
|
|
1635
|
+
const viewport = new VirtualViewportManager();
|
|
1636
|
+
const renderCache = /* @__PURE__ */ new Map();
|
|
1637
|
+
const renderBlockFull = createRenderBlockFull(registry, renderCache, externalRenderBlock);
|
|
1638
|
+
let renderedVersion = 0;
|
|
1639
|
+
let lastBlocks = [];
|
|
1640
|
+
let blockMap = /* @__PURE__ */ new Map();
|
|
1641
|
+
let mounted = false;
|
|
1642
|
+
let root = null;
|
|
1643
|
+
let pendingAbort = null;
|
|
1644
|
+
let pendingIdleIds = [];
|
|
1645
|
+
let scrollHandler = null;
|
|
1646
|
+
let scrollRafId = 0;
|
|
1647
|
+
let pendingScrollAbort = null;
|
|
1648
|
+
let blockResizeObserver = null;
|
|
1649
|
+
let observedWrappers = /* @__PURE__ */ new Set();
|
|
1650
|
+
let lastMeasurerWidth = 0;
|
|
1651
|
+
function getInitialHeight(block) {
|
|
1652
|
+
const cached = heightCache.get(block.renderKey);
|
|
1653
|
+
if (cached !== void 0) return cached;
|
|
1654
|
+
return estimateBlockHeight(block);
|
|
1655
|
+
}
|
|
1656
|
+
async function renderAndMountBlock(block, baseContext, signal) {
|
|
1657
|
+
if (signal.aborted) return;
|
|
1658
|
+
const result = await renderBlockFull(block, baseContext);
|
|
1659
|
+
if (signal.aborted) return;
|
|
1660
|
+
if (result.kind === "html") {
|
|
1661
|
+
viewport.setBlockHtml(block.blockId, result.html);
|
|
1662
|
+
} else {
|
|
1663
|
+
viewport.mountDomContent(block.blockId, result.mount, result.unmount);
|
|
1664
|
+
}
|
|
1665
|
+
const wrapper = viewport.getWrapper(block.blockId);
|
|
1666
|
+
if (wrapper) {
|
|
1667
|
+
const realHeight = wrapper.offsetHeight;
|
|
1668
|
+
if (realHeight > 0) {
|
|
1669
|
+
heightCache.set(block.renderKey, realHeight);
|
|
1670
|
+
const changed = layoutMap.updateHeight(block.blockId, realHeight, true);
|
|
1671
|
+
if (changed) {
|
|
1672
|
+
viewport.updateAllPositions(layoutMap);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
observeWrapper(block.blockId, wrapper);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
function setupResizeObserver() {
|
|
1679
|
+
blockResizeObserver = new ResizeObserver((entries) => {
|
|
1680
|
+
const updates = [];
|
|
1681
|
+
for (const entry of entries) {
|
|
1682
|
+
const el = entry.target;
|
|
1683
|
+
const blockId = el.getAttribute("data-block-id");
|
|
1684
|
+
if (!blockId) continue;
|
|
1685
|
+
const newHeight = entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight;
|
|
1686
|
+
if (newHeight <= 0) continue;
|
|
1687
|
+
const block = blockMap.get(blockId);
|
|
1688
|
+
if (block) {
|
|
1689
|
+
heightCache.set(block.renderKey, newHeight);
|
|
1690
|
+
}
|
|
1691
|
+
updates.push({ blockId, height: newHeight, measured: true });
|
|
1692
|
+
}
|
|
1693
|
+
if (updates.length > 0 && layoutMap.updateHeightBatch(updates)) {
|
|
1694
|
+
viewport.updateAllPositions(layoutMap);
|
|
1695
|
+
onContentUpdate?.();
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
function observeWrapper(blockId, wrapper) {
|
|
1700
|
+
if (!blockResizeObserver || observedWrappers.has(blockId)) return;
|
|
1701
|
+
blockResizeObserver.observe(wrapper);
|
|
1702
|
+
observedWrappers.add(blockId);
|
|
1703
|
+
}
|
|
1704
|
+
function unobserveWrapper(blockId) {
|
|
1705
|
+
if (!blockResizeObserver || !observedWrappers.has(blockId)) return;
|
|
1706
|
+
const wrapper = viewport.getWrapper(blockId);
|
|
1707
|
+
if (wrapper) {
|
|
1708
|
+
blockResizeObserver.unobserve(wrapper);
|
|
1709
|
+
}
|
|
1710
|
+
observedWrappers.delete(blockId);
|
|
1711
|
+
}
|
|
1712
|
+
function onScroll() {
|
|
1713
|
+
if (scrollRafId) return;
|
|
1714
|
+
scrollRafId = requestAnimationFrame(() => {
|
|
1715
|
+
scrollRafId = 0;
|
|
1716
|
+
handleScrollUpdate();
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
function handleScrollUpdate() {
|
|
1720
|
+
if (!mounted || lastBlocks.length === 0) return;
|
|
1721
|
+
const { unmounted, newlyMounted } = viewport.reconcile(lastBlocks, layoutMap);
|
|
1722
|
+
for (const blockId of unmounted) {
|
|
1723
|
+
unobserveWrapper(blockId);
|
|
1724
|
+
}
|
|
1725
|
+
if (newlyMounted.size > 0) {
|
|
1726
|
+
pendingScrollAbort?.abort();
|
|
1727
|
+
const abortController = new AbortController();
|
|
1728
|
+
pendingScrollAbort = abortController;
|
|
1729
|
+
const signal = abortController.signal;
|
|
1730
|
+
const baseContext = makeContext(renderedVersion, themeKey, signal);
|
|
1731
|
+
for (const blockId of newlyMounted) {
|
|
1732
|
+
const block = blockMap.get(blockId);
|
|
1733
|
+
if (!block) continue;
|
|
1734
|
+
if (isDeferred(block, registry)) {
|
|
1735
|
+
scheduleDeferredRender(block, baseContext, signal);
|
|
1736
|
+
} else {
|
|
1737
|
+
renderAndMountBlock(block, baseContext, signal).catch((err) => {
|
|
1738
|
+
if (!signal.aborted) {
|
|
1739
|
+
console.error(`Virtual scroll render error (${blockId}):`, err);
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
function scheduleDeferredRender(block, baseContext, signal) {
|
|
1747
|
+
const id = scheduleIdle(() => {
|
|
1748
|
+
if (signal.aborted) return;
|
|
1749
|
+
renderAndMountBlock(block, baseContext, signal).then(() => {
|
|
1750
|
+
if (!signal.aborted) onContentUpdate?.();
|
|
1751
|
+
}).catch((err) => {
|
|
1752
|
+
if (!signal.aborted) {
|
|
1753
|
+
console.error(`Virtual deferred render error (${block.blockId}):`, err);
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
pendingIdleIds.push(id);
|
|
1758
|
+
}
|
|
1759
|
+
function checkWidthChange() {
|
|
1760
|
+
const currentWidth = measurer.getWidth();
|
|
1761
|
+
if (currentWidth !== lastMeasurerWidth && lastMeasurerWidth > 0) {
|
|
1762
|
+
lastMeasurerWidth = currentWidth;
|
|
1763
|
+
heightCache.clear();
|
|
1764
|
+
layoutMap.invalidateAll((blockId) => {
|
|
1765
|
+
const block = blockMap.get(blockId);
|
|
1766
|
+
return block ? estimateBlockHeight(block) : FALLBACK_BLOCK_HEIGHT;
|
|
1767
|
+
});
|
|
1768
|
+
return true;
|
|
1769
|
+
}
|
|
1770
|
+
lastMeasurerWidth = currentWidth;
|
|
1771
|
+
return false;
|
|
1772
|
+
}
|
|
1773
|
+
return {
|
|
1774
|
+
mount(rootEl) {
|
|
1775
|
+
root = rootEl;
|
|
1776
|
+
viewport.mount(rootEl);
|
|
1777
|
+
measurer.init(rootEl);
|
|
1778
|
+
setupResizeObserver();
|
|
1779
|
+
scrollHandler = onScroll;
|
|
1780
|
+
rootEl.addEventListener("scroll", scrollHandler, { passive: true });
|
|
1781
|
+
lastMeasurerWidth = measurer.getWidth();
|
|
1782
|
+
mounted = true;
|
|
1783
|
+
},
|
|
1784
|
+
destroy() {
|
|
1785
|
+
pendingAbort?.abort();
|
|
1786
|
+
pendingAbort = null;
|
|
1787
|
+
pendingScrollAbort?.abort();
|
|
1788
|
+
pendingScrollAbort = null;
|
|
1789
|
+
cancelAllIdle(pendingIdleIds);
|
|
1790
|
+
if (scrollRafId) {
|
|
1791
|
+
cancelAnimationFrame(scrollRafId);
|
|
1792
|
+
scrollRafId = 0;
|
|
1793
|
+
}
|
|
1794
|
+
if (root && scrollHandler) {
|
|
1795
|
+
root.removeEventListener("scroll", scrollHandler);
|
|
1796
|
+
scrollHandler = null;
|
|
1797
|
+
}
|
|
1798
|
+
blockResizeObserver?.disconnect();
|
|
1799
|
+
blockResizeObserver = null;
|
|
1800
|
+
observedWrappers.clear();
|
|
1801
|
+
viewport.destroy();
|
|
1802
|
+
measurer.destroy();
|
|
1803
|
+
renderCache.clear();
|
|
1804
|
+
heightCache.clear();
|
|
1805
|
+
mounted = false;
|
|
1806
|
+
lastBlocks = [];
|
|
1807
|
+
blockMap.clear();
|
|
1808
|
+
renderedVersion = 0;
|
|
1809
|
+
root = null;
|
|
1810
|
+
},
|
|
1811
|
+
async update(state) {
|
|
1812
|
+
if (state.version <= renderedVersion) return;
|
|
1813
|
+
if (!mounted) return;
|
|
1814
|
+
pendingAbort?.abort();
|
|
1815
|
+
pendingScrollAbort?.abort();
|
|
1816
|
+
pendingScrollAbort = null;
|
|
1817
|
+
cancelAllIdle(pendingIdleIds);
|
|
1818
|
+
const abortController = new AbortController();
|
|
1819
|
+
pendingAbort = abortController;
|
|
1820
|
+
const signal = abortController.signal;
|
|
1821
|
+
const baseContext = makeContext(state.version, themeKey, signal);
|
|
1822
|
+
const blocks = state.previewBlocks;
|
|
1823
|
+
try {
|
|
1824
|
+
const cacheInvalid = isCacheInvalidation(state);
|
|
1825
|
+
if (cacheInvalid) {
|
|
1826
|
+
renderCache.clear();
|
|
1827
|
+
heightCache.clear();
|
|
1828
|
+
}
|
|
1829
|
+
const widthChanged = checkWidthChange();
|
|
1830
|
+
const isInitial = lastBlocks.length === 0 || state.dirtyReason === "full-rebuild" || cacheInvalid || widthChanged;
|
|
1831
|
+
const staleIds = new Set(blockMap.keys());
|
|
1832
|
+
for (const block of blocks) {
|
|
1833
|
+
blockMap.set(block.blockId, block);
|
|
1834
|
+
staleIds.delete(block.blockId);
|
|
1835
|
+
}
|
|
1836
|
+
for (const id of staleIds) blockMap.delete(id);
|
|
1837
|
+
if (isInitial) {
|
|
1838
|
+
await handleFullRebuild(blocks, state, baseContext, signal);
|
|
1839
|
+
} else {
|
|
1840
|
+
await handleIncrementalUpdate(blocks, state, baseContext, signal);
|
|
1841
|
+
}
|
|
1842
|
+
if (signal.aborted) return;
|
|
1843
|
+
lastBlocks = blocks;
|
|
1844
|
+
renderedVersion = state.version;
|
|
1845
|
+
onContentUpdate?.();
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
if (!signal.aborted) {
|
|
1848
|
+
console.error("Virtual preview engine render error:", err);
|
|
1849
|
+
}
|
|
1850
|
+
} finally {
|
|
1851
|
+
if (pendingAbort === abortController) {
|
|
1852
|
+
pendingAbort = null;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
},
|
|
1856
|
+
getRenderedVersion() {
|
|
1857
|
+
return renderedVersion;
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
async function handleFullRebuild(blocks, _state, baseContext, signal) {
|
|
1861
|
+
for (const blockId of observedWrappers) {
|
|
1862
|
+
const wrapper = viewport.getWrapper(blockId);
|
|
1863
|
+
if (wrapper && blockResizeObserver) {
|
|
1864
|
+
blockResizeObserver.unobserve(wrapper);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
observedWrappers.clear();
|
|
1868
|
+
const blockIds = blocks.map((b) => b.blockId);
|
|
1869
|
+
layoutMap.rebuild(blockIds, (blockId) => {
|
|
1870
|
+
const block = blockMap.get(blockId);
|
|
1871
|
+
return block ? getInitialHeight(block) : FALLBACK_BLOCK_HEIGHT;
|
|
1872
|
+
});
|
|
1873
|
+
const { mounted: mountedSet } = viewport.reconcile(blocks, layoutMap);
|
|
1874
|
+
if (signal.aborted) return;
|
|
1875
|
+
for (const blockId of mountedSet) {
|
|
1876
|
+
if (signal.aborted) return;
|
|
1877
|
+
const block = blockMap.get(blockId);
|
|
1878
|
+
if (!block) continue;
|
|
1879
|
+
if (isDeferred(block, registry)) {
|
|
1880
|
+
scheduleDeferredRender(block, baseContext, signal);
|
|
1881
|
+
} else {
|
|
1882
|
+
await renderAndMountBlock(block, baseContext, signal);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
async function handleIncrementalUpdate(blocks, state, baseContext, signal) {
|
|
1887
|
+
const dirtyBlockIds = collectDirtyBlockIds(state, lastBlocks);
|
|
1888
|
+
const blockIds = blocks.map((b) => b.blockId);
|
|
1889
|
+
layoutMap.rebuild(blockIds, (blockId) => {
|
|
1890
|
+
const block = blockMap.get(blockId);
|
|
1891
|
+
return block ? getInitialHeight(block) : FALLBACK_BLOCK_HEIGHT;
|
|
1892
|
+
});
|
|
1893
|
+
const { unmounted, newlyMounted } = viewport.reconcile(blocks, layoutMap);
|
|
1894
|
+
for (const blockId of unmounted) {
|
|
1895
|
+
unobserveWrapper(blockId);
|
|
1896
|
+
}
|
|
1897
|
+
if (signal.aborted) return;
|
|
1898
|
+
const mountedBlockIds = viewport.getMountedBlockIds();
|
|
1899
|
+
const toRender = /* @__PURE__ */ new Set();
|
|
1900
|
+
for (const blockId of mountedBlockIds) {
|
|
1901
|
+
if (newlyMounted.has(blockId) || dirtyBlockIds.has(blockId)) {
|
|
1902
|
+
toRender.add(blockId);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
for (const blockId of toRender) {
|
|
1906
|
+
if (signal.aborted) return;
|
|
1907
|
+
const block = blockMap.get(blockId);
|
|
1908
|
+
if (!block) continue;
|
|
1909
|
+
if (isDeferred(block, registry)) {
|
|
1910
|
+
scheduleDeferredRender(block, baseContext, signal);
|
|
1911
|
+
} else {
|
|
1912
|
+
await renderAndMountBlock(block, baseContext, signal);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// src/engine.ts
|
|
1919
|
+
function createOwoMarkPreviewEngine(options) {
|
|
1920
|
+
const strategy = options?.strategy ?? "virtual";
|
|
1921
|
+
switch (strategy) {
|
|
1922
|
+
case "incremental":
|
|
1923
|
+
return createIncrementalEngine(options);
|
|
1924
|
+
case "virtual":
|
|
1925
|
+
return createVirtualEngine(options);
|
|
1926
|
+
default:
|
|
1927
|
+
throw new Error(`Unknown preview strategy: ${strategy}`);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// src/dom/side-annotation-positioner.ts
|
|
1932
|
+
var SideAnnotationPositioner = class {
|
|
1933
|
+
container;
|
|
1934
|
+
resizeObserver = null;
|
|
1935
|
+
repositionScheduled = false;
|
|
1936
|
+
constructor(container) {
|
|
1937
|
+
this.container = container;
|
|
1938
|
+
this.setupResizeObserver();
|
|
1939
|
+
}
|
|
1940
|
+
setupResizeObserver() {
|
|
1941
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
1942
|
+
this.scheduleReposition();
|
|
1943
|
+
});
|
|
1944
|
+
this.resizeObserver.observe(this.container);
|
|
1945
|
+
}
|
|
1946
|
+
scheduleReposition() {
|
|
1947
|
+
if (this.repositionScheduled) return;
|
|
1948
|
+
this.repositionScheduled = true;
|
|
1949
|
+
requestAnimationFrame(() => {
|
|
1950
|
+
this.repositionScheduled = false;
|
|
1951
|
+
this.reposition();
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Scan all side annotations in the container, detect overlaps,
|
|
1956
|
+
* and apply offset positioning to avoid collisions.
|
|
1957
|
+
*/
|
|
1958
|
+
reposition() {
|
|
1959
|
+
const annotations = this.container.querySelectorAll(".side-annotation");
|
|
1960
|
+
if (annotations.length <= 1) return;
|
|
1961
|
+
const containerRect = this.container.getBoundingClientRect();
|
|
1962
|
+
const items = [];
|
|
1963
|
+
for (const annotation of annotations) {
|
|
1964
|
+
const aside = annotation.querySelector(".side-annotation-aside");
|
|
1965
|
+
if (!aside) continue;
|
|
1966
|
+
const asideRect = aside.getBoundingClientRect();
|
|
1967
|
+
const annotationRect = annotation.getBoundingClientRect();
|
|
1968
|
+
items.push({
|
|
1969
|
+
annotation,
|
|
1970
|
+
aside,
|
|
1971
|
+
// Original top position relative to container
|
|
1972
|
+
naturalTop: asideRect.top - containerRect.top,
|
|
1973
|
+
naturalBottom: asideRect.bottom - containerRect.top,
|
|
1974
|
+
height: asideRect.height,
|
|
1975
|
+
// Anchor block top (the main content area)
|
|
1976
|
+
anchorTop: annotationRect.top - containerRect.top,
|
|
1977
|
+
// Applied offset
|
|
1978
|
+
offset: 0
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
items.sort((a, b) => a.naturalTop - b.naturalTop);
|
|
1982
|
+
for (let i = 1; i < items.length; i++) {
|
|
1983
|
+
const prev = items[i - 1];
|
|
1984
|
+
const curr = items[i];
|
|
1985
|
+
const prevBottom = prev.naturalTop + prev.offset + prev.height;
|
|
1986
|
+
const currTop = curr.naturalTop + curr.offset;
|
|
1987
|
+
if (prevBottom > currTop) {
|
|
1988
|
+
const gap = 8;
|
|
1989
|
+
curr.offset = prevBottom - curr.naturalTop + gap;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
for (const item of items) {
|
|
1993
|
+
if (item.offset > 0) {
|
|
1994
|
+
item.aside.style.position = "relative";
|
|
1995
|
+
item.aside.style.top = `${item.offset}px`;
|
|
1996
|
+
if (item.offset > 20) {
|
|
1997
|
+
item.aside.classList.add("side-annotation-offset");
|
|
1998
|
+
item.aside.setAttribute("data-offset", String(Math.round(item.offset)));
|
|
1999
|
+
}
|
|
2000
|
+
} else {
|
|
2001
|
+
item.aside.style.position = "";
|
|
2002
|
+
item.aside.style.top = "";
|
|
2003
|
+
item.aside.classList.remove("side-annotation-offset");
|
|
2004
|
+
item.aside.removeAttribute("data-offset");
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
for (const item of items) {
|
|
2008
|
+
if (this.resizeObserver) {
|
|
2009
|
+
this.resizeObserver.observe(item.annotation);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
destroy() {
|
|
2014
|
+
if (this.resizeObserver) {
|
|
2015
|
+
this.resizeObserver.disconnect();
|
|
2016
|
+
this.resizeObserver = null;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
export {
|
|
2021
|
+
Callout,
|
|
2022
|
+
CodeDemo,
|
|
2023
|
+
DEFAULT_MDX_COMPONENTS,
|
|
2024
|
+
FileTree,
|
|
2025
|
+
Kbd,
|
|
2026
|
+
PreviewDomPatcher,
|
|
2027
|
+
SideAnnotationPositioner,
|
|
2028
|
+
Step,
|
|
2029
|
+
Steps,
|
|
2030
|
+
THEME_DARK_CLASS,
|
|
2031
|
+
THEME_LIGHT_CLASS,
|
|
2032
|
+
Tab,
|
|
2033
|
+
Tabs,
|
|
2034
|
+
createOwoMarkPreviewEngine,
|
|
2035
|
+
createOwoMarkProcessor,
|
|
2036
|
+
createOwoMarkVanillaEditor,
|
|
2037
|
+
createOwoMarkView,
|
|
2038
|
+
createRendererRegistry,
|
|
2039
|
+
createViewEngine,
|
|
2040
|
+
getOwoMarkPlugins,
|
|
2041
|
+
getThemeClassName,
|
|
2042
|
+
rehypeMathDisplayFix,
|
|
2043
|
+
rehypeSideAnnotation,
|
|
2044
|
+
remarkConvertSoftBreaksToHardBreaks,
|
|
2045
|
+
remarkSideAnnotation,
|
|
2046
|
+
renderBlockDefault
|
|
2047
|
+
};
|