@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Le Phuoc Minh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @shadow-garden/bapbong-view
2
+
3
+ The **render core + read-only viewer** for bapbong. It loads a `.docx`, lays it
4
+ out, and paints it to a virtualized `<canvas>` page-stack — with zoom, text
5
+ selection + copy, and an accessibility mirror — but **no editing** and **no
6
+ input-bridge**, so the preview bundle never pulls in the ProseMirror editing
7
+ surface.
8
+
9
+ This is the **preview tier** of the 3-tier architecture: headless
10
+ (`@shadow-garden/bapbong-headless`) / preview (this) / full editor
11
+ (`@shadow-garden/bapbong-editor`). The editor composes the same `RenderCore`
12
+ exported here, so layout/paint live in exactly one place.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pnpm add @shadow-garden/bapbong-view
18
+ ```
19
+
20
+ ## `BapbongView` — read-only viewer
21
+
22
+ Give it a scrollable host element (`overflow: auto` + a bounded height); it
23
+ renders the document inside.
24
+
25
+ ```ts
26
+ import { BapbongView } from '@shadow-garden/bapbong-view';
27
+
28
+ const view = new BapbongView(hostEl); // hostEl: overflow:auto, fixed height
29
+ await view.loadDocx(bytes); // ArrayBuffer of a .docx
30
+ view.setZoom(1.25);
31
+ view.onChange((c) => console.log(c.pageCount));
32
+ // view.print(); // render every page → one image per sheet
33
+ // view.destroy();
34
+ ```
35
+
36
+ Constructor options (`BapbongViewOptions`):
37
+
38
+ | Option | Default | Meaning |
39
+ | --- | --- | --- |
40
+ | `viewport` | the host | Scroll container for virtualization / scroll-into-view |
41
+ | `zoom` | `1` | Initial zoom factor |
42
+ | `selectable` | `true` | Drag to select text, double-click word, Ctrl/⌘+A, Ctrl/⌘+C copy |
43
+ | `a11y` | `true` | Build a visually-hidden ARIA mirror for screen readers |
44
+ | `a11yLabel` | `"Document content"` | `aria-label` for the mirror |
45
+
46
+ Methods: `loadDocx` · `scrollToPos` · `setZoom` / `getZoom` · `print` ·
47
+ `exportDocx` · `onChange` · `destroy`; getters `pageCount` · `schema`.
48
+
49
+ ## `RenderCore` — the shared render engine
50
+
51
+ Lower-level than `BapbongView` (no host wrapper); the editor composes it. It
52
+ owns load → layout → paint → scroll/zoom/virtualize plus the geometry queries
53
+ (`caretRect`, `selectionRects`, `posAtEvent`, `pageToCanvas`, …) and holds a
54
+ **read-only ProseMirror doc** (a `Node`, not an `EditorState`). You normally use
55
+ `BapbongView`; reach for `RenderCore` only to build a custom surface.
56
+
57
+ ## Notes
58
+
59
+ - **Selection is painted on the canvas** (using the same layout geometry the
60
+ painter uses, so it aligns at any zoom) and copies via the document model.
61
+ Because it isn't a native browser selection, the browser's right-click "Copy"
62
+ and find-in-page don't apply — `Ctrl/⌘+C` does.
63
+ - **Print is rasterized** (each page is a high-res PNG, one per sheet) — pixel
64
+ faithful to the screen, but the printout's text isn't selectable. Vector/PDF
65
+ output is a planned separate path (`@shadow-garden/bapbong-pdf`).
66
+ - Deps: `contracts`, `model`, `docx`, `measuring`, `layout-engine`,
67
+ `painter-canvas`, `selection`, `a11y` — **not** `input-bridge`.
package/dist/index.cjs ADDED
@@ -0,0 +1,597 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // packages/view/src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BapbongView: () => BapbongView,
24
+ RenderCore: () => RenderCore,
25
+ ViewerSelection: () => ViewerSelection,
26
+ collectFontFamilies: () => collectFontFamilies
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // packages/view/src/lib/render-core.ts
31
+ var import_bapbong_model = require("@shadow-garden/bapbong-model");
32
+ var import_bapbong_docx = require("@shadow-garden/bapbong-docx");
33
+ var import_bapbong_layout_engine = require("@shadow-garden/bapbong-layout-engine");
34
+ var import_bapbong_measuring = require("@shadow-garden/bapbong-measuring");
35
+ var import_bapbong_painter_canvas = require("@shadow-garden/bapbong-painter-canvas");
36
+ var import_bapbong_selection = require("@shadow-garden/bapbong-selection");
37
+ var import_bapbong_a11y = require("@shadow-garden/bapbong-a11y");
38
+ var A4 = {
39
+ width: 794,
40
+ height: 1123,
41
+ margin: { top: 96, right: 96, bottom: 96, left: 96 }
42
+ };
43
+ var RenderCore = class {
44
+ stack;
45
+ viewport;
46
+ painter;
47
+ measureText;
48
+ measureMetrics;
49
+ resolved = null;
50
+ doc = null;
51
+ zoomFactor;
52
+ // Page chrome from the imported docx, keyed by w:type (default/first/even).
53
+ chromeHeaders = {};
54
+ chromeFooters = {};
55
+ chromeTitlePg = false;
56
+ chromeEvenAndOdd = false;
57
+ // Footnote bodies keyed by display number (laid out at the page bottom).
58
+ footnotes;
59
+ // The imported source package — passed to exportDocx({ carry }) so styles /
60
+ // numbering / headers / media survive a round-trip.
61
+ importedRaw = null;
62
+ // The document schema: model's base, or a caller-supplied extension.
63
+ docSchema = import_bapbong_model.schema;
64
+ // Page geometry from the imported docx's sectPr (A4 until imported).
65
+ page = A4;
66
+ // Incremental re-layout: unchanged paragraphs skip measuring on each change.
67
+ // Replaced wholesale when late-loading fonts invalidate every measurement.
68
+ layoutCache = (0, import_bapbong_layout_engine.createLayoutCache)();
69
+ // Last painted overlay — reused on scroll/zoom/font-relayout repaints.
70
+ lastOverlay = { caret: null, selection: [] };
71
+ // Doc-range decorations to paint (comment tint, find highlight…). The editor
72
+ // sets this; the core resolves the ranges to page rects at paint time.
73
+ decorationProvider = null;
74
+ // Scroll repaint throttle (page virtualization).
75
+ scrollRaf = null;
76
+ // Late-font re-layout coalescing: families the current doc actually uses
77
+ // (set at load) + a debounce timer so a burst of loadingdone events (fonts
78
+ // land face-by-face) costs ONE re-layout, not one per event.
79
+ docFamilies = /* @__PURE__ */ new Set();
80
+ fontRelayoutTimer = null;
81
+ // Subscribers notified after a late-font relayout (rects moved).
82
+ fontsListeners = /* @__PURE__ */ new Set();
83
+ // Visually-hidden ARIA mirror of the doc (screen readers; canvas is opaque).
84
+ a11y;
85
+ constructor(stack, opts = {}) {
86
+ this.stack = stack;
87
+ this.viewport = opts.viewport ?? stack.closest(".canvas-wrap");
88
+ this.painter = new import_bapbong_painter_canvas.CanvasPainter(stack);
89
+ this.measureText = opts.measureText ?? (0, import_bapbong_measuring.createCanvasMeasurer)();
90
+ this.measureMetrics = opts.measureMetrics ?? (0, import_bapbong_measuring.createCanvasMetrics)();
91
+ this.zoomFactor = opts.zoom ?? 1;
92
+ this.a11y = opts.a11y === false ? null : new import_bapbong_a11y.A11yMirror(stack, { label: opts.a11yLabel });
93
+ this.viewport?.addEventListener("scroll", this.onScroll);
94
+ document.fonts?.addEventListener?.("loadingdone", this.onFontsLoaded);
95
+ }
96
+ // ── State accessors ─────────────────────────────────────────────────
97
+ /** The current read-only document, or null before the first load. */
98
+ get document() {
99
+ return this.doc;
100
+ }
101
+ /** The resolved layout for the current doc, or null. */
102
+ get layout() {
103
+ return this.resolved;
104
+ }
105
+ /** Number of laid-out pages (0 before the first document). */
106
+ get pageCount() {
107
+ return this.resolved?.pages.length ?? 0;
108
+ }
109
+ /** The document schema in use (model's base, or a caller-supplied extension). */
110
+ get schema() {
111
+ return this.docSchema;
112
+ }
113
+ /** Resolve doc-range decorations to page rects on each content paint. */
114
+ setDecorationProvider(fn) {
115
+ this.decorationProvider = fn;
116
+ }
117
+ // ── Load / export ───────────────────────────────────────────────────
118
+ /**
119
+ * Import a `.docx`, lay it out, and (by default) paint the first frame. Pass
120
+ * `{ schema }` to import against an extended schema (e.g. plugin marks);
121
+ * `{ paint: false }` to skip the initial paint (the editor paints with a caret
122
+ * overlay itself). Resolves with the doc + imported page-chrome keys.
123
+ */
124
+ async loadDocx(bytes, opts = {}) {
125
+ const { doc, headers, footers, footnotes, titlePg, evenAndOdd, page, raw } = await (0, import_bapbong_docx.importDocx)(
126
+ bytes,
127
+ opts.schema ? { schema: opts.schema } : void 0
128
+ );
129
+ this.docSchema = opts.schema ?? import_bapbong_model.schema;
130
+ this.importedRaw = raw;
131
+ this.chromeHeaders = headers;
132
+ this.chromeFooters = footers;
133
+ this.chromeTitlePg = titlePg;
134
+ this.chromeEvenAndOdd = evenAndOdd;
135
+ this.footnotes = footnotes;
136
+ this.page = page;
137
+ const families = collectFontFamilies(doc, ...Object.values(headers), ...Object.values(footers));
138
+ this.docFamilies = new Set(families.map(normalizeFamily));
139
+ await (0, import_bapbong_measuring.ensureFontsLoaded)(families);
140
+ this.layoutDoc(doc);
141
+ if (opts.paint !== false) this.paintContent();
142
+ return { doc, headerKeys: Object.keys(headers), footerKeys: Object.keys(footers) };
143
+ }
144
+ /** Export the current document to .docx bytes, carrying the imported source
145
+ * package so unmodelled parts survive the round-trip. */
146
+ async exportDocx() {
147
+ if (!this.doc) throw new Error("RenderCore: no document loaded");
148
+ return (0, import_bapbong_docx.exportDocx)(this.doc, this.importedRaw ? { carry: this.importedRaw } : void 0);
149
+ }
150
+ // ── Layout / paint ──────────────────────────────────────────────────
151
+ /** Lay out (or re-lay, incrementally) `doc` and store it. Does not paint. */
152
+ layoutDoc(doc) {
153
+ this.doc = doc;
154
+ this.a11y?.update(doc);
155
+ this.resolved = (0, import_bapbong_layout_engine.layout)(
156
+ doc,
157
+ { page: this.page, measureText: this.measureText, measureMetrics: this.measureMetrics },
158
+ this.layoutCache,
159
+ {
160
+ header: this.chromeHeaders["default"],
161
+ footer: this.chromeFooters["default"],
162
+ headerFirst: this.chromeHeaders["first"],
163
+ footerFirst: this.chromeFooters["first"],
164
+ headerEven: this.chromeHeaders["even"],
165
+ footerEven: this.chromeFooters["even"],
166
+ titlePg: this.chromeTitlePg,
167
+ evenAndOdd: this.chromeEvenAndOdd
168
+ },
169
+ this.footnotes
170
+ );
171
+ }
172
+ /** Full content repaint, virtualized to the scroll viewport. Pass an overlay
173
+ * to update the caret/selection painted on top (kept for later repaints). */
174
+ paintContent(overlay) {
175
+ if (!this.resolved) return;
176
+ if (overlay) this.lastOverlay = normalizeOverlay(overlay);
177
+ this.painter.paint(this.resolved, {
178
+ zoom: this.zoomFactor,
179
+ caret: this.lastOverlay.caret,
180
+ selection: this.lastOverlay.selection,
181
+ viewport: this.currentViewport(),
182
+ decorations: this.collectDecorations()
183
+ });
184
+ }
185
+ /** Redraw only the caret/selection overlay (just the affected page canvases —
186
+ * used for caret blink, drag preview, selection-only changes). */
187
+ paintOverlay(overlay) {
188
+ if (!this.resolved) return;
189
+ this.lastOverlay = normalizeOverlay(overlay);
190
+ this.painter.paintOverlay({ caret: this.lastOverlay.caret, selection: this.lastOverlay.selection });
191
+ }
192
+ /** Set the zoom factor (1 = 100%) and repaint content at the new scale. */
193
+ setZoom(zoom) {
194
+ this.zoomFactor = zoom;
195
+ this.paintContent();
196
+ }
197
+ /** The current zoom factor. */
198
+ getZoom() {
199
+ return this.zoomFactor;
200
+ }
201
+ /**
202
+ * Print the whole document. The live canvas is virtualized (only visible
203
+ * pages exist), so this renders **every** page with a throwaway painter,
204
+ * snapshots each to a PNG, and prints one image per sheet via a hidden iframe
205
+ * (clean pagination, the live view untouched).
206
+ */
207
+ async print() {
208
+ const layout2 = this.resolved;
209
+ if (!layout2) return;
210
+ const holder = document.createElement("div");
211
+ holder.style.cssText = "position:absolute;left:-99999px;top:0;pointer-events:none;";
212
+ document.body.appendChild(holder);
213
+ try {
214
+ new import_bapbong_painter_canvas.CanvasPainter(holder).paint(layout2, { zoom: 1 });
215
+ const canvases = Array.from(holder.querySelectorAll("canvas")).sort(
216
+ (a, b) => parseFloat(a.style.top || "0") - parseFloat(b.style.top || "0")
217
+ );
218
+ const sources = canvases.map((c) => c.toDataURL("image/png"));
219
+ await printImages(sources);
220
+ } finally {
221
+ holder.remove();
222
+ }
223
+ }
224
+ /** Gather decorations from the provider and resolve each doc range to
225
+ * page-local rects the painter can fill directly. */
226
+ collectDecorations() {
227
+ if (!this.resolved || !this.decorationProvider) return [];
228
+ const out = [];
229
+ for (const d of this.decorationProvider()) {
230
+ const rects = (0, import_bapbong_selection.selectionRects)(this.resolved, d.from, d.to, this.measureText);
231
+ if (rects.length) out.push({ rects, kind: d.kind, color: d.color });
232
+ }
233
+ return out;
234
+ }
235
+ // ── Geometry queries ────────────────────────────────────────────────
236
+ /** Caret geometry at a doc position (page-local), or null. */
237
+ caretRect(pos) {
238
+ return this.resolved ? (0, import_bapbong_selection.caretRect)(this.resolved, pos, this.measureText) : null;
239
+ }
240
+ /** Selection highlight rects for a doc range (page-local). */
241
+ selectionRects(from, to) {
242
+ return this.resolved ? (0, import_bapbong_selection.selectionRects)(this.resolved, from, to, this.measureText) : [];
243
+ }
244
+ /** The doc position one line above/below `head` at horizontal `x`, or null. */
245
+ verticalCaret(head, dir, x) {
246
+ return this.resolved ? (0, import_bapbong_selection.verticalCaret)(this.resolved, head, dir, x, this.measureText) : null;
247
+ }
248
+ /** Map client (viewport) coords to a page-local point, or null. */
249
+ clientToPage(clientX, clientY) {
250
+ const rect = this.stack.getBoundingClientRect();
251
+ return this.painter.canvasToPage(clientX - rect.left, clientY - rect.top);
252
+ }
253
+ /** The doc position under a page-local point, or null. */
254
+ posAtPoint(point) {
255
+ return this.resolved ? (0, import_bapbong_selection.hitTest)(this.resolved, point, this.measureText) : null;
256
+ }
257
+ /** The doc position under a pointer/mouse event, or null. */
258
+ posAtEvent(ev) {
259
+ const pt = this.clientToPage(ev.clientX, ev.clientY);
260
+ return pt ? this.posAtPoint(pt) : null;
261
+ }
262
+ /** Map a page-local point to container (canvas-stack) coordinates, or null. */
263
+ pageToCanvas(p) {
264
+ return this.painter.pageToCanvas(p);
265
+ }
266
+ /** Scroll the viewport so the caret at `pos` sits `topMargin` px from the top. */
267
+ scrollToPos(pos, topMargin = 80) {
268
+ const cr = this.caretRect(pos);
269
+ const pt = cr && this.painter.pageToCanvas({ pageIndex: cr.pageIndex, x: cr.x, y: cr.y });
270
+ if (pt && this.viewport) this.viewport.scrollTop = Math.max(0, pt.y - topMargin);
271
+ }
272
+ /** The viewport's window onto the page stack, in container CSS px. */
273
+ currentViewport() {
274
+ const wrap = this.viewport;
275
+ if (!wrap) return void 0;
276
+ const wrapRect = wrap.getBoundingClientRect();
277
+ const stackRect = this.stack.getBoundingClientRect();
278
+ return { top: wrapRect.top - stackRect.top, height: wrap.clientHeight };
279
+ }
280
+ // ── Lifecycle ───────────────────────────────────────────────────────
281
+ /** Subscribe to late-font relayouts (page geometry changed → recompute any
282
+ * caret/selection rects you hold). Returns an unsubscribe fn. */
283
+ onFontsReloaded(cb) {
284
+ this.fontsListeners.add(cb);
285
+ return () => this.fontsListeners.delete(cb);
286
+ }
287
+ destroy() {
288
+ if (this.scrollRaf != null) cancelAnimationFrame(this.scrollRaf);
289
+ this.scrollRaf = null;
290
+ if (this.fontRelayoutTimer != null) clearTimeout(this.fontRelayoutTimer);
291
+ this.fontRelayoutTimer = null;
292
+ this.viewport?.removeEventListener("scroll", this.onScroll);
293
+ document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
294
+ this.fontsListeners.clear();
295
+ this.a11y?.destroy();
296
+ }
297
+ /** Repaint newly visible pages while scrolling (rAF-throttled). */
298
+ onScroll = () => {
299
+ if (this.scrollRaf != null) return;
300
+ this.scrollRaf = requestAnimationFrame(() => {
301
+ this.scrollRaf = null;
302
+ this.paintContent();
303
+ });
304
+ };
305
+ onFontsLoaded = (ev) => {
306
+ if (!this.doc) return;
307
+ const faces = ev.fontfaces ?? [];
308
+ if (faces.length > 0 && !faces.some((f) => this.docFamilies.has(normalizeFamily(f.family)))) {
309
+ return;
310
+ }
311
+ if (this.fontRelayoutTimer != null) clearTimeout(this.fontRelayoutTimer);
312
+ this.fontRelayoutTimer = setTimeout(() => {
313
+ this.fontRelayoutTimer = null;
314
+ if (!this.doc) return;
315
+ this.layoutCache = (0, import_bapbong_layout_engine.createLayoutCache)();
316
+ this.layoutDoc(this.doc);
317
+ this.paintContent();
318
+ for (const cb of this.fontsListeners) cb();
319
+ }, 150);
320
+ };
321
+ };
322
+ function normalizeOverlay(overlay) {
323
+ return { caret: overlay.caret ?? null, selection: overlay.selection ?? [] };
324
+ }
325
+ function printImages(sources) {
326
+ return new Promise((resolve) => {
327
+ if (sources.length === 0) {
328
+ resolve();
329
+ return;
330
+ }
331
+ const iframe = document.createElement("iframe");
332
+ iframe.setAttribute("aria-hidden", "true");
333
+ iframe.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;";
334
+ document.body.appendChild(iframe);
335
+ const cw = iframe.contentWindow;
336
+ const doc = iframe.contentDocument;
337
+ if (!cw || !doc) {
338
+ iframe.remove();
339
+ resolve();
340
+ return;
341
+ }
342
+ const body = sources.map((s) => `<img src="${s}" alt="" />`).join("");
343
+ doc.open();
344
+ doc.write(
345
+ `<!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>`
346
+ );
347
+ doc.close();
348
+ let settled = false;
349
+ const finish = () => {
350
+ if (settled) return;
351
+ settled = true;
352
+ cw.focus();
353
+ cw.print();
354
+ setTimeout(() => iframe.remove(), 1e3);
355
+ resolve();
356
+ };
357
+ const pending = Array.from(doc.images).filter((im) => !im.complete);
358
+ if (pending.length === 0) {
359
+ finish();
360
+ return;
361
+ }
362
+ let left = pending.length;
363
+ const onOne = () => {
364
+ if (--left === 0) finish();
365
+ };
366
+ for (const im of pending) {
367
+ im.addEventListener("load", onOne);
368
+ im.addEventListener("error", onOne);
369
+ }
370
+ setTimeout(finish, 4e3);
371
+ });
372
+ }
373
+ function normalizeFamily(family) {
374
+ return family.replace(/^["']|["']$/g, "").trim().toLowerCase();
375
+ }
376
+ function collectFontFamilies(...docs) {
377
+ const families = /* @__PURE__ */ new Set(["Arial"]);
378
+ for (const doc of docs) {
379
+ doc?.descendants((node) => {
380
+ for (const mark of node.marks) {
381
+ if (mark.type.name === "fontFamily" && mark.attrs["family"]) {
382
+ families.add(String(mark.attrs["family"]));
383
+ }
384
+ }
385
+ });
386
+ }
387
+ return [...families];
388
+ }
389
+
390
+ // packages/view/src/lib/viewer-selection.ts
391
+ var ViewerSelection = class {
392
+ constructor(core) {
393
+ this.core = core;
394
+ const s = core.stack;
395
+ s.style.cursor = "text";
396
+ if (s.tabIndex < 0) s.tabIndex = 0;
397
+ s.style.outline = "none";
398
+ s.addEventListener("pointerdown", this.onDown);
399
+ s.addEventListener("pointermove", this.onMove);
400
+ s.addEventListener("pointerup", this.onUp);
401
+ s.addEventListener("pointercancel", this.onUp);
402
+ s.addEventListener("dblclick", this.onDblClick);
403
+ s.addEventListener("keydown", this.onKey);
404
+ }
405
+ core;
406
+ anchor = null;
407
+ head = null;
408
+ dragging = false;
409
+ /** Drop any current selection (e.g. when a new document loads). */
410
+ clear() {
411
+ this.anchor = this.head = null;
412
+ this.dragging = false;
413
+ this.core.paintOverlay({ selection: [] });
414
+ }
415
+ destroy() {
416
+ const s = this.core.stack;
417
+ s.removeEventListener("pointerdown", this.onDown);
418
+ s.removeEventListener("pointermove", this.onMove);
419
+ s.removeEventListener("pointerup", this.onUp);
420
+ s.removeEventListener("pointercancel", this.onUp);
421
+ s.removeEventListener("dblclick", this.onDblClick);
422
+ s.removeEventListener("keydown", this.onKey);
423
+ }
424
+ // ── Pointer ─────────────────────────────────────────────────────────
425
+ onDown = (ev) => {
426
+ if (ev.button !== 0) return;
427
+ const pos = this.core.posAtEvent(ev);
428
+ if (pos == null) return;
429
+ ev.preventDefault();
430
+ this.core.stack.focus?.();
431
+ this.anchor = this.head = pos;
432
+ this.dragging = true;
433
+ this.paint();
434
+ try {
435
+ ev.target.setPointerCapture?.(ev.pointerId);
436
+ } catch {
437
+ }
438
+ };
439
+ onMove = (ev) => {
440
+ if (!this.dragging || this.anchor == null || !(ev.buttons & 1)) return;
441
+ const coalesced = ev.getCoalescedEvents?.() ?? [];
442
+ const last = coalesced.length > 0 ? coalesced[coalesced.length - 1] : ev;
443
+ const pos = this.core.posAtEvent(last);
444
+ if (pos == null || pos === this.head) return;
445
+ this.head = pos;
446
+ this.paint();
447
+ };
448
+ onUp = () => {
449
+ this.dragging = false;
450
+ };
451
+ /** Double-click selects the word under the pointer. */
452
+ onDblClick = (ev) => {
453
+ const pos = this.core.posAtEvent(ev);
454
+ const doc = this.core.document;
455
+ if (pos == null || !doc) return;
456
+ const word = wordRangeAt(doc, pos);
457
+ if (!word) return;
458
+ ev.preventDefault();
459
+ this.anchor = word.from;
460
+ this.head = word.to;
461
+ this.paint();
462
+ };
463
+ // ── Keyboard ────────────────────────────────────────────────────────
464
+ onKey = (ev) => {
465
+ if (!(ev.metaKey || ev.ctrlKey)) return;
466
+ const key = ev.key.toLowerCase();
467
+ if (key === "c") {
468
+ const text = this.selectedText();
469
+ if (text) {
470
+ ev.preventDefault();
471
+ navigator.clipboard?.writeText(text).catch(() => {
472
+ });
473
+ }
474
+ } else if (key === "a") {
475
+ const doc = this.core.document;
476
+ if (!doc) return;
477
+ ev.preventDefault();
478
+ this.anchor = 0;
479
+ this.head = doc.content.size;
480
+ this.paint();
481
+ }
482
+ };
483
+ // ── Helpers ─────────────────────────────────────────────────────────
484
+ range() {
485
+ if (this.anchor == null || this.head == null || this.anchor === this.head) return null;
486
+ return { from: Math.min(this.anchor, this.head), to: Math.max(this.anchor, this.head) };
487
+ }
488
+ paint() {
489
+ const r = this.range();
490
+ this.core.paintOverlay({ selection: r ? this.core.selectionRects(r.from, r.to) : [] });
491
+ }
492
+ selectedText() {
493
+ const r = this.range();
494
+ const doc = this.core.document;
495
+ if (!r || !doc) return null;
496
+ return doc.textBetween(r.from, r.to, "\n", "\n");
497
+ }
498
+ };
499
+ function wordRangeAt(doc, pos) {
500
+ const $pos = doc.resolve(pos);
501
+ const text = $pos.parent.textContent;
502
+ if (!text) return null;
503
+ const start = $pos.start();
504
+ let off = pos - start;
505
+ off = Math.max(0, Math.min(off, text.length));
506
+ const isWord = (c) => c != null && !/\s/.test(c);
507
+ if (!isWord(text[off]) && isWord(text[off - 1])) off -= 1;
508
+ if (!isWord(text[off])) return null;
509
+ let from = off;
510
+ let to = off;
511
+ while (from > 0 && isWord(text[from - 1])) from--;
512
+ while (to < text.length && isWord(text[to])) to++;
513
+ return { from: start + from, to: start + to };
514
+ }
515
+
516
+ // packages/view/src/lib/bapbong-view.ts
517
+ var BapbongView = class {
518
+ stack;
519
+ core;
520
+ selection;
521
+ changeListeners = /* @__PURE__ */ new Set();
522
+ offFonts;
523
+ constructor(host, opts = {}) {
524
+ const stack = document.createElement("div");
525
+ stack.style.position = "relative";
526
+ host.appendChild(stack);
527
+ this.stack = stack;
528
+ this.core = new RenderCore(stack, {
529
+ viewport: opts.viewport ?? host,
530
+ zoom: opts.zoom,
531
+ a11y: opts.a11y,
532
+ a11yLabel: opts.a11yLabel
533
+ });
534
+ this.offFonts = this.core.onFontsReloaded(() => this.emit());
535
+ this.selection = opts.selectable === false ? null : new ViewerSelection(this.core);
536
+ }
537
+ /** Import a `.docx` and render the first frame. Resolves with the imported
538
+ * page-chrome keys (header/footer w:types present). */
539
+ async loadDocx(bytes) {
540
+ this.selection?.clear();
541
+ const { headerKeys, footerKeys } = await this.core.loadDocx(bytes);
542
+ this.emit();
543
+ return { headerKeys, footerKeys };
544
+ }
545
+ /** Export the current document back to .docx bytes (carries the source
546
+ * package so unmodelled parts survive the round-trip). */
547
+ exportDocx() {
548
+ return this.core.exportDocx();
549
+ }
550
+ /** Scroll so the caret at doc position `pos` sits `topMargin` px from the top. */
551
+ scrollToPos(pos, topMargin) {
552
+ this.core.scrollToPos(pos, topMargin);
553
+ }
554
+ /** Set the zoom factor (1 = 100%) and repaint at the new scale. */
555
+ setZoom(zoom) {
556
+ this.core.setZoom(zoom);
557
+ }
558
+ /** The current zoom factor. */
559
+ getZoom() {
560
+ return this.core.getZoom();
561
+ }
562
+ /** Print the whole document (renders every page, one per sheet). */
563
+ print() {
564
+ return this.core.print();
565
+ }
566
+ /** Number of laid-out pages (0 before the first document). */
567
+ get pageCount() {
568
+ return this.core.pageCount;
569
+ }
570
+ /** The document schema in use. */
571
+ get schema() {
572
+ return this.core.schema;
573
+ }
574
+ /** Subscribe to render cycles (document loaded, page count changed). Returns
575
+ * an unsubscribe fn. */
576
+ onChange(cb) {
577
+ this.changeListeners.add(cb);
578
+ return () => this.changeListeners.delete(cb);
579
+ }
580
+ destroy() {
581
+ this.offFonts();
582
+ this.selection?.destroy();
583
+ this.core.destroy();
584
+ this.stack.remove();
585
+ }
586
+ emit() {
587
+ const change = { pageCount: this.core.pageCount };
588
+ for (const cb of this.changeListeners) cb(change);
589
+ }
590
+ };
591
+ // Annotate the CommonJS export names for ESM import in node:
592
+ 0 && (module.exports = {
593
+ BapbongView,
594
+ RenderCore,
595
+ ViewerSelection,
596
+ collectFontFamilies
597
+ });
@@ -0,0 +1,4 @@
1
+ export * from './lib/render-core.js';
2
+ export * from './lib/viewer-selection.js';
3
+ export * from './lib/bapbong-view.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC"}