@onodocs/canvas 0.2.1 → 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);
@@ -1578,8 +1587,9 @@ __name(fillExactSectionColumnSeparator, "fillExactSectionColumnSeparator");
1578
1587
 
1579
1588
  // packages/canvas/src/text-selection.ts
1580
1589
  var nextView = 0;
1581
- function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, signal) {
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) {
@@ -1607,7 +1618,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1607
1618
  for (const caret of value.carets) {
1608
1619
  const offset = offsets[caret.scalarOffset - value.scalarStart];
1609
1620
  if (offset === void 0) continue;
1610
- const stop = { line, offset: length + offset, x: caret.x, index: stops.length };
1621
+ const stop = { line, offset: length + offset, x: caret.x, index: stops.length, ...caret.paragraphOffset === void 0 ? {} : { position: { paragraphId: value.paragraphId, offset: caret.paragraphOffset } } };
1611
1622
  line.stops.push(stop);
1612
1623
  stops.push(stop);
1613
1624
  }
@@ -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,29 +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";
1699
+ if (input) {
1700
+ viewport.style.position = "relative";
1701
+ viewport.append(input);
1702
+ }
1642
1703
  for (const shell of shells) shell.style.cursor = "text";
1643
1704
  function paint() {
1705
+ for (const handle of handles) handle.style.display = "none";
1644
1706
  for (const [index, layer] of layers) {
1645
1707
  layer.remove();
1646
1708
  layer.replaceChildren();
1647
1709
  if (!nearby.has(index)) layers.delete(index);
1648
1710
  }
1649
- if (!anchor || !focus || document2.activeElement !== viewport && !menu?.contains(document2.activeElement)) return;
1650
- const start = Math.min(anchor.offset, focus.offset), end = Math.max(anchor.offset, focus.offset);
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;
1722
+ if (input) {
1723
+ const shell = shells[focus.line.item.pageIndex], bounds = shell.getBoundingClientRect(), origin = viewport.getBoundingClientRect();
1724
+ const point = focus.line.transform.transformPoint({ x: focus.x, y: focus.line.item.selection.top });
1725
+ const scale = bounds.width / pageBounds[focus.line.item.pageIndex].width;
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` });
1727
+ }
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);
1651
1733
  for (const index of nearby) for (const line of pages[index] ?? []) {
1652
1734
  let rectangle2 = function(x, width, caret = false) {
1653
1735
  const rect = document2.createElementNS(svg, "rect");
1736
+ if (!overlay) rect.setAttribute("data-local-selection", "");
1654
1737
  rect.setAttribute("x", String(x));
1655
1738
  rect.setAttribute("y", String(value.top));
1656
1739
  rect.setAttribute("width", String(width));
1657
1740
  rect.setAttribute("height", String(value.height));
1658
- rect.setAttribute("fill", caret ? "#1769c2" : "#4285f4");
1741
+ rect.setAttribute("fill", overlay?.color ?? (caret ? "#1769c2" : "#4285f4"));
1659
1742
  rect.setAttribute("fill-opacity", caret ? "1" : "0.28");
1660
1743
  group.append(rect);
1661
1744
  };
@@ -1667,7 +1750,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1667
1750
  const group = document2.createElementNS(svg, "g");
1668
1751
  const transform = line.transform;
1669
1752
  if (transform) group.setAttribute("transform", `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`);
1670
- 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);
1671
1754
  else {
1672
1755
  let left, right = 0;
1673
1756
  for (let index2 = 1; index2 < line.stops.length; index2++) {
@@ -1682,12 +1765,19 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1682
1765
  }
1683
1766
  if (left !== void 0) rectangle2(left, right - left);
1684
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
+ }
1685
1775
  let clipped = group;
1686
1776
  const clips = [...line.item.clip ? [{ rectangle: line.item.clip, transform: line.item.transform }] : [], ...line.item.physicalClips ?? []];
1687
1777
  for (let index2 = 0; index2 < clips.length; index2++) {
1688
1778
  const clip = clips[index2], rectangle3 = clip.rectangle, transform2 = matrix(clip.transform);
1689
1779
  const path = document2.createElementNS(svg, "clipPath");
1690
- path.id = `selection-${viewId}-${line.item.pageIndex}-${line.index}-${index2}`;
1780
+ path.id = `selection-${viewId}-${line.item.pageIndex}-${line.index}-${index2}-${overlayIndex}`;
1691
1781
  const polygon = document2.createElementNS(svg, "polygon");
1692
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]) => {
1693
1783
  const point = transform2.transformPoint({ x, y });
@@ -1703,11 +1793,19 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1703
1793
  layer.append(clipped);
1704
1794
  shells[line.item.pageIndex].append(layer);
1705
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
+ }
1706
1804
  }
1707
- __name(paint, "paint");
1805
+ __name(paintRange, "paintRange");
1708
1806
  function nearest(clientX, clientY) {
1709
1807
  if (!logical.length) return;
1710
- let result, best = Infinity;
1808
+ let result, bestY = Infinity, bestX = Infinity;
1711
1809
  let first = 0, last = shells.length - 1;
1712
1810
  while (first < last) {
1713
1811
  const middle = first + last >>> 1;
@@ -1732,9 +1830,9 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1732
1830
  const dy = Math.max(value.top - y, 0, y - value.top - value.height) * box.height / height;
1733
1831
  for (const stop of line.stops) {
1734
1832
  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;
1833
+ if (dy < bestY || dy === bestY && dx < bestX) {
1834
+ bestY = dy;
1835
+ bestX = dx;
1738
1836
  result = stop;
1739
1837
  }
1740
1838
  }
@@ -1744,10 +1842,12 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1744
1842
  }
1745
1843
  __name(nearest, "nearest");
1746
1844
  function select(stop, extend, scroll = false) {
1747
- if (!stop) return;
1845
+ if (!stop || input && !input.readOnly && !stop.position) return;
1748
1846
  focus = stop;
1749
1847
  if (!extend || !anchor) anchor = stop;
1750
1848
  paint();
1849
+ onSelectionChange?.(currentSelection());
1850
+ if (input && activeElement() === viewport) input.focus({ preventScroll: true });
1751
1851
  if (scroll) {
1752
1852
  const shell = shells[stop.line.item.pageIndex];
1753
1853
  const point = stop.line.transform.transformPoint({ x: stop.x, y: stop.line.item.selection.top });
@@ -1766,12 +1866,49 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1766
1866
  if (pointer === void 0) return;
1767
1867
  const bounds = scroller?.getBoundingClientRect() ?? { top: 0, bottom: window.innerHeight };
1768
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));
1769
- select(nearest(pointerPosition.x, pointerPosition.y), true);
1869
+ dragTo(pointerPosition.x, pointerPosition.y);
1770
1870
  frame = window.requestAnimationFrame(drag);
1771
1871
  }
1772
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");
1773
1892
  viewport.addEventListener("pointerdown", (event) => {
1774
- 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;
1775
1912
  const stop = nearest(event.clientX, event.clientY);
1776
1913
  if (!stop) return;
1777
1914
  event.preventDefault();
@@ -1784,53 +1921,83 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1784
1921
  frame = window.requestAnimationFrame(drag);
1785
1922
  }, { signal });
1786
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
+ }
1787
1928
  if (event.pointerId !== pointer) return;
1788
- pointerPosition = { x: event.clientX, y: event.clientY };
1789
- select(nearest(event.clientX, event.clientY), true);
1929
+ pointerPosition = { x: event.clientX, y: event.clientY - (draggedEndpoint ? 18 : 0) };
1930
+ dragTo(pointerPosition.x, pointerPosition.y);
1931
+ }, { signal });
1932
+ viewport.addEventListener("dblclick", (event) => {
1933
+ if (event.target.closest("button,input,textarea,select,a,[data-onodocs-attachment]")) return;
1934
+ event.preventDefault();
1935
+ selectWord(event.clientX, event.clientY);
1790
1936
  }, { signal });
1791
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
+ }
1792
1948
  if (event.pointerId !== pointer) return;
1793
1949
  pointer = void 0;
1950
+ draggedEndpoint = void 0;
1794
1951
  window.cancelAnimationFrame(frame);
1795
1952
  }, { signal });
1796
1953
  viewport.addEventListener("keydown", (event) => {
1797
- if (event.target !== viewport || !logical.length) 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;
1798
1956
  const command = event.ctrlKey || event.metaKey;
1799
1957
  if (command && event.key.toLowerCase() === "a") {
1800
1958
  event.preventDefault();
1801
- anchor = logical[0];
1802
- select(logical.at(-1), true);
1959
+ anchor = ordered[0];
1960
+ select(ordered.at(-1), true);
1803
1961
  return;
1804
1962
  }
1805
- 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;
1806
1964
  event.preventDefault();
1807
1965
  if (!focus) {
1808
- select(logical[0], false);
1966
+ select(ordered[0], false);
1809
1967
  return;
1810
1968
  }
1811
1969
  let next;
1812
- 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);
1813
1975
  else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1814
1976
  const delta = event.key === "ArrowLeft" ? -1 : 1;
1815
1977
  if (!event.shiftKey && anchor.offset !== focus.offset) next = delta < 0 ? anchor.offset < focus.offset ? anchor : focus : anchor.offset > focus.offset ? anchor : focus;
1816
- else {
1817
- let index = logicalIndex.get(focus) + delta;
1818
- while (logical[index]?.offset === focus.offset) index += delta;
1819
- next = logical[Math.max(0, Math.min(logical.length - 1, index))];
1978
+ else if (command) {
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);
1983
+ } else {
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))];
1820
1987
  }
1821
1988
  } else {
1822
1989
  preferredX ??= focus.x;
1823
1990
  const delta = event.key === "ArrowUp" ? -1 : 1;
1824
- let index = logicalIndex.get(focus) + delta;
1825
- while (logical[index]?.line === focus.line) index += delta;
1826
- 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;
1827
1994
  next = line?.stops.reduce((best, stop) => Math.abs(stop.x - preferredX) < Math.abs(best.x - preferredX) ? stop : best);
1828
1995
  }
1829
1996
  if (event.key !== "ArrowUp" && event.key !== "ArrowDown") preferredX = void 0;
1830
1997
  select(next, event.shiftKey, true);
1831
1998
  }, { signal });
1832
1999
  document2.addEventListener("copy", (event) => {
1833
- if (document2.activeElement !== viewport && !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;
1834
2001
  event.clipboardData?.setData("text/plain", selectedText());
1835
2002
  event.preventDefault();
1836
2003
  }, { signal });
@@ -1892,7 +2059,7 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1892
2059
  menu.append(copy);
1893
2060
  document2.body.append(menu);
1894
2061
  menu.showPopover();
1895
- 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();
1896
2063
  const x = event.clientX || caret.left, y = event.clientY || caret.bottom;
1897
2064
  const bounds = menu.getBoundingClientRect();
1898
2065
  menu.style.left = `${Math.max(0, Math.min(x, window.innerWidth - bounds.width))}px`;
@@ -1915,10 +2082,33 @@ function mountTextSelection(pageText, viewport, shells, pageBounds, nearby, sign
1915
2082
  viewport.addEventListener("blur", paint, { signal });
1916
2083
  signal.addEventListener("abort", () => {
1917
2084
  menu?.remove();
2085
+ window.clearTimeout(touchTimer);
1918
2086
  window.cancelAnimationFrame(frame);
2087
+ for (const handle of handles) handle.remove();
1919
2088
  for (const layer of layers.values()) layer.remove();
1920
2089
  }, { once: true });
1921
- return { paint };
2090
+ function currentSelection() {
2091
+ if (!anchor?.position || !focus?.position) return;
2092
+ return anchor.offset <= focus.offset ? { start: anchor.position, end: focus.position } : { start: focus.position, end: anchor.position };
2093
+ }
2094
+ __name(currentSelection, "currentSelection");
2095
+ const byParagraph = /* @__PURE__ */ new Map();
2096
+ for (const stop of logical) if (stop.position) {
2097
+ let offsets = byParagraph.get(stop.position.paragraphId);
2098
+ if (!offsets) byParagraph.set(stop.position.paragraphId, offsets = /* @__PURE__ */ new Map());
2099
+ offsets.set(stop.position.offset, stop);
2100
+ }
2101
+ return { paint, setSelectionOverlays(values) {
2102
+ overlays = values;
2103
+ paint();
2104
+ }, get selection() {
2105
+ return currentSelection();
2106
+ }, setSelection(value) {
2107
+ const start = byParagraph.get(value.start.paragraphId)?.get(value.start.offset), end = byParagraph.get(value.end.paragraphId)?.get(value.end.offset);
2108
+ if (!start || !end) return;
2109
+ anchor = start;
2110
+ select(end, true, true);
2111
+ } };
1922
2112
  }
1923
2113
  __name(mountTextSelection, "mountTextSelection");
1924
2114
 
@@ -1965,10 +2155,10 @@ function mountDocument(document2, container, options = {}, onDispose) {
1965
2155
  let selection;
1966
2156
  let selectionLifetime;
1967
2157
  function prepareSelection(event) {
1968
- if (event.target.closest("[data-onodocs-attachment]")) return;
2158
+ if (event?.target?.closest("[data-onodocs-attachment]")) return;
1969
2159
  if (selection) return;
1970
2160
  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]));
2161
+ 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
2162
  }
1973
2163
  __name(prepareSelection, "prepareSelection");
1974
2164
  for (const type of ["pointerdown", "keydown", "contextmenu", "focus"]) element.addEventListener(type, prepareSelection, { capture: true, signal: lifetime.signal });
@@ -1986,6 +2176,7 @@ function mountDocument(document2, container, options = {}, onDispose) {
1986
2176
  Object.assign(prior.layer.style, { width: `${page.size.width * cssScale}px`, height: `${page.size.height * cssScale}px` });
1987
2177
  }
1988
2178
  prior.page = page;
2179
+ updateReading(prior.reading, page);
1989
2180
  prior.paintedWidth = 0;
1990
2181
  return prior;
1991
2182
  }
@@ -1993,17 +2184,35 @@ function mountDocument(document2, container, options = {}, onDispose) {
1993
2184
  shell.setAttribute("aria-label", `Page ${page.index + 1}`);
1994
2185
  Object.assign(shell.style, { position: "relative", flex: "none", background: "white" });
1995
2186
  const canvas = dom.createElement("canvas");
2187
+ canvas.setAttribute("aria-hidden", "true");
1996
2188
  canvas.width = 0;
1997
2189
  canvas.height = 0;
1998
2190
  Object.assign(canvas.style, { width: "100%", height: "100%", display: "block" });
1999
2191
  const layer = dom.createElement("div");
2000
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" });
2001
- 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);
2002
2197
  element.append(shell);
2003
- return { shell, canvas, layer, page, width: 0, paintedWidth: 0, pixelRatio: 0 };
2198
+ return { shell, canvas, layer, reading, page, width: 0, paintedWidth: 0, pixelRatio: 0 };
2004
2199
  });
2005
2200
  }
2006
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");
2007
2216
  const observer = new window.ResizeObserver(resize);
2008
2217
  const visibility = new window.IntersectionObserver((entries) => {
2009
2218
  visibilityReady = true;
@@ -2041,6 +2250,20 @@ function mountDocument(document2, container, options = {}, onDispose) {
2041
2250
  }
2042
2251
  const view = {
2043
2252
  element,
2253
+ get selection() {
2254
+ return selection?.selection;
2255
+ },
2256
+ setSelection(value) {
2257
+ checkOpen();
2258
+ prepareSelection();
2259
+ selection.setSelection(value);
2260
+ },
2261
+ setSelectionOverlays(values) {
2262
+ checkOpen();
2263
+ if (!values.length && !selection) return;
2264
+ prepareSelection();
2265
+ selection.setSelectionOverlays(values);
2266
+ },
2044
2267
  get disposed() {
2045
2268
  return disposed;
2046
2269
  },
@@ -2366,8 +2589,10 @@ function createDocument(source, options = {}) {
2366
2589
  canvas.height = Math.ceil(page.size.height * ratio);
2367
2590
  const context = canvas.getContext("2d");
2368
2591
  if (!context) throw new Error("Canvas 2D context is unavailable.");
2369
- context.fillStyle = "white";
2370
- 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
+ }
2371
2596
  context.scale(ratio, ratio);
2372
2597
  materializePageDisplayListToCanvas(data, context, images);
2373
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.2.1",
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-07",
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": "69453bc7ca1f06f5ee022dd905765bf1d513e53a"
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/`.