@onodocs/canvas 0.3.0 → 0.4.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/index.js CHANGED
@@ -80,6 +80,15 @@ function geometryPoint(point, transform) {
80
80
  }
81
81
  __name(geometryPoint, "geometryPoint");
82
82
  function hitTestPage(interaction, point) {
83
+ for (let index = interaction.areas.length - 1; index >= 0; index--) {
84
+ const area = interaction.areas[index];
85
+ if (area.kind !== "image") continue;
86
+ const local = inversePoint(point, area.fragment.transform);
87
+ if (local && insideRectangle(local, area.fragment.rectangle) && area.fragment.clips.every((clip) => {
88
+ const p = inversePoint(point, clip.transform);
89
+ return p !== void 0 && insideRectangle(p, clip.rectangle);
90
+ })) return area;
91
+ }
83
92
  for (let index = interaction.lines.length - 1; index >= 0; index--) {
84
93
  const line = interaction.lines[index], fragment = line.fragment;
85
94
  const local = inversePoint(point, fragment.transform);
@@ -1580,6 +1589,7 @@ __name(fillExactSectionColumnSeparator, "fillExactSectionColumnSeparator");
1580
1589
  var nextView = 0;
1581
1590
  function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, signal, input, onSelectionChange) {
1582
1591
  const document2 = viewport.ownerDocument;
1592
+ const activeElement = /* @__PURE__ */ __name(() => viewport.getRootNode().activeElement, "activeElement");
1583
1593
  const window = document2.defaultView;
1584
1594
  const svg = "http://www.w3.org/2000/svg";
1585
1595
  const viewId = nextView++;
@@ -1592,6 +1602,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1592
1602
  const pages = shells.map(() => []);
1593
1603
  const stops = [];
1594
1604
  const chunks = [];
1605
+ const paragraphRanges = /* @__PURE__ */ new Map();
1595
1606
  let length = 0;
1596
1607
  let previous;
1597
1608
  for (const item of lines) {
@@ -1615,11 +1626,32 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1615
1626
  pages[item.pageIndex].push(line);
1616
1627
  chunks.push(value.scalars.join(""));
1617
1628
  length = line.end;
1629
+ const range = paragraphRanges.get(value.paragraphId);
1630
+ if (range) range.end = length;
1631
+ else paragraphRanges.set(value.paragraphId, { start: line.start, end: length });
1618
1632
  previous = value;
1619
1633
  }
1620
1634
  const text = chunks.join("");
1621
1635
  const logical = [...stops].sort((a, b) => a.offset - b.offset || a.index - b.index);
1622
1636
  const logicalIndex = new Map(logical.map((stop, index) => [stop, index]));
1637
+ const editableLogical = logical.filter((stop) => stop.position);
1638
+ const editableStories = /* @__PURE__ */ new Map(), editableIndex = /* @__PURE__ */ new Map();
1639
+ for (const stop of editableLogical) {
1640
+ const id = stop.line.item.selection.storyId ?? "", story = editableStories.get(id) ?? [];
1641
+ editableIndex.set(stop, story.length);
1642
+ story.push(stop);
1643
+ editableStories.set(id, story);
1644
+ }
1645
+ function atOffset(offset, ordered = logical) {
1646
+ let first = 0, last = ordered.length;
1647
+ while (first < last) {
1648
+ const middle = first + last >>> 1;
1649
+ if (ordered[middle].offset < offset) first = middle + 1;
1650
+ else last = middle;
1651
+ }
1652
+ return ordered[first] ?? ordered.at(-1);
1653
+ }
1654
+ __name(atOffset, "atOffset");
1623
1655
  const layers = /* @__PURE__ */ new Map();
1624
1656
  function layerFor(index) {
1625
1657
  const existing = layers.get(index);
@@ -1633,39 +1665,80 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1633
1665
  return layer;
1634
1666
  }
1635
1667
  __name(layerFor, "layerFor");
1668
+ let overlays = [];
1636
1669
  let anchor, focus, pointer, preferredX;
1637
1670
  let frame = 0;
1638
1671
  let pointerPosition = { x: 0, y: 0 };
1639
1672
  let menu;
1673
+ let touch, touchTimer = 0, touchSelection = false;
1674
+ let draggedEndpoint;
1675
+ const handles = [document2.createElement("span"), document2.createElement("span")];
1676
+ for (const [index, handle] of handles.entries()) {
1677
+ handle.setAttribute("aria-hidden", "true");
1678
+ handle.dataset.selectionHandle = String(index);
1679
+ Object.assign(handle.style, { position: "absolute", width: "36px", height: "36px", padding: "10px", boxSizing: "border-box", zIndex: "3", touchAction: "none", display: "none" });
1680
+ const dot = document2.createElement("span");
1681
+ Object.assign(dot.style, { display: "block", width: "16px", height: "16px", borderRadius: "50%", background: "#1769c2" });
1682
+ handle.append(dot);
1683
+ handle.addEventListener("pointerdown", (event) => {
1684
+ event.preventDefault();
1685
+ event.stopPropagation();
1686
+ const first = anchor && focus && anchor.offset <= focus.offset ? "anchor" : "focus";
1687
+ draggedEndpoint = index === 0 ? first : first === "anchor" ? "focus" : "anchor";
1688
+ pointer = event.pointerId;
1689
+ pointerPosition = { x: event.clientX, y: event.clientY - 18 };
1690
+ viewport.setPointerCapture(pointer);
1691
+ frame = window.requestAnimationFrame(drag);
1692
+ }, { signal });
1693
+ viewport.append(handle);
1694
+ }
1640
1695
  viewport.tabIndex = 0;
1641
1696
  viewport.style.userSelect = "none";
1697
+ viewport.style.touchAction = "pan-x pan-y pinch-zoom";
1698
+ viewport.style.position = "relative";
1642
1699
  if (input) {
1643
1700
  viewport.style.position = "relative";
1644
1701
  viewport.append(input);
1645
1702
  }
1646
1703
  for (const shell of shells) shell.style.cursor = "text";
1647
1704
  function paint() {
1705
+ for (const handle of handles) handle.style.display = "none";
1648
1706
  for (const [index, layer] of layers) {
1649
1707
  layer.remove();
1650
1708
  layer.replaceChildren();
1651
1709
  if (!nearby.has(index)) layers.delete(index);
1652
1710
  }
1653
- if (!anchor || !focus || !input && document2.activeElement !== viewport && !menu?.contains(document2.activeElement)) return;
1711
+ const visible = anchor && focus && (input || activeElement() === viewport || menu?.contains(activeElement()));
1712
+ const labels = /* @__PURE__ */ new Map();
1713
+ for (let index = 0; index < overlays.length; index++) {
1714
+ const overlay = overlays[index], start = byParagraph.get(overlay.selection.start.paragraphId)?.get(overlay.selection.start.offset), end = byParagraph.get(overlay.selection.end.paragraphId)?.get(overlay.selection.end.offset);
1715
+ if (start && end) {
1716
+ const key = `${end.line.item.pageIndex}:${end.line.index}`, row = labels.get(key) ?? 0;
1717
+ labels.set(key, row + 1);
1718
+ paintRange(start, end, overlay, index, row);
1719
+ }
1720
+ }
1721
+ if (!visible) return;
1654
1722
  if (input) {
1655
1723
  const shell = shells[focus.line.item.pageIndex], bounds = shell.getBoundingClientRect(), origin = viewport.getBoundingClientRect();
1656
1724
  const point = focus.line.transform.transformPoint({ x: focus.x, y: focus.line.item.selection.top });
1657
1725
  const scale = bounds.width / pageBounds[focus.line.item.pageIndex].width;
1658
1726
  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
1727
  }
1660
- const start = Math.min(anchor.offset, focus.offset), end = Math.max(anchor.offset, focus.offset);
1728
+ paintRange(anchor, focus);
1729
+ }
1730
+ __name(paint, "paint");
1731
+ function paintRange(anchor2, focus2, overlay, overlayIndex = -1, labelRow = 0) {
1732
+ const start = Math.min(anchor2.offset, focus2.offset), end = Math.max(anchor2.offset, focus2.offset);
1661
1733
  for (const index of nearby) for (const line of pages[index] ?? []) {
1662
1734
  let rectangle2 = function(x, width, caret = false) {
1663
1735
  const rect = document2.createElementNS(svg, "rect");
1736
+ if (!overlay) rect.setAttribute("data-local-selection", "");
1664
1737
  rect.setAttribute("x", String(x));
1665
1738
  rect.setAttribute("y", String(value.top));
1666
1739
  rect.setAttribute("width", String(width));
1667
1740
  rect.setAttribute("height", String(value.height));
1668
- rect.setAttribute("fill", caret ? "#1769c2" : "#4285f4");
1741
+ rect.setAttribute("fill", overlay?.color ?? (caret ? "#1769c2" : "#4285f4"));
1669
1742
  rect.setAttribute("fill-opacity", caret ? "1" : "0.28");
1670
1743
  group.append(rect);
1671
1744
  };
@@ -1677,7 +1750,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1677
1750
  const group = document2.createElementNS(svg, "g");
1678
1751
  const transform = line.transform;
1679
1752
  if (transform) group.setAttribute("transform", `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`);
1680
- if (start === end && focus.line === line) rectangle2(focus.x, Number(pageBounds[line.item.pageIndex].width) / shells[line.item.pageIndex].getBoundingClientRect().width * 1.5, true);
1753
+ if (start === end && focus2.line === line) rectangle2(focus2.x, Number(pageBounds[line.item.pageIndex].width) / shells[line.item.pageIndex].getBoundingClientRect().width * 1.5, true);
1681
1754
  else {
1682
1755
  let left, right = 0;
1683
1756
  for (let index2 = 1; index2 < line.stops.length; index2++) {
@@ -1692,12 +1765,19 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1692
1765
  }
1693
1766
  if (left !== void 0) rectangle2(left, right - left);
1694
1767
  }
1768
+ if (overlay && focus2.line === line) {
1769
+ if (start !== end) rectangle2(focus2.x, Number(pageBounds[line.item.pageIndex].width) / shells[line.item.pageIndex].getBoundingClientRect().width * 1.5, true);
1770
+ const label = document2.createElementNS(svg, "text"), unit = Number(pageBounds[line.item.pageIndex].width) / shells[line.item.pageIndex].getBoundingClientRect().width;
1771
+ label.textContent = overlay.label;
1772
+ for (const [key, value2] of Object.entries({ x: String((line.stops.at(-1)?.x ?? focus2.x) + 8 * unit), y: String(line.item.selection.top + line.item.selection.height * 0.8 + labelRow * 14 * unit), fill: overlay.color, stroke: "white", "stroke-width": String(3 * unit), "paint-order": "stroke", "font-family": "Arial,sans-serif", "font-size": String(11 * unit), "font-weight": "600" })) label.setAttribute(key, value2);
1773
+ group.append(label);
1774
+ }
1695
1775
  let clipped = group;
1696
1776
  const clips = [...line.item.clip ? [{ rectangle: line.item.clip, transform: line.item.transform }] : [], ...line.item.physicalClips ?? []];
1697
1777
  for (let index2 = 0; index2 < clips.length; index2++) {
1698
1778
  const clip = clips[index2], rectangle3 = clip.rectangle, transform2 = matrix(clip.transform);
1699
1779
  const path = document2.createElementNS(svg, "clipPath");
1700
- path.id = `selection-${viewId}-${line.item.pageIndex}-${line.index}-${index2}`;
1780
+ path.id = `selection-${viewId}-${line.item.pageIndex}-${line.index}-${index2}-${overlayIndex}`;
1701
1781
  const polygon = document2.createElementNS(svg, "polygon");
1702
1782
  polygon.setAttribute("points", [[rectangle3.x, rectangle3.y], [rectangle3.x + rectangle3.width, rectangle3.y], [rectangle3.x + rectangle3.width, rectangle3.y + rectangle3.height], [rectangle3.x, rectangle3.y + rectangle3.height]].map(([x, y]) => {
1703
1783
  const point = transform2.transformPoint({ x, y });
@@ -1713,8 +1793,16 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1713
1793
  layer.append(clipped);
1714
1794
  shells[line.item.pageIndex].append(layer);
1715
1795
  }
1796
+ if (!overlay && touchSelection && anchor2.offset !== focus2.offset) {
1797
+ const endpoints = anchor2.offset <= focus2.offset ? [anchor2, focus2] : [focus2, anchor2], origin = viewport.getBoundingClientRect();
1798
+ for (const [index, stop] of endpoints.entries()) {
1799
+ const bounds = shells[stop.line.item.pageIndex].getBoundingClientRect(), size = pageBounds[stop.line.item.pageIndex];
1800
+ const point = stop.line.transform.transformPoint({ x: stop.x, y: stop.line.item.selection.top + stop.line.item.selection.height });
1801
+ Object.assign(handles[index].style, { display: "block", left: `${bounds.left - origin.left + point.x * bounds.width / size.width - 18}px`, top: `${bounds.top - origin.top + point.y * bounds.height / size.height - 10}px` });
1802
+ }
1803
+ }
1716
1804
  }
1717
- __name(paint, "paint");
1805
+ __name(paintRange, "paintRange");
1718
1806
  function nearest(clientX, clientY) {
1719
1807
  if (!logical.length) return;
1720
1808
  let result, bestY = Infinity, bestX = Infinity;
@@ -1754,12 +1842,12 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1754
1842
  }
1755
1843
  __name(nearest, "nearest");
1756
1844
  function select(stop, extend, scroll = false) {
1757
- if (!stop) return;
1845
+ if (!stop || input && !input.readOnly && !stop.position) return;
1758
1846
  focus = stop;
1759
1847
  if (!extend || !anchor) anchor = stop;
1760
1848
  paint();
1761
1849
  onSelectionChange?.(currentSelection());
1762
- if (input && document2.activeElement === viewport) input.focus({ preventScroll: true });
1850
+ if (input && activeElement() === viewport) input.focus({ preventScroll: true });
1763
1851
  if (scroll) {
1764
1852
  const shell = shells[stop.line.item.pageIndex];
1765
1853
  const point = stop.line.transform.transformPoint({ x: stop.x, y: stop.line.item.selection.top });
@@ -1778,12 +1866,49 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1778
1866
  if (pointer === void 0) return;
1779
1867
  const bounds = scroller?.getBoundingClientRect() ?? { top: 0, bottom: window.innerHeight };
1780
1868
  scrollElement.scrollTop += Math.min(30, Math.max(-30, pointerPosition.y < bounds.top + 20 ? pointerPosition.y - bounds.top - 20 : pointerPosition.y > bounds.bottom - 20 ? pointerPosition.y - bounds.bottom + 20 : 0));
1781
- select(nearest(pointerPosition.x, pointerPosition.y), true);
1869
+ dragTo(pointerPosition.x, pointerPosition.y);
1782
1870
  frame = window.requestAnimationFrame(drag);
1783
1871
  }
1784
1872
  __name(drag, "drag");
1873
+ function dragTo(x, y) {
1874
+ const stop = nearest(x, y);
1875
+ if (draggedEndpoint === "anchor" && stop) {
1876
+ anchor = stop;
1877
+ paint();
1878
+ onSelectionChange?.(currentSelection());
1879
+ } else select(stop, true);
1880
+ }
1881
+ __name(dragTo, "dragTo");
1882
+ function selectWord(x, y) {
1883
+ const stop = nearest(x, y);
1884
+ if (!stop) return;
1885
+ const range = paragraphRanges.get(stop.line.item.selection.paragraphId);
1886
+ const segment = [...new Intl.Segmenter(void 0, { granularity: "word" }).segment(text.slice(range.start, range.end))].find((value) => stop.offset >= range.start + value.index && stop.offset < range.start + value.index + value.segment.length);
1887
+ if (!segment) return;
1888
+ anchor = atOffset(range.start + segment.index);
1889
+ select(atOffset(range.start + segment.index + segment.segment.length), true);
1890
+ }
1891
+ __name(selectWord, "selectWord");
1785
1892
  viewport.addEventListener("pointerdown", (event) => {
1786
- if (event.button !== 0 || event.target.closest("button,input,textarea,select,a,[contenteditable], [data-onodocs-attachment]")) return;
1893
+ if (event.button !== 0 || event.target.closest("button,input,textarea,select,a,[contenteditable],[data-selection-handle],[data-onodocs-attachment]")) return;
1894
+ if (event.pointerType === "touch") {
1895
+ window.clearTimeout(touchTimer);
1896
+ if (!event.isPrimary) {
1897
+ touch = void 0;
1898
+ return;
1899
+ }
1900
+ touch = { id: event.pointerId, x: event.clientX, y: event.clientY, held: false };
1901
+ touchTimer = window.setTimeout(() => {
1902
+ if (touch) {
1903
+ touch.held = true;
1904
+ touchSelection = true;
1905
+ viewport.focus({ preventScroll: true });
1906
+ selectWord(touch.x, touch.y);
1907
+ }
1908
+ }, 500);
1909
+ return;
1910
+ }
1911
+ touchSelection = false;
1787
1912
  const stop = nearest(event.clientX, event.clientY);
1788
1913
  if (!stop) return;
1789
1914
  event.preventDefault();
@@ -1796,67 +1921,83 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1796
1921
  frame = window.requestAnimationFrame(drag);
1797
1922
  }, { signal });
1798
1923
  viewport.addEventListener("pointermove", (event) => {
1924
+ if (touch?.id === event.pointerId && Math.hypot(event.clientX - touch.x, event.clientY - touch.y) > 8) {
1925
+ window.clearTimeout(touchTimer);
1926
+ touch = void 0;
1927
+ }
1799
1928
  if (event.pointerId !== pointer) return;
1800
- pointerPosition = { x: event.clientX, y: event.clientY };
1801
- select(nearest(event.clientX, event.clientY), true);
1929
+ pointerPosition = { x: event.clientX, y: event.clientY - (draggedEndpoint ? 18 : 0) };
1930
+ dragTo(pointerPosition.x, pointerPosition.y);
1802
1931
  }, { signal });
1803
1932
  viewport.addEventListener("dblclick", (event) => {
1804
1933
  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
1934
  event.preventDefault();
1810
- anchor = logical.find((value) => value.offset >= segment.index);
1811
- select(logical.find((value) => value.offset >= segment.index + segment.segment.length), true);
1935
+ selectWord(event.clientX, event.clientY);
1812
1936
  }, { signal });
1813
1937
  for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) viewport.addEventListener(type, (event) => {
1938
+ if (event.pointerId === touch?.id) {
1939
+ window.clearTimeout(touchTimer);
1940
+ if (type === "pointerup" && !touch.held) {
1941
+ event.preventDefault();
1942
+ touchSelection = true;
1943
+ viewport.focus({ preventScroll: true });
1944
+ select(nearest(event.clientX, event.clientY), false);
1945
+ }
1946
+ touch = void 0;
1947
+ }
1814
1948
  if (event.pointerId !== pointer) return;
1815
1949
  pointer = void 0;
1950
+ draggedEndpoint = void 0;
1816
1951
  window.cancelAnimationFrame(frame);
1817
1952
  }, { signal });
1818
1953
  viewport.addEventListener("keydown", (event) => {
1819
- if (event.target !== viewport && event.target !== input || !logical.length || event.isComposing) return;
1954
+ const ordered = input && !input.readOnly ? editableStories.get(focus?.line.item.selection.storyId ?? "") ?? editableLogical : logical, orderedIndex = input && !input.readOnly ? editableIndex : logicalIndex;
1955
+ if (event.target !== viewport && event.target !== input || !ordered.length || event.isComposing) return;
1820
1956
  const command = event.ctrlKey || event.metaKey;
1821
1957
  if (command && event.key.toLowerCase() === "a") {
1822
1958
  event.preventDefault();
1823
- anchor = logical[0];
1824
- select(logical.at(-1), true);
1959
+ anchor = ordered[0];
1960
+ select(ordered.at(-1), true);
1825
1961
  return;
1826
1962
  }
1827
- if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) || event.altKey) return;
1963
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(event.key) || event.altKey) return;
1828
1964
  event.preventDefault();
1829
1965
  if (!focus) {
1830
- select(logical[0], false);
1966
+ select(ordered[0], false);
1831
1967
  return;
1832
1968
  }
1833
1969
  let next;
1834
- if (event.key === "Home" || event.key === "End") next = command ? event.key === "Home" ? logical[0] : logical.at(-1) : event.key === "Home" ? focus.line.stops[0] : focus.line.stops.at(-1);
1970
+ if (event.key === "PageUp" || event.key === "PageDown") {
1971
+ const bounds = shells[focus.line.item.pageIndex].getBoundingClientRect(), size = pageBounds[focus.line.item.pageIndex];
1972
+ const point = focus.line.transform.transformPoint({ x: focus.x, y: focus.line.item.selection.top });
1973
+ next = nearest(bounds.left + point.x * bounds.width / size.width, bounds.top + point.y * bounds.height / size.height + (event.key === "PageUp" ? -1 : 1) * (scroller?.clientHeight ?? window.innerHeight) * 0.8);
1974
+ } else if (event.key === "Home" || event.key === "End") next = command ? event.key === "Home" ? ordered[0] : ordered.at(-1) : event.key === "Home" ? focus.line.stops.find((stop) => !input || input.readOnly || stop.position) : focus.line.stops.filter((stop) => !input || input.readOnly || stop.position).at(-1);
1835
1975
  else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1836
1976
  const delta = event.key === "ArrowLeft" ? -1 : 1;
1837
1977
  if (!event.shiftKey && anchor.offset !== focus.offset) next = delta < 0 ? anchor.offset < focus.offset ? anchor : focus : anchor.offset > focus.offset ? anchor : focus;
1838
1978
  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);
1979
+ const range = paragraphRanges.get(focus.line.item.selection.paragraphId);
1980
+ const words = [...new Intl.Segmenter(void 0, { granularity: "word" }).segment(text.slice(range.start, range.end))].filter((value) => value.isWordLike).map((value) => range.start + value.index);
1981
+ const offset = delta < 0 ? words.filter((value) => value < focus.offset).at(-1) ?? Math.max(0, range.start - 1) : words.find((value) => value > focus.offset) ?? Math.min(text.length, range.end + 1);
1982
+ next = atOffset(offset, ordered);
1842
1983
  } else {
1843
- let index = logicalIndex.get(focus) + delta;
1844
- while (logical[index]?.offset === focus.offset) index += delta;
1845
- next = logical[Math.max(0, Math.min(logical.length - 1, index))];
1984
+ let index = orderedIndex.get(focus) + delta;
1985
+ while (ordered[index]?.offset === focus.offset) index += delta;
1986
+ next = ordered[Math.max(0, Math.min(ordered.length - 1, index))];
1846
1987
  }
1847
1988
  } else {
1848
1989
  preferredX ??= focus.x;
1849
1990
  const delta = event.key === "ArrowUp" ? -1 : 1;
1850
- let index = logicalIndex.get(focus) + delta;
1851
- while (logical[index]?.line === focus.line) index += delta;
1852
- const line = logical[index]?.line;
1991
+ let index = orderedIndex.get(focus) + delta;
1992
+ while (ordered[index]?.line === focus.line) index += delta;
1993
+ const line = ordered[index]?.line;
1853
1994
  next = line?.stops.reduce((best, stop) => Math.abs(stop.x - preferredX) < Math.abs(best.x - preferredX) ? stop : best);
1854
1995
  }
1855
1996
  if (event.key !== "ArrowUp" && event.key !== "ArrowDown") preferredX = void 0;
1856
1997
  select(next, event.shiftKey, true);
1857
1998
  }, { signal });
