@onodocs/canvas 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,7 +60,69 @@ Batch range offsets refer to the original paragraph text. Empty ranges insert te
60
60
 
61
61
  Browser updates rebuild layout and refresh mounted views. Query, semantic, layout, page and geometry values obtained earlier remain snapshots; read them again after updating. Application attachments are removed during refresh and can be reattached using fresh geometry. Updates run in submission order and accept `{ signal }` for cancellation. The original `source` is unchanged.
62
62
 
63
- Node inspection exposes the same update method. For server rendering, inspect the input with the default entry point to select elements or ranges, then pass those updates to the server document before `renderPage` or `pdf`. Exports reflect the updated text. Saving an updated DOCX, structural editing, and field recalculation are not supported.
63
+ Node inspection exposes the same update method. For server rendering, inspect the input with the default entry point to select elements or ranges, then pass those updates to the server document before `renderPage`, `pdf`, or `save`. Exports reflect the updated text.
64
+
65
+ ## Save an edited Word document
66
+
67
+ `save()` returns DOCX bytes in Node, the browser, and server rendering. Saving without edits returns the original file bytes. Text edits preserve surrounding run formatting and unedited package content. Saving and updating run in submission order. Both accept `{ signal }`; cancellation leaves the committed document available. Call `dispose()` when finished, including for Node documents, to release the preserved source package.
68
+
69
+ ```js
70
+ import { readFile, writeFile } from "node:fs/promises";
71
+ import { openDocument } from "@onodocs/sdk";
72
+
73
+ const doc = await openDocument(await readFile("template.docx"));
74
+ try {
75
+ await doc.update({
76
+ target: doc.query.contentControls().where({ tag: "customer" }).one(),
77
+ text: "Willow Design"
78
+ });
79
+ await writeFile("completed.docx", await doc.save());
80
+ } finally {
81
+ doc.dispose();
82
+ }
83
+ ```
84
+
85
+ In a browser, create a Blob from the returned bytes with type `application/vnd.openxmlformats-officedocument.wordprocessingml.document`. In a combined deployment, serve `await doc.save()` from an application-owned download endpoint with that content type and a `.docx` filename. The backend processing document owns saving; a Canvas-only frontend does not contain the original Word package.
86
+
87
+ The SDK package includes a complete command-line example. After installing it, run `node node_modules/@onodocs/sdk/examples/fill-template.mjs template.docx completed.docx customer "Willow Design"`. Set `ONODOCS_LICENSE_KEY` for trial or commercial use. The source workspace keeps the same example at `examples/fill-template.mjs`.
88
+
89
+ The initial editing profile covers ordinary text and unbound text or rich-text content controls within one paragraph, including supported tables, headers, footers and notes. Edits inside bound or content-locked controls, temporary or specialized controls, fields, revisions, alternate-content branches, and source runs with generated or unsupported content are rejected before publication. Plain-text controls that disallow multiple lines reject newlines. Filling an unbound text control clears its placeholder-display flag. Edits elsewhere preserve these constructs. `DocumentSaveError.reason` identifies source-writing restrictions; existing semantic target errors remain `RangeError`.
90
+
91
+ Signed packages, enforced document protection, enabled change tracking, macro-enabled documents and templates can be saved unchanged, but editing them is not supported. Field recalculation and revision authoring are not included. Field caches remain as imported, and saved bookmark/comment markers retain their original run boundaries. Original authored formatting is preserved independently of rendering compatibility decisions.
92
+
93
+ Evaluation exports contain the full Word document without adding a Word watermark. The existing non-production evaluation terms apply; rendered evaluation output continues to include its watermark.
94
+
95
+ ## Edit formatting and paragraphs
96
+
97
+ `edit()` accepts a logical selection with paragraph IDs and UTF-16 offsets. It returns the selection in the resulting document. Use fresh queries after editing because structural operations rebuild the document and its IDs. The `source` getter then describes the new committed package.
98
+
99
+ ```js
100
+ const paragraph = doc.query.paragraphs().first();
101
+ let selection = {
102
+ start: { paragraphId: paragraph.id, offset: 0 },
103
+ end: { paragraphId: paragraph.id, offset: paragraph.text.length }
104
+ };
105
+ selection = await doc.edit({ kind: "format", selection, formatting: { bold: true, fontSize: 18 } });
106
+ selection = await doc.edit({ kind: "replace", selection, text: "First paragraph\nSecond paragraph" });
107
+ await doc.edit({ kind: "align", selection, alignment: "center" });
108
+ const bytes = await doc.save();
109
+ ```
110
+
111
+ Character formatting supports `bold`, `italic`, `underline`, `strike`, `fontSize` in points, `fontFamily`, and `color` as `#RRGGBB`. Values change direct formatting on the selected text while preserving the other authored properties. `run.formatting` exposes the common effective character properties for controls; `paragraph.alignment` exposes left, center, right or justified alignment. These convenience properties describe ordinary Latin text; they do not replace the full semantic typography model for script-specific inspection.
112
+
113
+ A replacement with a collapsed selection inserts text. Newlines split paragraphs; set `paragraphBreaks: false` for line breaks within a paragraph. Replacing a selection across adjacent paragraphs joins their surviving prefix and suffix. Empty text deletes the selection. New text inherits the starting run's formatting unless the operation includes `formatting`. Split paragraphs inherit the starting paragraph's properties. The first paragraph's properties win when joining.
114
+
115
+ Rich editing supports ordinary text paragraphs, including empty paragraphs and paragraphs in table cells and secondary stories. Operations affecting paragraphs with fields, links, controls, drawings, comments, bookmarks, tracked revisions or unsupported children reject atomically. Splitting or joining section boundaries and joining across cells or stories also reject. Such content remains preserved elsewhere in the document. The narrower `update()` operation continues to support safe text replacement within unbound controls.
116
+
117
+ Node and browser documents expose `edit`; server documents forward the same operation. Parsing, semantic construction and browser layout finish before publication, so cancellation or failure leaves the previous document usable. This first implementation rebuilds the document for structural edits. Browser resources stay with the document lifetime, and the sample keeps undo history as document snapshots. Large documents need further editing-performance work before this can serve as a general-purpose editor.
118
+
119
+ ## Run the inline browser editor sample
120
+
121
+ From the source workspace, run `npm run build` and `npm run example:editor`, then open `http://127.0.0.1:5190`. The sample opens its included Word document and accepts local DOCX files. Click directly on a rendered page to type, drag to select text, and use the toolbar to change formatting. Enter splits paragraphs; Backspace at a paragraph's start joins it to the previous paragraph. Shift+Enter inserts a line break. The sample also supports keyboard selection, copy/cut, plain-text paste, undo/redo and Word download. Files remain in the browser.
122
+
123
+ The sample is in `examples/browser-editor/`. It consumes only exported SDK and Canvas APIs. Canvas `createDocument` accepts a native textarea through `viewOptions.input`, plus an `onSelectionChange` callback in the same options. The view positions this input at the published caret, handles selection using layout geometry, and exposes `selection` and `setSelection`. The application handles input events and submits `doc.edit()` operations. Text, line wrapping and pagination remain rendered by OnoDocs.
124
+
125
+ Run `npm run validate:editor` for the browser editing journey. The sample currently uses the workspace build; it requires the new editing APIs and does not work against the earlier published package.
64
126
 
