@onodocs/canvas 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -4
- package/LICENSING.md +19 -3
- package/README.md +400 -256
- package/SDK.md +400 -256
- package/agreement-review.md +13 -0
- package/ai-report-demo.md +40 -0
- package/application-integration.md +89 -0
- package/document-collaboration.md +54 -0
- package/document-forms.md +84 -0
- package/document-modes.md +44 -0
- package/editor.md +39 -0
- package/index.js +214 -41
- package/package.json +12 -3
- package/proposal.md +36 -0
- package/templates.md +71 -0
- package/types/canvas/index.d.ts +3 -0
- package/types/canvas/presentation.d.ts +3 -0
- package/types/canvas/text-selection.d.ts +6 -0
- package/types/foundation/page-interaction.d.ts +17 -0
- package/types/render-api/page-display-list.d.ts +4 -0
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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(
|
|
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 &&
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1824
|
-
select(
|
|
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(
|
|
1966
|
+
select(ordered[0], false);
|
|
1831
1967
|
return;
|
|
1832
1968
|
}
|
|
1833
1969
|
let next;
|
|
1834
|
-
if (event.key === "
|
|
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
|
|
1840
|
-
const
|
|
1841
|
-
|
|
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 =
|
|
1844
|
-
while (
|
|
1845
|
-
next =
|
|
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 =
|
|
1851
|
-
while (
|
|
1852
|
-
const 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 (
|
|
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("
|
|
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,
|
|
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
|
-
|
|
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
|
},
|
|
@@ -2194,7 +2365,7 @@ function mountDocument(document2, container, options = {}, onDispose) {
|
|
|
2194
2365
|
const [anchorX, anchorY, elementX, elementY] = placementPoints[options2.placement ?? "overlay"];
|
|
2195
2366
|
const x = r.x + anchorX * r.width - elementX * size.width + (1 - 2 * anchorX) * inset + (anchorX - elementX) * gap2 + (options2.offset?.x ?? 0);
|
|
2196
2367
|
const y = r.y + anchorY * r.height - elementY * size.height + (1 - 2 * anchorY) * inset + (anchorY - elementY) * gap2 + (options2.offset?.y ?? 0);
|
|
2197
|
-
Object.assign(box.style, { position: "absolute", left: "0", top: "0", width: `${size.width * cssScale}px`, height: `${size.height * cssScale}px`, transformOrigin: "0 0", transform: `matrix(${t.a},${t.b},${t.c},${t.d},${(t.a * x + t.c * y + t.e) * cssScale},${(t.b * x + t.d * y + t.f) * cssScale})`, pointerEvents: "auto" });
|
|
2368
|
+
Object.assign(box.style, { position: "absolute", left: "0", top: "0", width: `${size.width * cssScale}px`, height: `${size.height * cssScale}px`, transformOrigin: "0 0", transform: `matrix(${t.a},${t.b},${t.c},${t.d},${(t.a * x + t.c * y + t.e) * cssScale},${(t.b * x + t.d * y + t.f) * cssScale})`, pointerEvents: options2.interactive === false ? "none" : "auto" });
|
|
2198
2369
|
attachedElements.get(node)?.dispose();
|
|
2199
2370
|
const oldStyle = node.getAttribute("style");
|
|
2200
2371
|
Object.assign(node.style, { width: "100%", height: "100%", boxSizing: "border-box", margin: "0" });
|
|
@@ -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
|
-
|
|
2422
|
-
|
|
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
|
+
"version": "0.5.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-
|
|
18
|
+
"onodocsReleaseDate": "2026-10-09",
|
|
19
19
|
"onodocsEnvironment": "live",
|
|
20
20
|
"type": "module",
|
|
21
21
|
"sideEffects": false,
|
|
@@ -33,6 +33,15 @@
|
|
|
33
33
|
"types",
|
|
34
34
|
"README.md",
|
|
35
35
|
"SDK.md",
|
|
36
|
+
"editor.md",
|
|
37
|
+
"templates.md",
|
|
38
|
+
"proposal.md",
|
|
39
|
+
"agreement-review.md",
|
|
40
|
+
"ai-report-demo.md",
|
|
41
|
+
"application-integration.md",
|
|
42
|
+
"document-forms.md",
|
|
43
|
+
"document-modes.md",
|
|
44
|
+
"document-collaboration.md",
|
|
36
45
|
"LICENSING.md",
|
|
37
46
|
"LICENSE"
|
|
38
47
|
],
|
|
@@ -40,5 +49,5 @@
|
|
|
40
49
|
"type": "git",
|
|
41
50
|
"url": "https://github.com/onodocs/onodocs.git"
|
|
42
51
|
},
|
|
43
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "9eab22dd958dbf90e88fe039a087684760034356"
|
|
44
53
|
}
|
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 Editor
|
|
15
|
+
|
|
16
|
+
```powershell
|
|
17
|
+
npm install @onodocs/editor esbuild
|
|
18
|
+
node node_modules/@onodocs/editor/examples/proposal/serve.mjs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The Editor package 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/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/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. Formatting uses JavaScript `Date` conversion and rejects invalid timestamps. It does not strictly validate calendar strings: for example, `2026-02-30` converts to 2 March 2026. Validate calendar dates in the host application if strict rejection is required. 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.
|