1858
1999
  document2.addEventListener("copy", (event) => {
1859
- if (document2.activeElement !== viewport && document2.activeElement !== input && !menu?.contains(document2.activeElement) || !focus || anchor.offset === focus.offset) return;
2000
+ if (activeElement() !== viewport && activeElement() !== input && !menu?.contains(activeElement()) || !focus || anchor.offset === focus.offset) return;
1860
2001
  event.clipboardData?.setData("text/plain", selectedText());
1861
2002
  event.preventDefault();
1862
2003
  }, { signal });
@@ -1918,7 +2059,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1918
2059
  menu.append(copy);
1919
2060
  document2.body.append(menu);
1920
2061
  menu.showPopover();
1921
- const caret = (focus ? layers.get(focus.line.item.pageIndex) : void 0)?.querySelector("rect")?.getBoundingClientRect() ?? viewport.getBoundingClientRect();
2062
+ const caret = (focus ? layers.get(focus.line.item.pageIndex) : void 0)?.querySelector("[data-local-selection]")?.getBoundingClientRect() ?? viewport.getBoundingClientRect();
1922
2063
  const x = event.clientX || caret.left, y = event.clientY || caret.bottom;
1923
2064
  const bounds = menu.getBoundingClientRect();
1924
2065
  menu.style.left = `${Math.max(0, Math.min(x, window.innerWidth - bounds.width))}px`;