65
127
  ## Render and attach ordinary DOM
66
128
 
package/SDK.md CHANGED
@@ -60,7 +60,69 @@ Batch range offsets refer to the original paragraph text. Empty ranges insert te
60
60
 
61
61
  Browser updates rebuild layout and refresh mounted views. Query, semantic, layout, page and geometry values obtained earlier remain snapshots; read them again after updating. Application attachments are removed during refresh and can be reattached using fresh geometry. Updates run in submission order and accept `{ signal }` for cancellation. The original `source` is unchanged.
62
62
 
63
- Node inspection exposes the same update method. For server rendering, inspect the input with the default entry point to select elements or ranges, then pass those updates to the server document before `renderPage` or `pdf`. Exports reflect the updated text. Saving an updated DOCX, structural editing, and field recalculation are not supported.
63
+ Node inspection exposes the same update method. For server rendering, inspect the input with the default entry point to select elements or ranges, then pass those updates to the server document before `renderPage`, `pdf`, or `save`. Exports reflect the updated text.
64
+
65
+ ## Save an edited Word document
66
+
67
+ `save()` returns DOCX bytes in Node, the browser, and server rendering. Saving without edits returns the original file bytes. Text edits preserve surrounding run formatting and unedited package content. Saving and updating run in submission order. Both accept `{ signal }`; cancellation leaves the committed document available. Call `dispose()` when finished, including for Node documents, to release the preserved source package.
68
+
69
+ ```js
70
+ import { readFile, writeFile } from "node:fs/promises";
71
+ import { openDocument } from "@onodocs/sdk";
72
+
73
+ const doc = await openDocument(await readFile("template.docx"));
74
+ try {
75
+ await doc.update({
76
+ target: doc.query.contentControls().where({ tag: "customer" }).one(),
77
+ text: "Willow Design"
78
+ });
79
+ await writeFile("completed.docx", await doc.save());
80
+ } finally {
81
+ doc.dispose();
82
+ }
83
+ ```
84
+
85
+ In a browser, create a Blob from the returned bytes with type `application/vnd.openxmlformats-officedocument.wordprocessingml.document`. In a combined deployment, serve `await doc.save()` from an application-owned download endpoint with that content type and a `.docx` filename. The backend processing document owns saving; a Canvas-only frontend does not contain the original Word package.
86
+
87
+ The SDK package includes a complete command-line example. After installing it, run `node node_modules/@onodocs/sdk/examples/fill-template.mjs template.docx completed.docx customer "Willow Design"`. Set `ONODOCS_LICENSE_KEY` for trial or commercial use. The source workspace keeps the same example at `examples/fill-template.mjs`.
88
+
89
+ The initial editing profile covers ordinary text and unbound text or rich-text content controls within one paragraph, including supported tables, headers, footers and notes. Edits inside bound or content-locked controls, temporary or specialized controls, fields, revisions, alternate-content branches, and source runs with generated or unsupported content are rejected before publication. Plain-text controls that disallow multiple lines reject newlines. Filling an unbound text control clears its placeholder-display flag. Edits elsewhere preserve these constructs. `DocumentSaveError.reason` identifies source-writing restrictions; existing semantic target errors remain `RangeError`.
90
+
91
+ Signed packages, enforced document protection, enabled change tracking, macro-enabled documents and templates can be saved unchanged, but editing them is not supported. Field recalculation and revision authoring are not included. Field caches remain as imported, and saved bookmark/comment markers retain their original run boundaries. Original authored formatting is preserved independently of rendering compatibility decisions.
92
+
93
+ Evaluation exports contain the full Word document without adding a Word watermark. The existing non-production evaluation terms apply; rendered evaluation output continues to include its watermark.
94
+
95
+ ## Edit formatting and paragraphs
96
+
97
+ `edit()` accepts a logical selection with paragraph IDs and UTF-16 offsets. It returns the selection in the resulting document. Use fresh queries after editing because structural operations rebuild the document and its IDs. The `source` getter then describes the new committed package.
98
+
99
+ ```js
100
+ const paragraph = doc.query.paragraphs().first();
101
+ let selection = {
102
+ start: { paragraphId: paragraph.id, offset: 0 },
103
+ end: { paragraphId: paragraph.id, offset: paragraph.text.length }
104
+ };
105
+ selection = await doc.edit({ kind: "format", selection, formatting: { bold: true, fontSize: 18 } });
106
+ selection = await doc.edit({ kind: "replace", selection, text: "First paragraph\nSecond paragraph" });
107
+ await doc.edit({ kind: "align", selection, alignment: "center" });
108
+ const bytes = await doc.save();
109
+ ```
110
+
111
+ Character formatting supports `bold`, `italic`, `underline`, `strike`, `fontSize` in points, `fontFamily`, and `color` as `#RRGGBB`. Values change direct formatting on the selected text while preserving the other authored properties. `run.formatting` exposes the common effective character properties for controls; `paragraph.alignment` exposes left, center, right or justified alignment. These convenience properties describe ordinary Latin text; they do not replace the full semantic typography model for script-specific inspection.
112
+
113
+ A replacement with a collapsed selection inserts text. Newlines split paragraphs; set `paragraphBreaks: false` for line breaks within a paragraph. Replacing a selection across adjacent paragraphs joins their surviving prefix and suffix. Empty text deletes the selection. New text inherits the starting run's formatting unless the operation includes `formatting`. Split paragraphs inherit the starting paragraph's properties. The first paragraph's properties win when joining.
114
+
115
+ Rich editing supports ordinary text paragraphs, including empty paragraphs and paragraphs in table cells and secondary stories. Operations affecting paragraphs with fields, links, controls, drawings, comments, bookmarks, tracked revisions or unsupported children reject atomically. Splitting or joining section boundaries and joining across cells or stories also reject. Such content remains preserved elsewhere in the document. The narrower `update()` operation continues to support safe text replacement within unbound controls.
116
+
117
+ Node and browser documents expose `edit`; server documents forward the same operation. Parsing, semantic construction and browser layout finish before publication, so cancellation or failure leaves the previous document usable. This first implementation rebuilds the document for structural edits. Browser resources stay with the document lifetime, and the sample keeps undo history as document snapshots. Large documents need further editing-performance work before this can serve as a general-purpose editor.
118
+
119
+ ## Run the inline browser editor sample
120
+
121
+ From the source workspace, run `npm run build` and `npm run example:editor`, then open `http://127.0.0.1:5190`. The sample opens its included Word document and accepts local DOCX files. Click directly on a rendered page to type, drag to select text, and use the toolbar to change formatting. Enter splits paragraphs; Backspace at a paragraph's start joins it to the previous paragraph. Shift+Enter inserts a line break. The sample also supports keyboard selection, copy/cut, plain-text paste, undo/redo and Word download. Files remain in the browser.
122
+
123
+ The sample is in `examples/browser-editor/`. It consumes only exported SDK and Canvas APIs. Canvas `createDocument` accepts a native textarea through `viewOptions.input`, plus an `onSelectionChange` callback in the same options. The view positions this input at the published caret, handles selection using layout geometry, and exposes `selection` and `setSelection`. The application handles input events and submits `doc.edit()` operations. Text, line wrapping and pagination remain rendered by OnoDocs.
124
+
125
+ Run `npm run validate:editor` for the browser editing journey. The sample currently uses the workspace build; it requires the new editing APIs and does not work against the earlier published package.
64
126
 
