@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/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"}