@shadow-garden/bapbong-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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/index.cjs +597 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +571 -0
- package/dist/lib/bapbong-view.d.ts +63 -0
- package/dist/lib/bapbong-view.d.ts.map +1 -0
- package/dist/lib/render-core.d.ts +147 -0
- package/dist/lib/render-core.d.ts.map +1 -0
- package/dist/lib/viewer-selection.d.ts +32 -0
- package/dist/lib/viewer-selection.d.ts.map +1 -0
- package/package.json +76 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// packages/view/src/lib/render-core.ts
|
|
2
|
+
import { schema as baseSchema } from "@shadow-garden/bapbong-model";
|
|
3
|
+
import { importDocx, exportDocx } from "@shadow-garden/bapbong-docx";
|
|
4
|
+
import { createLayoutCache, layout } from "@shadow-garden/bapbong-layout-engine";
|
|
5
|
+
import {
|
|
6
|
+
createCanvasMeasurer,
|
|
7
|
+
createCanvasMetrics,
|
|
8
|
+
ensureFontsLoaded
|
|
9
|
+
} from "@shadow-garden/bapbong-measuring";
|
|
10
|
+
import { CanvasPainter } from "@shadow-garden/bapbong-painter-canvas";
|
|
11
|
+
import { caretRect, hitTest, selectionRects, verticalCaret } from "@shadow-garden/bapbong-selection";
|
|
12
|
+
import { A11yMirror } from "@shadow-garden/bapbong-a11y";
|
|
13
|
+
var A4 = {
|
|
14
|
+
width: 794,
|
|
15
|
+
height: 1123,
|
|
16
|
+
margin: { top: 96, right: 96, bottom: 96, left: 96 }
|
|
17
|
+
};
|
|
18
|
+
var RenderCore = class {
|
|
19
|
+
stack;
|
|
20
|
+
viewport;
|
|
21
|
+
painter;
|
|
22
|
+
measureText;
|
|
23
|
+
measureMetrics;
|
|
24
|
+
resolved = null;
|
|
25
|
+
doc = null;
|
|
26
|
+
zoomFactor;
|
|
27
|
+
// Page chrome from the imported docx, keyed by w:type (default/first/even).
|
|
28
|
+
chromeHeaders = {};
|
|
29
|
+
chromeFooters = {};
|
|
30
|
+
chromeTitlePg = false;
|
|
31
|
+
chromeEvenAndOdd = false;
|
|
32
|
+
// Footnote bodies keyed by display number (laid out at the page bottom).
|
|
33
|
+
footnotes;
|
|
34
|
+
// The imported source package — passed to exportDocx({ carry }) so styles /
|
|
35
|
+
// numbering / headers / media survive a round-trip.
|
|
36
|
+
importedRaw = null;
|
|
37
|
+
// The document schema: model's base, or a caller-supplied extension.
|
|
38
|
+
docSchema = baseSchema;
|
|
39
|
+
// Page geometry from the imported docx's sectPr (A4 until imported).
|
|
40
|
+
page = A4;
|
|
41
|
+
// Incremental re-layout: unchanged paragraphs skip measuring on each change.
|
|
42
|
+
// Replaced wholesale when late-loading fonts invalidate every measurement.
|
|
43
|
+
layoutCache = createLayoutCache();
|
|
44
|
+
// Last painted overlay — reused on scroll/zoom/font-relayout repaints.
|
|
45
|
+
lastOverlay = { caret: null, selection: [] };
|
|
46
|
+
// Doc-range decorations to paint (comment tint, find highlight…). The editor
|
|
47
|
+
// sets this; the core resolves the ranges to page rects at paint time.
|
|
48
|
+
decorationProvider = null;
|
|
49
|
+
// Scroll repaint throttle (page virtualization).
|
|
50
|
+
scrollRaf = null;
|
|
51
|
+
// Late-font re-layout coalescing: families the current doc actually uses
|
|
52
|
+
// (set at load) + a debounce timer so a burst of loadingdone events (fonts
|
|
53
|
+
// land face-by-face) costs ONE re-layout, not one per event.
|
|
54
|
+
docFamilies = /* @__PURE__ */ new Set();
|
|
55
|
+
fontRelayoutTimer = null;
|
|
56
|
+
// Subscribers notified after a late-font relayout (rects moved).
|
|
57
|
+
fontsListeners = /* @__PURE__ */ new Set();
|
|
58
|
+
// Visually-hidden ARIA mirror of the doc (screen readers; canvas is opaque).
|
|
59
|
+
a11y;
|
|
60
|
+
constructor(stack, opts = {}) {
|
|
61
|
+
this.stack = stack;
|
|
62
|
+
this.viewport = opts.viewport ?? stack.closest(".canvas-wrap");
|
|
63
|
+
this.painter = new CanvasPainter(stack);
|
|
64
|
+
this.measureText = opts.measureText ?? createCanvasMeasurer();
|
|
65
|
+
this.measureMetrics = opts.measureMetrics ?? createCanvasMetrics();
|
|
66
|
+
this.zoomFactor = opts.zoom ?? 1;
|
|
67
|
+
this.a11y = opts.a11y === false ? null : new A11yMirror(stack, { label: opts.a11yLabel });
|
|
68
|
+
this.viewport?.addEventListener("scroll", this.onScroll);
|
|
69
|
+
document.fonts?.addEventListener?.("loadingdone", this.onFontsLoaded);
|
|
70
|
+
}
|
|
71
|
+
// ── State accessors ─────────────────────────────────────────────────
|
|
72
|
+
/** The current read-only document, or null before the first load. */
|
|
73
|
+
get document() {
|
|
74
|
+
return this.doc;
|
|
75
|
+
}
|
|
76
|
+
/** The resolved layout for the current doc, or null. */
|
|
77
|
+
get layout() {
|
|
78
|
+
return this.resolved;
|
|
79
|
+
}
|
|
80
|
+
/** Number of laid-out pages (0 before the first document). */
|
|
81
|
+
get pageCount() {
|
|
82
|
+
return this.resolved?.pages.length ?? 0;
|
|
83
|
+
}
|
|
84
|
+
/** The document schema in use (model's base, or a caller-supplied extension). */
|
|
85
|
+
get schema() {
|
|
86
|
+
return this.docSchema;
|
|
87
|
+
}
|
|
88
|
+
/** Resolve doc-range decorations to page rects on each content paint. */
|
|
89
|
+
setDecorationProvider(fn) {
|
|
90
|
+
this.decorationProvider = fn;
|
|
91
|
+
}
|
|
92
|
+
// ── Load / export ───────────────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* Import a `.docx`, lay it out, and (by default) paint the first frame. Pass
|
|
95
|
+
* `{ schema }` to import against an extended schema (e.g. plugin marks);
|
|
96
|
+
* `{ paint: false }` to skip the initial paint (the editor paints with a caret
|
|
97
|
+
* overlay itself). Resolves with the doc + imported page-chrome keys.
|
|
98
|
+
*/
|
|
99
|
+
async loadDocx(bytes, opts = {}) {
|
|
100
|
+
const { doc, headers, footers, footnotes, titlePg, evenAndOdd, page, raw } = await importDocx(
|
|
101
|
+
bytes,
|
|
102
|
+
opts.schema ? { schema: opts.schema } : void 0
|
|
103
|
+
);
|
|
104
|
+
this.docSchema = opts.schema ?? baseSchema;
|
|
105
|
+
this.importedRaw = raw;
|
|
106
|
+
this.chromeHeaders = headers;
|
|
107
|
+
this.chromeFooters = footers;
|
|
108
|
+
this.chromeTitlePg = titlePg;
|
|
109
|
+
this.chromeEvenAndOdd = evenAndOdd;
|
|
110
|
+
this.footnotes = footnotes;
|
|
111
|
+
this.page = page;
|
|
112
|
+
const families = collectFontFamilies(doc, ...Object.values(headers), ...Object.values(footers));
|
|
113
|
+
this.docFamilies = new Set(families.map(normalizeFamily));
|
|
114
|
+
await ensureFontsLoaded(families);
|
|
115
|
+
this.layoutDoc(doc);
|
|
116
|
+
if (opts.paint !== false) this.paintContent();
|
|
117
|
+
return { doc, headerKeys: Object.keys(headers), footerKeys: Object.keys(footers) };
|
|
118
|
+
}
|
|
119
|
+
/** Export the current document to .docx bytes, carrying the imported source
|
|
120
|
+
* package so unmodelled parts survive the round-trip. */
|
|
121
|
+
async exportDocx() {
|
|
122
|
+
if (!this.doc) throw new Error("RenderCore: no document loaded");
|
|
123
|
+
return exportDocx(this.doc, this.importedRaw ? { carry: this.importedRaw } : void 0);
|
|
124
|
+
}
|
|
125
|
+
// ── Layout / paint ──────────────────────────────────────────────────
|
|
126
|
+
/** Lay out (or re-lay, incrementally) `doc` and store it. Does not paint. */
|
|
127
|
+
layoutDoc(doc) {
|
|
128
|
+
this.doc = doc;
|
|
129
|
+
this.a11y?.update(doc);
|
|
130
|
+
this.resolved = layout(
|
|
131
|
+
doc,
|
|
132
|
+
{ page: this.page, measureText: this.measureText, measureMetrics: this.measureMetrics },
|
|
133
|
+
this.layoutCache,
|
|
134
|
+
{
|
|
135
|
+
header: this.chromeHeaders["default"],
|
|
136
|
+
footer: this.chromeFooters["default"],
|
|
137
|
+
headerFirst: this.chromeHeaders["first"],
|
|
138
|
+
footerFirst: this.chromeFooters["first"],
|
|
139
|
+
headerEven: this.chromeHeaders["even"],
|
|
140
|
+
footerEven: this.chromeFooters["even"],
|
|
141
|
+
titlePg: this.chromeTitlePg,
|
|
142
|
+
evenAndOdd: this.chromeEvenAndOdd
|
|
143
|
+
},
|
|
144
|
+
this.footnotes
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
/** Full content repaint, virtualized to the scroll viewport. Pass an overlay
|
|
148
|
+
* to update the caret/selection painted on top (kept for later repaints). */
|
|
149
|
+
paintContent(overlay) {
|
|
150
|
+
if (!this.resolved) return;
|
|
151
|
+
if (overlay) this.lastOverlay = normalizeOverlay(overlay);
|
|
152
|
+
this.painter.paint(this.resolved, {
|
|
153
|
+
zoom: this.zoomFactor,
|
|
154
|
+
caret: this.lastOverlay.caret,
|
|
155
|
+
selection: this.lastOverlay.selection,
|
|
156
|
+
viewport: this.currentViewport(),
|
|
157
|
+
decorations: this.collectDecorations()
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** Redraw only the caret/selection overlay (just the affected page canvases —
|
|
161
|
+
* used for caret blink, drag preview, selection-only changes). */
|
|
162
|
+
paintOverlay(overlay) {
|
|
163
|
+
if (!this.resolved) return;
|
|
164
|
+
this.lastOverlay = normalizeOverlay(overlay);
|
|
165
|
+
this.painter.paintOverlay({ caret: this.lastOverlay.caret, selection: this.lastOverlay.selection });
|
|
166
|
+
}
|
|
167
|
+
/** Set the zoom factor (1 = 100%) and repaint content at the new scale. */
|
|
168
|
+
setZoom(zoom) {
|
|
169
|
+
this.zoomFactor = zoom;
|
|
170
|
+
this.paintContent();
|
|
171
|
+
}
|
|
172
|
+
/** The current zoom factor. */
|
|
173
|
+
getZoom() {
|
|
174
|
+
return this.zoomFactor;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Print the whole document. The live canvas is virtualized (only visible
|
|
178
|
+
* pages exist), so this renders **every** page with a throwaway painter,
|
|
179
|
+
* snapshots each to a PNG, and prints one image per sheet via a hidden iframe
|
|
180
|
+
* (clean pagination, the live view untouched).
|
|
181
|
+
*/
|
|
182
|
+
async print() {
|
|
183
|
+
const layout2 = this.resolved;
|
|
184
|
+
if (!layout2) return;
|
|
185
|
+
const holder = document.createElement("div");
|
|
186
|
+
holder.style.cssText = "position:absolute;left:-99999px;top:0;pointer-events:none;";
|
|
187
|
+
document.body.appendChild(holder);
|
|
188
|
+
try {
|
|
189
|
+
new CanvasPainter(holder).paint(layout2, { zoom: 1 });
|
|
190
|
+
const canvases = Array.from(holder.querySelectorAll("canvas")).sort(
|
|
191
|
+
(a, b) => parseFloat(a.style.top || "0") - parseFloat(b.style.top || "0")
|
|
192
|
+
);
|
|
193
|
+
const sources = canvases.map((c) => c.toDataURL("image/png"));
|
|
194
|
+
await printImages(sources);
|
|
195
|
+
} finally {
|
|
196
|
+
holder.remove();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Gather decorations from the provider and resolve each doc range to
|
|
200
|
+
* page-local rects the painter can fill directly. */
|
|
201
|
+
collectDecorations() {
|
|
202
|
+
if (!this.resolved || !this.decorationProvider) return [];
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const d of this.decorationProvider()) {
|
|
205
|
+
const rects = selectionRects(this.resolved, d.from, d.to, this.measureText);
|
|
206
|
+
if (rects.length) out.push({ rects, kind: d.kind, color: d.color });
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
// ── Geometry queries ────────────────────────────────────────────────
|
|
211
|
+
/** Caret geometry at a doc position (page-local), or null. */
|
|
212
|
+
caretRect(pos) {
|
|
213
|
+
return this.resolved ? caretRect(this.resolved, pos, this.measureText) : null;
|
|
214
|
+
}
|
|
215
|
+
/** Selection highlight rects for a doc range (page-local). */
|
|
216
|
+
selectionRects(from, to) {
|
|
217
|
+
return this.resolved ? selectionRects(this.resolved, from, to, this.measureText) : [];
|
|
218
|
+
}
|
|
219
|
+
/** The doc position one line above/below `head` at horizontal `x`, or null. */
|
|
220
|
+
verticalCaret(head, dir, x) {
|
|
221
|
+
return this.resolved ? verticalCaret(this.resolved, head, dir, x, this.measureText) : null;
|
|
222
|
+
}
|
|
223
|
+
/** Map client (viewport) coords to a page-local point, or null. */
|
|
224
|
+
clientToPage(clientX, clientY) {
|
|
225
|
+
const rect = this.stack.getBoundingClientRect();
|
|
226
|
+
return this.painter.canvasToPage(clientX - rect.left, clientY - rect.top);
|
|
227
|
+
}
|
|
228
|
+
/** The doc position under a page-local point, or null. */
|
|
229
|
+
posAtPoint(point) {
|
|
230
|
+
return this.resolved ? hitTest(this.resolved, point, this.measureText) : null;
|
|
231
|
+
}
|
|
232
|
+
/** The doc position under a pointer/mouse event, or null. */
|
|
233
|
+
posAtEvent(ev) {
|
|
234
|
+
const pt = this.clientToPage(ev.clientX, ev.clientY);
|
|
235
|
+
return pt ? this.posAtPoint(pt) : null;
|
|
236
|
+
}
|
|
237
|
+
/** Map a page-local point to container (canvas-stack) coordinates, or null. */
|
|
238
|
+
pageToCanvas(p) {
|
|
239
|
+
return this.painter.pageToCanvas(p);
|
|
240
|
+
}
|
|
241
|
+
/** Scroll the viewport so the caret at `pos` sits `topMargin` px from the top. */
|
|
242
|
+
scrollToPos(pos, topMargin = 80) {
|
|
243
|
+
const cr = this.caretRect(pos);
|
|
244
|
+
const pt = cr && this.painter.pageToCanvas({ pageIndex: cr.pageIndex, x: cr.x, y: cr.y });
|
|
245
|
+
if (pt && this.viewport) this.viewport.scrollTop = Math.max(0, pt.y - topMargin);
|
|
246
|
+
}
|
|
247
|
+
/** The viewport's window onto the page stack, in container CSS px. */
|
|
248
|
+
currentViewport() {
|
|
249
|
+
const wrap = this.viewport;
|
|
250
|
+
if (!wrap) return void 0;
|
|
251
|
+
const wrapRect = wrap.getBoundingClientRect();
|
|
252
|
+
const stackRect = this.stack.getBoundingClientRect();
|
|
253
|
+
return { top: wrapRect.top - stackRect.top, height: wrap.clientHeight };
|
|
254
|
+
}
|
|
255
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
256
|
+
/** Subscribe to late-font relayouts (page geometry changed → recompute any
|
|
257
|
+
* caret/selection rects you hold). Returns an unsubscribe fn. */
|
|
258
|
+
onFontsReloaded(cb) {
|
|
259
|
+
this.fontsListeners.add(cb);
|
|
260
|
+
return () => this.fontsListeners.delete(cb);
|
|
261
|
+
}
|
|
262
|
+
destroy() {
|
|
263
|
+
if (this.scrollRaf != null) cancelAnimationFrame(this.scrollRaf);
|
|
264
|
+
this.scrollRaf = null;
|
|
265
|
+
if (this.fontRelayoutTimer != null) clearTimeout(this.fontRelayoutTimer);
|
|
266
|
+
this.fontRelayoutTimer = null;
|
|
267
|
+
this.viewport?.removeEventListener("scroll", this.onScroll);
|
|
268
|
+
document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
|
|
269
|
+
this.fontsListeners.clear();
|
|
270
|
+
this.a11y?.destroy();
|
|
271
|
+
}
|
|
272
|
+
/** Repaint newly visible pages while scrolling (rAF-throttled). */
|
|
273
|
+
onScroll = () => {
|
|
274
|
+
if (this.scrollRaf != null) return;
|
|
275
|
+
this.scrollRaf = requestAnimationFrame(() => {
|
|
276
|
+
this.scrollRaf = null;
|
|
277
|
+
this.paintContent();
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
onFontsLoaded = (ev) => {
|
|
281
|
+
if (!this.doc) return;
|
|
282
|
+
const faces = ev.fontfaces ?? [];
|
|
283
|
+
if (faces.length > 0 && !faces.some((f) => this.docFamilies.has(normalizeFamily(f.family)))) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (this.fontRelayoutTimer != null) clearTimeout(this.fontRelayoutTimer);
|
|
287
|
+
this.fontRelayoutTimer = setTimeout(() => {
|
|
288
|
+
this.fontRelayoutTimer = null;
|
|
289
|
+
if (!this.doc) return;
|
|
290
|
+
this.layoutCache = createLayoutCache();
|
|
291
|
+
this.layoutDoc(this.doc);
|
|
292
|
+
this.paintContent();
|
|
293
|
+
for (const cb of this.fontsListeners) cb();
|
|
294
|
+
}, 150);
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
function normalizeOverlay(overlay) {
|
|
298
|
+
return { caret: overlay.caret ?? null, selection: overlay.selection ?? [] };
|
|
299
|
+
}
|
|
300
|
+
function printImages(sources) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
if (sources.length === 0) {
|
|
303
|
+
resolve();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const iframe = document.createElement("iframe");
|
|
307
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
308
|
+
iframe.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;";
|
|
309
|
+
document.body.appendChild(iframe);
|
|
310
|
+
const cw = iframe.contentWindow;
|
|
311
|
+
const doc = iframe.contentDocument;
|
|
312
|
+
if (!cw || !doc) {
|
|
313
|
+
iframe.remove();
|
|
314
|
+
resolve();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const body = sources.map((s) => `<img src="${s}" alt="" />`).join("");
|
|
318
|
+
doc.open();
|
|
319
|
+
doc.write(
|
|
320
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>@page{margin:0}html,body{margin:0;padding:0}img{display:block;width:100%;break-after:page;page-break-after:always}img:last-child{break-after:auto;page-break-after:auto}</style></head><body>${body}</body></html>`
|
|
321
|
+
);
|
|
322
|
+
doc.close();
|
|
323
|
+
let settled = false;
|
|
324
|
+
const finish = () => {
|
|
325
|
+
if (settled) return;
|
|
326
|
+
settled = true;
|
|
327
|
+
cw.focus();
|
|
328
|
+
cw.print();
|
|
329
|
+
setTimeout(() => iframe.remove(), 1e3);
|
|
330
|
+
resolve();
|
|
331
|
+
};
|
|
332
|
+
const pending = Array.from(doc.images).filter((im) => !im.complete);
|
|
333
|
+
if (pending.length === 0) {
|
|
334
|
+
finish();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
let left = pending.length;
|
|
338
|
+
const onOne = () => {
|
|
339
|
+
if (--left === 0) finish();
|
|
340
|
+
};
|
|
341
|
+
for (const im of pending) {
|
|
342
|
+
im.addEventListener("load", onOne);
|
|
343
|
+
im.addEventListener("error", onOne);
|
|
344
|
+
}
|
|
345
|
+
setTimeout(finish, 4e3);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function normalizeFamily(family) {
|
|
349
|
+
return family.replace(/^["']|["']$/g, "").trim().toLowerCase();
|
|
350
|
+
}
|
|
351
|
+
function collectFontFamilies(...docs) {
|
|
352
|
+
const families = /* @__PURE__ */ new Set(["Arial"]);
|
|
353
|
+
for (const doc of docs) {
|
|
354
|
+
doc?.descendants((node) => {
|
|
355
|
+
for (const mark of node.marks) {
|
|
356
|
+
if (mark.type.name === "fontFamily" && mark.attrs["family"]) {
|
|
357
|
+
families.add(String(mark.attrs["family"]));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return [...families];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// packages/view/src/lib/viewer-selection.ts
|
|
366
|
+
var ViewerSelection = class {
|
|
367
|
+
constructor(core) {
|
|
368
|
+
this.core = core;
|
|
369
|
+
const s = core.stack;
|
|
370
|
+
s.style.cursor = "text";
|
|
371
|
+
if (s.tabIndex < 0) s.tabIndex = 0;
|
|
372
|
+
s.style.outline = "none";
|
|
373
|
+
s.addEventListener("pointerdown", this.onDown);
|
|
374
|
+
s.addEventListener("pointermove", this.onMove);
|
|
375
|
+
s.addEventListener("pointerup", this.onUp);
|
|
376
|
+
s.addEventListener("pointercancel", this.onUp);
|
|
377
|
+
s.addEventListener("dblclick", this.onDblClick);
|
|
378
|
+
s.addEventListener("keydown", this.onKey);
|
|
379
|
+
}
|
|
380
|
+
core;
|
|
381
|
+
anchor = null;
|
|
382
|
+
head = null;
|
|
383
|
+
dragging = false;
|
|
384
|
+
/** Drop any current selection (e.g. when a new document loads). */
|
|
385
|
+
clear() {
|
|
386
|
+
this.anchor = this.head = null;
|
|
387
|
+
this.dragging = false;
|
|
388
|
+
this.core.paintOverlay({ selection: [] });
|
|
389
|
+
}
|
|
390
|
+
destroy() {
|
|
391
|
+
const s = this.core.stack;
|
|
392
|
+
s.removeEventListener("pointerdown", this.onDown);
|
|
393
|
+
s.removeEventListener("pointermove", this.onMove);
|
|
394
|
+
s.removeEventListener("pointerup", this.onUp);
|
|
395
|
+
s.removeEventListener("pointercancel", this.onUp);
|
|
396
|
+
s.removeEventListener("dblclick", this.onDblClick);
|
|
397
|
+
s.removeEventListener("keydown", this.onKey);
|
|
398
|
+
}
|
|
399
|
+
// ── Pointer ─────────────────────────────────────────────────────────
|
|
400
|
+
onDown = (ev) => {
|
|
401
|
+
if (ev.button !== 0) return;
|
|
402
|
+
const pos = this.core.posAtEvent(ev);
|
|
403
|
+
if (pos == null) return;
|
|
404
|
+
ev.preventDefault();
|
|
405
|
+
this.core.stack.focus?.();
|
|
406
|
+
this.anchor = this.head = pos;
|
|
407
|
+
this.dragging = true;
|
|
408
|
+
this.paint();
|
|
409
|
+
try {
|
|
410
|
+
ev.target.setPointerCapture?.(ev.pointerId);
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
onMove = (ev) => {
|
|
415
|
+
if (!this.dragging || this.anchor == null || !(ev.buttons & 1)) return;
|
|
416
|
+
const coalesced = ev.getCoalescedEvents?.() ?? [];
|
|
417
|
+
const last = coalesced.length > 0 ? coalesced[coalesced.length - 1] : ev;
|
|
418
|
+
const pos = this.core.posAtEvent(last);
|
|
419
|
+
if (pos == null || pos === this.head) return;
|
|
420
|
+
this.head = pos;
|
|
421
|
+
this.paint();
|
|
422
|
+
};
|
|
423
|
+
onUp = () => {
|
|
424
|
+
this.dragging = false;
|
|
425
|
+
};
|
|
426
|
+
/** Double-click selects the word under the pointer. */
|
|
427
|
+
onDblClick = (ev) => {
|
|
428
|
+
const pos = this.core.posAtEvent(ev);
|
|
429
|
+
const doc = this.core.document;
|
|
430
|
+
if (pos == null || !doc) return;
|
|
431
|
+
const word = wordRangeAt(doc, pos);
|
|
432
|
+
if (!word) return;
|
|
433
|
+
ev.preventDefault();
|
|
434
|
+
this.anchor = word.from;
|
|
435
|
+
this.head = word.to;
|
|
436
|
+
this.paint();
|
|
437
|
+
};
|
|
438
|
+
// ── Keyboard ────────────────────────────────────────────────────────
|
|
439
|
+
onKey = (ev) => {
|
|
440
|
+
if (!(ev.metaKey || ev.ctrlKey)) return;
|
|
441
|
+
const key = ev.key.toLowerCase();
|
|
442
|
+
if (key === "c") {
|
|
443
|
+
const text = this.selectedText();
|
|
444
|
+
if (text) {
|
|
445
|
+
ev.preventDefault();
|
|
446
|
+
navigator.clipboard?.writeText(text).catch(() => {
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
} else if (key === "a") {
|
|
450
|
+
const doc = this.core.document;
|
|
451
|
+
if (!doc) return;
|
|
452
|
+
ev.preventDefault();
|
|
453
|
+
this.anchor = 0;
|
|
454
|
+
this.head = doc.content.size;
|
|
455
|
+
this.paint();
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
459
|
+
range() {
|
|
460
|
+
if (this.anchor == null || this.head == null || this.anchor === this.head) return null;
|
|
461
|
+
return { from: Math.min(this.anchor, this.head), to: Math.max(this.anchor, this.head) };
|
|
462
|
+
}
|
|
463
|
+
paint() {
|
|
464
|
+
const r = this.range();
|
|
465
|
+
this.core.paintOverlay({ selection: r ? this.core.selectionRects(r.from, r.to) : [] });
|
|
466
|
+
}
|
|
467
|
+
selectedText() {
|
|
468
|
+
const r = this.range();
|
|
469
|
+
const doc = this.core.document;
|
|
470
|
+
if (!r || !doc) return null;
|
|
471
|
+
return doc.textBetween(r.from, r.to, "\n", "\n");
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
function wordRangeAt(doc, pos) {
|
|
475
|
+
const $pos = doc.resolve(pos);
|
|
476
|
+
const text = $pos.parent.textContent;
|
|
477
|
+
if (!text) return null;
|
|
478
|
+
const start = $pos.start();
|
|
479
|
+
let off = pos - start;
|
|
480
|
+
off = Math.max(0, Math.min(off, text.length));
|
|
481
|
+
const isWord = (c) => c != null && !/\s/.test(c);
|
|
482
|
+
if (!isWord(text[off]) && isWord(text[off - 1])) off -= 1;
|
|
483
|
+
if (!isWord(text[off])) return null;
|
|
484
|
+
let from = off;
|
|
485
|
+
let to = off;
|
|
486
|
+
while (from > 0 && isWord(text[from - 1])) from--;
|
|
487
|
+
while (to < text.length && isWord(text[to])) to++;
|
|
488
|
+
return { from: start + from, to: start + to };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// packages/view/src/lib/bapbong-view.ts
|
|
492
|
+
var BapbongView = class {
|
|
493
|
+
stack;
|
|
494
|
+
core;
|
|
495
|
+
selection;
|
|
496
|
+
changeListeners = /* @__PURE__ */ new Set();
|
|
497
|
+
offFonts;
|
|
498
|
+
constructor(host, opts = {}) {
|
|
499
|
+
const stack = document.createElement("div");
|
|
500
|
+
stack.style.position = "relative";
|
|
501
|
+
host.appendChild(stack);
|
|
502
|
+
this.stack = stack;
|
|
503
|
+
this.core = new RenderCore(stack, {
|
|
504
|
+
viewport: opts.viewport ?? host,
|
|
505
|
+
zoom: opts.zoom,
|
|
506
|
+
a11y: opts.a11y,
|
|
507
|
+
a11yLabel: opts.a11yLabel
|
|
508
|
+
});
|
|
509
|
+
this.offFonts = this.core.onFontsReloaded(() => this.emit());
|
|
510
|
+
this.selection = opts.selectable === false ? null : new ViewerSelection(this.core);
|
|
511
|
+
}
|
|
512
|
+
/** Import a `.docx` and render the first frame. Resolves with the imported
|
|
513
|
+
* page-chrome keys (header/footer w:types present). */
|
|
514
|
+
async loadDocx(bytes) {
|
|
515
|
+
this.selection?.clear();
|
|
516
|
+
const { headerKeys, footerKeys } = await this.core.loadDocx(bytes);
|
|
517
|
+
this.emit();
|
|
518
|
+
return { headerKeys, footerKeys };
|
|
519
|
+
}
|
|
520
|
+
/** Export the current document back to .docx bytes (carries the source
|
|
521
|
+
* package so unmodelled parts survive the round-trip). */
|
|
522
|
+
exportDocx() {
|
|
523
|
+
return this.core.exportDocx();
|
|
524
|
+
}
|
|
525
|
+
/** Scroll so the caret at doc position `pos` sits `topMargin` px from the top. */
|
|
526
|
+
scrollToPos(pos, topMargin) {
|
|
527
|
+
this.core.scrollToPos(pos, topMargin);
|
|
528
|
+
}
|
|
529
|
+
/** Set the zoom factor (1 = 100%) and repaint at the new scale. */
|
|
530
|
+
setZoom(zoom) {
|
|
531
|
+
this.core.setZoom(zoom);
|
|
532
|
+
}
|
|
533
|
+
/** The current zoom factor. */
|
|
534
|
+
getZoom() {
|
|
535
|
+
return this.core.getZoom();
|
|
536
|
+
}
|
|
537
|
+
/** Print the whole document (renders every page, one per sheet). */
|
|
538
|
+
print() {
|
|
539
|
+
return this.core.print();
|
|
540
|
+
}
|
|
541
|
+
/** Number of laid-out pages (0 before the first document). */
|
|
542
|
+
get pageCount() {
|
|
543
|
+
return this.core.pageCount;
|
|
544
|
+
}
|
|
545
|
+
/** The document schema in use. */
|
|
546
|
+
get schema() {
|
|
547
|
+
return this.core.schema;
|
|
548
|
+
}
|
|
549
|
+
/** Subscribe to render cycles (document loaded, page count changed). Returns
|
|
550
|
+
* an unsubscribe fn. */
|
|
551
|
+
onChange(cb) {
|
|
552
|
+
this.changeListeners.add(cb);
|
|
553
|
+
return () => this.changeListeners.delete(cb);
|
|
554
|
+
}
|
|
555
|
+
destroy() {
|
|
556
|
+
this.offFonts();
|
|
557
|
+
this.selection?.destroy();
|
|
558
|
+
this.core.destroy();
|
|
559
|
+
this.stack.remove();
|
|
560
|
+
}
|
|
561
|
+
emit() {
|
|
562
|
+
const change = { pageCount: this.core.pageCount };
|
|
563
|
+
for (const cb of this.changeListeners) cb(change);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
export {
|
|
567
|
+
BapbongView,
|
|
568
|
+
RenderCore,
|
|
569
|
+
ViewerSelection,
|
|
570
|
+
collectFontFamilies
|
|
571
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Schema } from 'prosemirror-model';
|
|
2
|
+
import { type RenderCoreOptions } from './render-core.js';
|
|
3
|
+
export interface BapbongViewOptions extends RenderCoreOptions {
|
|
4
|
+
/** The scroll viewport for virtualization / scroll-into-view. Defaults to the
|
|
5
|
+
* host element itself (give it `overflow:auto` + a bounded height). */
|
|
6
|
+
viewport?: HTMLElement;
|
|
7
|
+
/** Allow read-only text selection + copy by dragging over the canvas
|
|
8
|
+
* (double-click selects a word, Ctrl/⌘+A selects all, Ctrl/⌘+C copies).
|
|
9
|
+
* Default true; pass false for a non-interactive preview. */
|
|
10
|
+
selectable?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Emitted after the document (re)renders — page count changed, fonts settled. */
|
|
13
|
+
export interface ViewChange {
|
|
14
|
+
pageCount: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A **read-only** bapbong document viewer (preview). Give it a scrollable host
|
|
18
|
+
* element; it renders the imported `.docx` to a virtualized canvas page-stack
|
|
19
|
+
* with zoom — no editing, no input-bridge, so the preview bundle never pulls in
|
|
20
|
+
* the ProseMirror editing surface.
|
|
21
|
+
*
|
|
22
|
+
* const view = new BapbongView(hostEl); // host: overflow:auto, fixed height
|
|
23
|
+
* await view.loadDocx(bytes);
|
|
24
|
+
* view.setZoom(1.25);
|
|
25
|
+
*
|
|
26
|
+
* For an editable instance use `@shadow-garden/bapbong-editor` (it composes the
|
|
27
|
+
* same {@link RenderCore}). For headless/server use `@shadow-garden/bapbong-headless`.
|
|
28
|
+
*/
|
|
29
|
+
export declare class BapbongView {
|
|
30
|
+
private readonly stack;
|
|
31
|
+
private readonly core;
|
|
32
|
+
private readonly selection;
|
|
33
|
+
private readonly changeListeners;
|
|
34
|
+
private readonly offFonts;
|
|
35
|
+
constructor(host: HTMLElement, opts?: BapbongViewOptions);
|
|
36
|
+
/** Import a `.docx` and render the first frame. Resolves with the imported
|
|
37
|
+
* page-chrome keys (header/footer w:types present). */
|
|
38
|
+
loadDocx(bytes: ArrayBuffer): Promise<{
|
|
39
|
+
headerKeys: string[];
|
|
40
|
+
footerKeys: string[];
|
|
41
|
+
}>;
|
|
42
|
+
/** Export the current document back to .docx bytes (carries the source
|
|
43
|
+
* package so unmodelled parts survive the round-trip). */
|
|
44
|
+
exportDocx(): Promise<Uint8Array>;
|
|
45
|
+
/** Scroll so the caret at doc position `pos` sits `topMargin` px from the top. */
|
|
46
|
+
scrollToPos(pos: number, topMargin?: number): void;
|
|
47
|
+
/** Set the zoom factor (1 = 100%) and repaint at the new scale. */
|
|
48
|
+
setZoom(zoom: number): void;
|
|
49
|
+
/** The current zoom factor. */
|
|
50
|
+
getZoom(): number;
|
|
51
|
+
/** Print the whole document (renders every page, one per sheet). */
|
|
52
|
+
print(): Promise<void>;
|
|
53
|
+
/** Number of laid-out pages (0 before the first document). */
|
|
54
|
+
get pageCount(): number;
|
|
55
|
+
/** The document schema in use. */
|
|
56
|
+
get schema(): Schema;
|
|
57
|
+
/** Subscribe to render cycles (document loaded, page count changed). Returns
|
|
58
|
+
* an unsubscribe fn. */
|
|
59
|
+
onChange(cb: (c: ViewChange) => void): () => void;
|
|
60
|
+
destroy(): void;
|
|
61
|
+
private emit;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=bapbong-view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bapbong-view.d.ts","sourceRoot":"","sources":["../../src/lib/bapbong-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAc,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAGtE,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D;4EACwE;IACxE,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB;;kEAE8D;IAC9D,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,kFAAkF;AAClF,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAsC;IACtE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAa;gBAE1B,IAAI,EAAE,WAAW,EAAE,IAAI,GAAE,kBAAuB;IAiB5D;4DACwD;IAClD,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAO3F;+DAC2D;IAC3D,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC;IAIjC,kFAAkF;IAClF,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;IAIlD,mEAAmE;IACnE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAI3B,+BAA+B;IAC/B,OAAO,IAAI,MAAM;IAIjB,oEAAoE;IACpE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAItB,8DAA8D;IAC9D,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,kCAAkC;IAClC,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED;6BACyB;IACzB,QAAQ,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI;IAKjD,OAAO,IAAI,IAAI;IAOf,OAAO,CAAC,IAAI;CAIb"}
|