65
127
  ## Render and attach ordinary DOM
66
128
 
package/index.js CHANGED
@@ -1578,7 +1578,7 @@ __name(fillExactSectionColumnSeparator, "fillExactSectionColumnSeparator");
1578
1578
 
1579
1579
  // packages/canvas/src/text-selection.ts
1580
1580
  var nextView = 0;
1581
- function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, signal) {
1581
+ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, signal, input, onSelectionChange) {
1582
1582
  const document2 = viewport.ownerDocument;
1583
1583
  const window = document2.defaultView;
1584
1584
  const svg = "http://www.w3.org/2000/svg";
@@ -1607,7 +1607,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1607
1607
  for (const caret of value.carets) {
1608
1608
  const offset = offsets[caret.scalarOffset - value.scalarStart];
1609
1609
  if (offset === void 0) continue;
1610
- const stop = { line, offset: length + offset, x: caret.x, index: stops.length };
1610
+ const stop = { line, offset: length + offset, x: caret.x, index: stops.length, ...caret.paragraphOffset === void 0 ? {} : { position: { paragraphId: value.paragraphId, offset: caret.paragraphOffset } } };
1611
1611
  line.stops.push(stop);
1612
1612
  stops.push(stop);
1613
1613
  }
@@ -1639,6 +1639,10 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1639
1639
  let menu;
1640
1640
  viewport.tabIndex = 0;
1641
1641
  viewport.style.userSelect = "none";
1642
+ if (input) {
1643
+ viewport.style.position = "relative";
1644
+ viewport.append(input);
1645
+ }
1642
1646
  for (const shell of shells) shell.style.cursor = "text";
1643
1647
  function paint() {
1644
1648
  for (const [index, layer] of layers) {
@@ -1646,7 +1650,13 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1646
1650
  layer.replaceChildren();
1647
1651
  if (!nearby.has(index)) layers.delete(index);
1648
1652
  }
1649
- if (!anchor || !focus || document2.activeElement !== viewport && !menu?.contains(document2.activeElement)) return;
1653
+ if (!anchor || !focus || !input && document2.activeElement !== viewport && !menu?.contains(document2.activeElement)) return;
1654
+ if (input) {
1655
+ const shell = shells[focus.line.item.pageIndex], bounds = shell.getBoundingClientRect(), origin = viewport.getBoundingClientRect();
1656
+ const point = focus.line.transform.transformPoint({ x: focus.x, y: focus.line.item.selection.top });
1657
+ const scale = bounds.width / pageBounds[focus.line.item.pageIndex].width;
1658
+ Object.assign(input.style, { position: "absolute", left: `${bounds.left - origin.left + point.x * scale}px`, top: `${bounds.top - origin.top + point.y * scale}px`, height: `${focus.line.item.selection.height * scale}px` });
1659
+ }
1650
1660
  const start = Math.min(anchor.offset, focus.offset), end = Math.max(anchor.offset, focus.offset);
1651
1661
  for (const index of nearby) for (const line of pages[index] ?? []) {
1652
1662
  let rectangle2 = function(x, width, caret = false) {
@@ -1707,7 +1717,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1707
1717
  __name(paint, "paint");
1708
1718
  function nearest(clientX, clientY) {
1709
1719
  if (!logical.length) return;
1710
- let result, best = Infinity;
1720
+ let result, bestY = Infinity, bestX = Infinity;
1711
1721
  let first = 0, last = shells.length - 1;
1712
1722
  while (first < last) {
1713
1723
  const middle = first + last >>> 1;
@@ -1732,9 +1742,9 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1732
1742
  const dy = Math.max(value.top - y, 0, y - value.top - value.height) * box.height / height;
1733
1743
  for (const stop of line.stops) {
1734
1744
  const dx = Math.abs(stop.x - x) * box.width / width;
1735
- const distance = dy * dy * 16 + dx * dx;
1736
- if (distance < best) {
1737
- best = distance;
1745
+ if (dy < bestY || dy === bestY && dx < bestX) {
1746
+ bestY = dy;
1747
+ bestX = dx;
1738
1748
  result = stop;
1739
1749
  }
1740
1750
  }
@@ -1748,6 +1758,8 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1748
1758
  focus = stop;
1749
1759
  if (!extend || !anchor) anchor = stop;
1750
1760
  paint();
1761
+ onSelectionChange?.(currentSelection());
1762
+ if (input && document2.activeElement === viewport) input.focus({ preventScroll: true });
1751
1763
  if (scroll) {
1752
1764
  const shell = shells[stop.line.item.pageIndex];
1753
1765
  const point = stop.line.transform.transformPoint({ x: stop.x, y: stop.line.item.selection.top });
@@ -1788,13 +1800,23 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1788
1800
  pointerPosition = { x: event.clientX, y: event.clientY };
1789
1801
  select(nearest(event.clientX, event.clientY), true);
1790
1802
  }, { signal });
1803
+ viewport.addEventListener("dblclick", (event) => {
1804
+ if (event.target.closest("button,input,textarea,select,a,[data-onodocs-attachment]")) return;
1805
+ const stop = nearest(event.clientX, event.clientY);
1806
+ if (!stop) return;
1807
+ const segment = [...new Intl.Segmenter(void 0, { granularity: "word" }).segment(text)].find((value) => stop.offset >= value.index && stop.offset < value.index + value.segment.length);
1808
+ if (!segment) return;
1809
+ event.preventDefault();
1810
+ anchor = logical.find((value) => value.offset >= segment.index);
1811
+ select(logical.find((value) => value.offset >= segment.index + segment.segment.length), true);
1812
+ }, { signal });
1791
1813
  for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) viewport.addEventListener(type, (event) => {
1792
1814
  if (event.pointerId !== pointer) return;
1793
1815
  pointer = void 0;
1794
1816
  window.cancelAnimationFrame(frame);
1795
1817
  }, { signal });
1796
1818
  viewport.addEventListener("keydown", (event) => {
1797
- if (event.target !== viewport || !logical.length) return;
1819
+ if (event.target !== viewport && event.target !== input || !logical.length || event.isComposing) return;
1798
1820
  const command = event.ctrlKey || event.metaKey;
1799
1821
  if (command && event.key.toLowerCase() === "a") {
1800
1822
  event.preventDefault();
@@ -1813,7 +1835,11 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1813
1835
  else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1814
1836
  const delta = event.key === "ArrowLeft" ? -1 : 1;
1815
1837
  if (!event.shiftKey && anchor.offset !== focus.offset) next = delta < 0 ? anchor.offset < focus.offset ? anchor : focus : anchor.offset > focus.offset ? anchor : focus;
1816
- else {
1838
+ else if (command) {
1839
+ const words = [...new Intl.Segmenter(void 0, { granularity: "word" }).segment(text)].filter((value) => value.isWordLike);
1840
+ const offset = delta < 0 ? words.filter((value) => value.index < focus.offset).at(-1)?.index ?? 0 : words.find((value) => value.index > focus.offset)?.index ?? text.length;
1841
+ next = logical.find((value) => value.offset >= offset) ?? logical.at(-1);
1842
+ } else {
1817
1843
  let index = logicalIndex.get(focus) + delta;
1818
1844
  while (logical[index]?.offset === focus.offset) index += delta;
1819
1845
  next = logical[Math.max(0, Math.min(logical.length - 1, index))];
@@ -1830,7 +1856,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1830
1856
  select(next, event.shiftKey, true);
1831
1857
  }, { signal });
1832
1858
  document2.addEventListener("copy", (event) => {
1833
- if (document2.activeElement !== viewport && !menu?.contains(document2.activeElement) || !focus || anchor.offset === focus.offset) return;
1859
+ if (document2.activeElement !== viewport && document2.activeElement !== input && !menu?.contains(document2.activeElement) || !focus || anchor.offset === focus.offset) return;
1834
1860
  event.clipboardData?.setData("text/plain", selectedText());
1835
1861
  event.preventDefault();
1836
1862
  }, { signal });
@@ -1918,7 +1944,25 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1918
1944
  window.cancelAnimationFrame(frame);
1919
1945
  for (const layer of layers.values()) layer.remove();
1920
1946
  }, { once: true });
1921
- return { paint };
1947
+ function currentSelection() {
1948
+ if (!anchor?.position || !focus?.position) return;
1949
+ return anchor.offset <= focus.offset ? { start: anchor.position, end: focus.position } : { start: focus.position, end: anchor.position };
1950
+ }
1951
+ __name(currentSelection, "currentSelection");
1952
+ const byParagraph = /* @__PURE__ */ new Map();
1953
+ for (const stop of logical) if (stop.position) {
1954
+ let offsets = byParagraph.get(stop.position.paragraphId);
1955
+ if (!offsets) byParagraph.set(stop.position.paragraphId, offsets = /* @__PURE__ */ new Map());
1956
+ offsets.set(stop.position.offset, stop);
1957
+ }
1958
+ return { paint, get selection() {
1959
+ return currentSelection();
1960
+ }, setSelection(value) {
1961
+ const start = byParagraph.get(value.start.paragraphId)?.get(value.start.offset), end = byParagraph.get(value.end.paragraphId)?.get(value.end.offset);
1962
+ if (!start || !end) return;
1963
+ anchor = start;
1964
+ select(end, true, true);
1965
+ } };
1922
1966
  }
1923
1967
  __name(mountTextSelection, "mountTextSelection");
1924
1968
 
@@ -1965,10 +2009,10 @@ function mountDocument(document2, container, options = {}, onDispose) {
1965
2009
  let selection;
1966
2010
  let selectionLifetime;
1967
2011
  function prepareSelection(event) {
1968
- if (event.target.closest("[data-onodocs-attachment]")) return;
2012
+ if (event?.target?.closest("[data-onodocs-attachment]")) return;
1969
2013
  if (selection) return;
1970
2014
  selectionLifetime = new AbortController();
1971
- selection = mountTextSelection(document2.pages.flatMap((page) => page.interaction.text), element, shells.map((entry) => entry.shell), document2.pages.map((page) => page.size), nearby, AbortSignal.any([lifetime.signal, selectionLifetime.signal]));
2015
+ selection = mountTextSelection(document2.pages.flatMap((page) => page.interaction.text), element, shells.map((entry) => entry.shell), document2.pages.map((page) => page.size), nearby, AbortSignal.any([lifetime.signal, selectionLifetime.signal]), options.input, options.onSelectionChange);
1972
2016
  }
1973
2017
  __name(prepareSelection, "prepareSelection");
1974
2018
  for (const type of ["pointerdown", "keydown", "contextmenu", "focus"]) element.addEventListener(type, prepareSelection, { capture: true, signal: lifetime.signal });
@@ -2041,6 +2085,14 @@ function mountDocument(document2, container, options = {}, onDispose) {
2041
2085
  }
2042
2086
  const view = {
2043
2087
  element,
2088
+ get selection() {
2089
+ return selection?.selection;
2090
+ },
2091
+ setSelection(value) {
2092
+ checkOpen();
2093
+ prepareSelection();
2094
+ selection.setSelection(value);
2095
+ },
2044
2096
  get disposed() {
2045
2097
  return disposed;
2046
2098
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onodocs/canvas",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "OnoDocs canvas viewer, selection and HTML attachments for prepared Word document pages.",
6
6
  "homepage": "https://onodocs.com/developers/",
@@ -15,7 +15,7 @@
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },
18
- "onodocsReleaseDate": "2026-10-07",
18
+ "onodocsReleaseDate": "2026-10-08",
19
19
  "onodocsEnvironment": "live",
20
20
  "type": "module",
21
21
  "sideEffects": false,
@@ -40,5 +40,5 @@
40
40
  "type": "git",
41
41
  "url": "https://github.com/onodocs/onodocs.git"
42
42
  },
43
- "gitHead": "69453bc7ca1f06f5ee022dd905765bf1d513e53a"
43
+ "gitHead": "cf082bcfda5fc7581332b4251fa27c39a9a66ec7"
44
44
  }
@@ -1,11 +1,13 @@
1
1
  /** Spec: pipeline.semantic.document-update */
2
2
  /** Spec: pipeline.sdk.presentation */
3
- import type { DocumentFragment, DocumentHit, DocumentPoint } from "../render-api/index.js";
3
+ import type { DocumentFragment, DocumentHit, DocumentPoint, DocumentSelection } from "../render-api/index.js";
4
4
  import type { DocumentPage, PageSize } from "./index.js";
5
5
  export interface ViewOptions {
6
6
  readonly zoom?: number | "fit-width";
7
7
  readonly gap?: number;
8
8
  readonly title?: string;
9
+ readonly input?: HTMLTextAreaElement;
10
+ readonly onSelectionChange?: (selection: DocumentSelection | undefined) => void;
9
11
  }
10
12
  export interface DocumentAttachment {
11
13
  readonly element: HTMLElement;
@@ -42,6 +44,8 @@ export interface DocumentView {
42
44
  point: DocumentPoint;
43
45
  }> | undefined;
44
46
  hitTest(point: DocumentPoint): DocumentHit | undefined;
47
+ readonly selection: DocumentSelection | undefined;
48
+ setSelection(selection: DocumentSelection): void;
45
49
  scrollTo(anchor: ViewAnchor, options?: ScrollIntoViewOptions): boolean;
46
50
  attach(element: HTMLElement, options: AttachmentOptions): DocumentAttachment;
47
51
  dispose(): void;
@@ -1,7 +1,9 @@
1
- import type { PageTextLine } from "../render-api/index.js";
1
+ import type { DocumentSelection, PageTextLine } from "../render-api/index.js";
2
2
  import type { DocumentPage } from "./index.js";
3
3
  /** Spec: pipeline.sdk.presentation */
4
- export declare function mountTextSelection(pageText: readonly PageTextLine[], viewport: HTMLElement, shells: readonly HTMLElement[], pageBounds: readonly DocumentPage["size"][], nearby: ReadonlySet<number>, signal: AbortSignal): {
4
+ export declare function mountTextSelection(pageText: readonly PageTextLine[], viewport: HTMLElement, shells: readonly HTMLElement[], pageBounds: readonly DocumentPage["size"][], nearby: ReadonlySet<number>, signal: AbortSignal, input?: HTMLTextAreaElement, onSelectionChange?: (selection: DocumentSelection | undefined) => void): {
5
5
  paint: () => void;
6
+ readonly selection: DocumentSelection | undefined;
7
+ setSelection(value: DocumentSelection): void;
6
8
  };
7
9
  //# sourceMappingURL=text-selection.d.ts.map
@@ -3,6 +3,14 @@ export interface DocumentPoint {
3
3
  readonly x: number;
4
4
  readonly y: number;
5
5
  }
6
+ export interface ParagraphPosition {
7
+ readonly paragraphId: string;
8
+ readonly offset: number;
9
+ }
10
+ export interface DocumentSelection {
11
+ readonly start: ParagraphPosition;
12
+ readonly end: ParagraphPosition;
13
+ }
6
14
  export interface PageRectangle extends DocumentPoint {
7
15
  readonly width: number;
8
16
  readonly height: number;
@@ -54,6 +62,7 @@ export interface PageTextLine {
54
62
  carets: readonly Readonly<{
55
63
  scalarOffset: number;
56
64
  x: number;
65
+ paragraphOffset?: number;
57
66
  }>[];
58
67
  }>;
59
68
  }
@@ -1,4 +1,4 @@
1
1
  export * from "./page-display-list.js";
2
2
  export * from "./page-source.js";
3
- export type { DocumentFragment, DocumentHit, DocumentPoint, PageTextLine, PageInteraction, FontFaceResource, ImageResourceData } from "../foundation/index.js";
3
+ export type { DocumentFragment, DocumentHit, DocumentPoint, DocumentSelection, ParagraphPosition, PageTextLine, PageInteraction, FontFaceResource, ImageResourceData } from "../foundation/index.js";
4
4
  //# sourceMappingURL=index.d.ts.map