@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/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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|