@@ -1941,7 +2082,9 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1941
2082
  viewport.addEventListener("blur", paint, { signal });
1942
2083
  signal.addEventListener("abort", () => {
1943
2084
  menu?.remove();
2085
+ window.clearTimeout(touchTimer);
1944
2086
  window.cancelAnimationFrame(frame);
2087
+ for (const handle of handles) handle.remove();
1945
2088
  for (const layer of layers.values()) layer.remove();
1946
2089
  }, { once: true });
1947
2090
  function currentSelection() {
@@ -1955,7 +2098,10 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1955
2098
  if (!offsets) byParagraph.set(stop.position.paragraphId, offsets = /* @__PURE__ */ new Map());
1956
2099
  offsets.set(stop.position.offset, stop);
1957
2100
  }
1958
- return { paint, get selection() {
2101
+ return { paint, setSelectionOverlays(values) {
2102
+ overlays = values;
2103
+ paint();
2104
+ }, get selection() {
1959
2105
  return currentSelection();
1960
2106
  }, setSelection(value) {
1961
2107
  const start = byParagraph.get(value.start.paragraphId)?.get(value.start.offset), end = byParagraph.get(value.end.paragraphId)?.get(value.end.offset);
@@ -2030,6 +2176,7 @@ function mountDocument(document2, container, options = {}, onDispose) {
2030
2176
  Object.assign(prior.layer.style, { width: `${page.size.width * cssScale}px`, height: `${page.size.height * cssScale}px` });
2031
2177
  }
2032
2178
  prior.page = page;
2179
+ updateReading(prior.reading, page);
2033
2180
  prior.paintedWidth = 0;
2034
2181
  return prior;
2035
2182
  }
@@ -2037,17 +2184,35 @@ function mountDocument(document2, container, options = {}, onDispose) {
2037
2184
  shell.setAttribute("aria-label", `Page ${page.index + 1}`);
2038
2185
  Object.assign(shell.style, { position: "relative", flex: "none", background: "white" });
2039
2186
  const canvas = dom.createElement("canvas");
2187
+ canvas.setAttribute("aria-hidden", "true");
2040
2188
  canvas.width = 0;
2041
2189
  canvas.height = 0;
2042
2190
  Object.assign(canvas.style, { width: "100%", height: "100%", display: "block" });
2043
2191
  const layer = dom.createElement("div");
2044
2192
  Object.assign(layer.style, { position: "absolute", left: "0", top: "0", transformOrigin: "0 0", width: `${page.size.width * cssScale}px`, height: `${page.size.height * cssScale}px`, pointerEvents: "none" });
2045
- shell.append(canvas, layer);
2193
+ const reading = dom.createElement("div");
2194
+ Object.assign(reading.style, { position: "absolute", width: "1px", height: "1px", overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "pre-wrap" });
2195
+ updateReading(reading, page);
2196
+ shell.append(canvas, layer, reading);
2046
2197
  element.append(shell);
2047
- return { shell, canvas, layer, page, width: 0, paintedWidth: 0, pixelRatio: 0 };
2198
+ return { shell, canvas, layer, reading, page, width: 0, paintedWidth: 0, pixelRatio: 0 };
2048
2199
  });
2049
2200
  }
2050
2201
  __name(createShells, "createShells");
2202
+ function updateReading(element2, page) {
2203
+ const content = page.interaction.readingOrder;
2204
+ if (element2.childNodes.length === content.length && content.every((value, index) => element2.children[index].textContent === value.text && element2.children[index].getAttribute("aria-level") === (value.headingLevel ? String(value.headingLevel) : null))) return;
2205
+ element2.replaceChildren(...content.map((value) => {
2206
+ const paragraph = dom.createElement("p");
2207
+ paragraph.textContent = value.text;
2208
+ if (value.headingLevel) {
2209
+ paragraph.setAttribute("role", "heading");
2210
+ paragraph.setAttribute("aria-level", String(value.headingLevel));
2211
+ }
2212
+ return paragraph;
2213
+ }));
2214
+ }
2215
+ __name(updateReading, "updateReading");
2051
2216
  const observer = new window.ResizeObserver(resize);
2052
2217
  const visibility = new window.IntersectionObserver((entries) => {
2053
2218
  visibilityReady = true;
@@ -2093,6 +2258,12 @@ function mountDocument(document2, container, options = {}, onDispose) {
2093
2258
  prepareSelection();
2094
2259
  selection.setSelection(value);
2095
2260
  },
2261
+ setSelectionOverlays(values) {
2262
+ checkOpen();
2263
+ if (!values.length && !selection) return;
2264
+ prepareSelection();
2265
+ selection.setSelectionOverlays(values);
2266
+ },
2096
2267
  get disposed() {
2097
2268
  return disposed;
2098
2269
  },
@@ -2418,8 +2589,10 @@ function createDocument(source, options = {}) {
2418
2589
  canvas.height = Math.ceil(page.size.height * ratio);
2419
2590
  const context = canvas.getContext("2d");
2420
2591
  if (!context) throw new Error("Canvas 2D context is unavailable.");
2421
- context.fillStyle = "white";
2422
- context.fillRect(0, 0, canvas.width, canvas.height);
2592
+ if (options2.background !== "transparent") {
2593
+ context.fillStyle = "white";
2594
+ context.fillRect(0, 0, canvas.width, canvas.height);
2595
+ }
2423
2596
  context.scale(ratio, ratio);
2424
2597
  materializePageDisplayListToCanvas(data, context, images);
2425
2598
  if (data.evaluation) paintEvaluationWatermark(context, canvas.width, canvas.height);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onodocs/canvas",
3
- "version": "0.3.0",
3
+ "version": "0.4.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-08",
18
+ "onodocsReleaseDate": "2026-10-09",
19
19
  "onodocsEnvironment": "live",
20
20
  "type": "module",
21
21
  "sideEffects": false,
@@ -33,6 +33,14 @@
33
33
  "types",
34
34
  "README.md",
35
35
  "SDK.md",
36
+ "templates.md",
37
+ "proposal.md",
38
+ "agreement-review.md",
39
+ "ai-report-demo.md",
40
+ "application-integration.md",
41
+ "document-forms.md",
42
+ "document-modes.md",
43
+ "document-collaboration.md",
36
44
  "LICENSING.md",
37
45
  "LICENSE"
38
46
  ],
@@ -40,5 +48,5 @@
40
48
  "type": "git",
41
49
  "url": "https://github.com/onodocs/onodocs.git"
42
50
  },
43
- "gitHead": "cf082bcfda5fc7581332b4251fa27c39a9a66ec7"
51
+ "gitHead": "3227556064a15863db186152f40ee94cff0a7d9b"
44
52
  }
package/proposal.md ADDED
@@ -0,0 +1,36 @@
1
+ # Proposal studio
2
+
3
+ Run a complete proposal workflow with a fictional design studio and client. Change the business details, add or remove priced services, generate the Word document, refine its wording and download DOCX or searchable PDF. Everything runs in the browser; the local server serves the example assets.
4
+
5
+ ## Run from the repository
6
+
7
+ ```powershell
8
+ npm run build
9
+ npm run example:proposal
10
+ ```
11
+
12
+ Open `http://127.0.0.1:5196`. Use `PORT` to choose another local port.
13
+
14
+ ## Run from an installed SDK
15
+
16
+ ```powershell
17
+ npm install @onodocs/sdk esbuild
18
+ node node_modules/@onodocs/sdk/examples/proposal/serve.mjs
19
+ ```
20
+
21
+ The SDK includes the HTML, CSS, JavaScript and reusable `sample.docx`. Evaluation mode adds a watermark. For licensed use, pass your license key in the browser options supplied to both `openTemplate` and `createEditor` in `main.js`, following the SDK licensing guide.
22
+
23
+ ## Try the workflow
24
+
25
+ 1. Change the client and project overview. Adjust service days or rates, or add another service. The application calculates line amounts and the total in USD.
26
+ 2. Choose **Generate proposal**. The public browser template API repeats the table rows and updates supported fields, including footer page numbers.
27
+ 3. Choose **Edit wording**, click a passage and type. Formatting and undo use the standard editor. Return to **Preview** to review the document.
28
+ 4. Download Word or PDF. Both exports contain the current wording. Changing business inputs does not silently overwrite the document. Regeneration requires confirmation after wording edits.
29
+
30
+ The developer panel downloads the Word template and current business data. Open the template in the existing template designer to change its bindings. It contains scalar values and a repeating `items` table row. `tests/sdk/proposal-template.mjs` reproduces the sample template in the repository.
31
+
32
+ `examples/proposal/data.js` supplies fictional starting data and calculates amounts. `main.js` calls `openTemplate(...).generate(data)`, opens the returned bytes with `createEditor`, and delegates downloads to `editor.save()` and `editor.pdf()`. The sample keeps no drafts after a page refresh. Add application-owned persistence if your workflow needs saved proposals.
33
+
34
+ ## Validation
35
+
36
+ Run `npm run validate:proposal` for the complete browser journey. It changes business data, repeats and removes services, edits wording with the keyboard, checks regeneration confirmation, reopens the Word download and checks searchable PDF content. Desktop and mobile screenshots use manually reviewed references under `tests/sdk/proposal/out/`. The proposal DOCX also has a LibreOffice comparison under `tests/parity/layout/proposal-demo/`.
package/templates.md ADDED
@@ -0,0 +1,71 @@
1
+ # Word templates and document generation
2
+
3
+ Use `createTemplateEditor` from `@onodocs/sdk/editor` to design reusable DOCX templates. Run `npm run example:templates` in the source workspace for the invoice example at localhost port 5193. The designer uses the ordinary Word editor for layout, formatting and generated-document adjustments.
4
+
5
+ For a complete business-data-to-export journey, run `npm run example:proposal`. The [proposal studio guide](proposal.md) covers setup, repeating fee rows, wording edits and Word/PDF downloads.
6
+
7
+ ```ts
8
+ import { createTemplateEditor } from "@onodocs/sdk/editor";
9
+
10
+ const designer = createTemplateEditor({
11
+ container: document.querySelector("#templates"),
12
+ sampleData: { customer: "Willow Design", items: [{ name: "Review", price: 125 }] },
13
+ });
14
+ await designer.open(templateBytes);
15
+ ```
16
+
17
+ Select a placeholder and choose Text field, a data field and Apply binding. Selected text keeps the rest of the sentence intact. Use Selected paragraphs for whole paragraphs, Table row for repeating line items, or Whole table for a conditional table. Define an outer repeat or condition before adding fields inside it. Array-item choices in the data selector resolve against the current repeated item. Number, currency and date fields have format controls; image fields use point dimensions and supplied PNG/JPEG data. Existing bindings can be selected, updated or removed. Removing a binding preserves its placeholder content.
18
+
19
+ Reusable sections have a name and content. Define a section once, then choose Insert reusable section at each insertion point. Each insertion can select a different subrecord. Definitions do not appear in generated output. Sections are reused within their defining Word story part; cross-part insertion is not supported because package relationships belong to their source part. Section content can contain its own fields, repeats and conditions.
20
+
21
+ Preview document opens generated content in the editor. Adjust it with the ordinary editing controls and download Word or PDF. Back to template restores the design without incorporating those preview adjustments. Download template always saves the design. A JSON array in Sample data enables Generate batch, which downloads one DOCX for each record; browsers may ask to allow multiple downloads. The SDK batch iterator gives applications control over filenames and storage.
22
+
23
+ For generation in Node or a browser, use the default SDK entry. The browser entry exports the same template API.
24
+
25
+ ```ts
26
+ import { openTemplate } from "@onodocs/sdk";
27
+
28
+ const template = await openTemplate(templateBytes, { licenseKey });
29
+ try {
30
+ const documentBytes = await template.generate(data, { locale: "en-US", signal });
31
+ for await (const bytes of template.batch(records, { locale: "en-US", signal })) {
32
+ await storeDocument(bytes);
33
+ }
34
+ } finally {
35
+ template.dispose();
36
+ }
37
+ ```
38
+
39
+ Generation starts from the original source each time. It neither edits the source template nor calls external services. Open the result through the existing Node/browser SDK or server renderer to query, render, edit or export it. Failed and cancelled generations leave the template available for another attempt. Consume batch results incrementally to avoid retaining a whole batch in memory.
40
+
41
+ Bindings are stored in ordinary Word content-control tags. Use `templateTag(binding)` with the editor's `template` command for programmatic authoring:
42
+
43
+ ```ts
44
+ import { templateTag } from "@onodocs/sdk";
45
+
46
+ await doc.edit({
47
+ kind: "template",
48
+ scope: "selection",
49
+ selection,
50
+ title: "Customer name",
51
+ tag: templateTag({ kind: "value", path: ["customer", "name"] }),
52
+ });
53
+ ```
54
+
55
+ The command supports `selection`, `paragraph`, `row` and `table` scopes. Inline selection stays within one paragraph and uses ordinary adjacent text runs; it cannot cross existing controls or marks. A collapsed selection inserts a placeholder using the title. `action: "update"` changes the selected control's tag and title; `action: "remove"` unwraps it. Supply `controlId` when choosing a specific control from `doc.query.contentControls()`. Use `editor.execute` for changes that must participate in the component's undo history.
56
+
57
+ | Binding | Required values | Result |
58
+ | --- | --- | --- |
59
+ | `value` | `path` | String, finite number, boolean or null; null becomes empty text |
60
+ | `value` with `format` | `path`, `{ kind: "number", options }` or `{ kind: "date", options }` | `Intl.NumberFormat` or `Intl.DateTimeFormat` output |
61
+ | `image` | `path`, `width`, `height` | PNG/JPEG from `{ bytes: Uint8Array, mediaType }`; optional `description` |
62
+ | `repeat` | `path` | One copy for each array item; descendants use that item as their context |
63
+ | `if` | `path` | Included for truthy values or nonempty arrays |
64
+ | `section` | `name` | Reusable content definition, omitted from output |
65
+ | `include` | `name`, optional `path` | Section inserted with the current context or selected subrecord |
66
+
67
+ Paths are arrays of own-property names, not executable expressions. An empty path selects the current record. Missing data, wrong value types and recursive sections report an error. Currency options are ordinary `Intl.NumberFormatOptions`, for example `{ style: "currency", currency: "USD" }`. Date values can be a `Date`, timestamp or ISO date string; use an explicit offset for timestamps. The default locale is `en-US` and the default date time zone is UTC.
68
+
69
+ Formatting comes from the first run of a text placeholder. Unrelated Word content and package resources are preserved. Generation refreshes supported sequence and bookmark-reference fields. The browser template API also lays out the generated document to refresh page numbers, page references and supported tables of contents. Node callers can supply a `finalize` callback that opens the generated bytes with the server renderer and returns `document.save(options)`.
70
+
71
+ Unsupported fields retain their cached content. Use `onFieldStatus` to receive diagnostics, or `fields: "require-current"` to reject output containing unresolved fields. The default reports retained caches and completes generation. Locked fields retain their authored values. `fields: "preserve"` explicitly keeps all cached results. Existing signed-package, document-protection and editing restrictions still apply. Native DOTX/DOTM generation is not supported; save the template as DOCX. The reusable source is still a Word document and can be saved and opened by Word.
@@ -1,3 +1,4 @@
1
+ /** Spec: pipeline.sdk.pdf-export */
1
2
  /** Spec: pipeline.sdk.page-source */
2
3
  import { type PageInteraction } from "../foundation/index.js";
3
4
  import type { DocumentSource, PageSize } from "../render-api/index.js";
@@ -5,6 +6,7 @@ import type { DocumentView, ViewOptions } from "./presentation.js";
5
6
  export { materializePageDisplayListToCanvas } from "./canvas-materializer.js";
6
7
  export { encode, decode } from "./transport.js";
7
8
  export type * from "../render-api/index.js";
9
+ export type { SelectionOverlay } from "./text-selection.js";
8
10
  export type { DocumentView, ViewOptions, ViewAnchor, DocumentAttachment, AttachmentOptions, AttachmentPlacement } from "./presentation.js";
9
11
  export interface DocumentPage {
10
12
  readonly index: number;
@@ -12,6 +14,7 @@ export interface DocumentPage {
12
14
  readonly interaction: PageInteraction;
13
15
  render(canvas: HTMLCanvasElement, options?: Readonly<{
14
16
  dpi?: number;
17
+ background?: "white" | "transparent";
15
18
  signal?: AbortSignal;
16
19
  }>): Promise<void>;
17
20
  }