@openpresentation/opf-editor 0.10.6 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// The in-canvas crop tool (RR-25): the DOM over the image-crop model. Selecting a picture on the canvas shows a "Crop picture"
|
|
2
|
+
// button; it opens a crop layer over the slide with the whole picture, a crop rectangle with eight drag handles, an
|
|
3
|
+
// aspect lock (Free, Original, the frame's own shape, common ratios; Shift while dragging keeps the current ratio), a focal
|
|
4
|
+
// point tool for the frame's shape with a zoom, exact numbers, Reset, Restore original, Cancel and Apply. Apply is one
|
|
5
|
+
// undoable change (see image-crop.js for what it writes). Pointer events with capture serve mouse, pen and touch (the
|
|
6
|
+
// handles have 44px touch targets on a touch screen); the rectangle takes arrow keys (move; Shift resizes; +/- scale),
|
|
7
|
+
// Enter applies and Esc cancels. On a phone or tablet the layer fills the screen. Importing this module does not need a DOM.
|
|
8
|
+
import {
|
|
9
|
+
CROP_ASPECTS,
|
|
10
|
+
MIN_CROP_SIDE,
|
|
11
|
+
applyCrop,
|
|
12
|
+
aspectRatioFor,
|
|
13
|
+
clampRect,
|
|
14
|
+
describeImage,
|
|
15
|
+
fitAspect,
|
|
16
|
+
focalPointOf,
|
|
17
|
+
focalWindow,
|
|
18
|
+
fullRect,
|
|
19
|
+
isFullRect,
|
|
20
|
+
loadImagePixels,
|
|
21
|
+
moveRect,
|
|
22
|
+
prepareRestore,
|
|
23
|
+
resizeRect,
|
|
24
|
+
restoreOriginal,
|
|
25
|
+
roundRect,
|
|
26
|
+
} from "./image-crop.js";
|
|
27
|
+
|
|
28
|
+
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|
29
|
+
const STYLE_ID = "opf-crop-style";
|
|
30
|
+
const CSS = `
|
|
31
|
+
.opf-crop-pill{position:absolute;pointer-events:auto;z-index:3;min-height:32px;padding:5px 12px;font:600 12px/1.2 system-ui,sans-serif;color:#fff;background:#4b3fb4;border:1px solid #fff;border-radius:16px;box-shadow:0 2px 8px #0004;cursor:pointer}
|
|
32
|
+
.opf-crop-pill:focus-visible{outline:3px solid #ffd45a;outline-offset:2px}
|
|
33
|
+
.opf-crop-pill[hidden]{display:none}
|
|
34
|
+
.opf-crop{position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;background:rgba(22,20,31,.94);color:#f4f2fa;font:13px/1.45 system-ui,sans-serif;pointer-events:auto;touch-action:none;overflow:hidden}
|
|
35
|
+
.opf-crop[data-fullscreen]{position:fixed;z-index:2147483000}
|
|
36
|
+
.opf-crop *{box-sizing:border-box}
|
|
37
|
+
.opf-crop-stage{position:relative;overflow:hidden;flex:1 1 auto;min-height:120px;display:flex;align-items:center;justify-content:center;padding:12px}
|
|
38
|
+
.opf-crop-box{position:relative;flex:none;touch-action:none;user-select:none;-webkit-user-select:none;overflow:visible}
|
|
39
|
+
.opf-crop-box>img{position:absolute;inset:0;width:100%;height:100%;display:block;user-select:none;-webkit-user-drag:none;pointer-events:none}
|
|
40
|
+
.opf-crop-rect{position:absolute;box-shadow:0 0 0 9999px rgba(8,6,16,.62);outline:2px solid #fff;cursor:move;touch-action:none;background-image:linear-gradient(to right,transparent calc(33.33% - .5px),#fff6 calc(33.33% - .5px),#fff6 calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),#fff6 calc(66.66% - .5px),#fff6 calc(66.66% + .5px),transparent calc(66.66% + .5px)),linear-gradient(to bottom,transparent calc(33.33% - .5px),#fff6 calc(33.33% - .5px),#fff6 calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),#fff6 calc(66.66% - .5px),#fff6 calc(66.66% + .5px),transparent calc(66.66% + .5px))}
|
|
41
|
+
.opf-crop-rect:focus-visible{outline:3px solid #ffd45a;outline-offset:1px}
|
|
42
|
+
.opf-crop-handle{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;background:#fff;border:2px solid #4b3fb4;border-radius:3px;touch-action:none}
|
|
43
|
+
.opf-crop-handle::after{content:"";position:absolute;inset:-12px}
|
|
44
|
+
.opf-crop-handle[data-handle=nw]{left:0;top:0}.opf-crop-handle[data-handle=n]{left:50%;top:0}.opf-crop-handle[data-handle=ne]{left:100%;top:0}.opf-crop-handle[data-handle=e]{left:100%;top:50%}.opf-crop-handle[data-handle=se]{left:100%;top:100%}.opf-crop-handle[data-handle=s]{left:50%;top:100%}.opf-crop-handle[data-handle=sw]{left:0;top:100%}.opf-crop-handle[data-handle=w]{left:0;top:50%}
|
|
45
|
+
@media (pointer:coarse){.opf-crop-handle{width:22px;height:22px;margin:-11px 0 0 -11px}.opf-crop-handle::after{inset:-11px}}
|
|
46
|
+
.opf-crop-handle[data-handle=n],.opf-crop-handle[data-handle=s]{cursor:ns-resize}.opf-crop-handle[data-handle=e],.opf-crop-handle[data-handle=w]{cursor:ew-resize}.opf-crop-handle[data-handle=nw],.opf-crop-handle[data-handle=se]{cursor:nwse-resize}.opf-crop-handle[data-handle=ne],.opf-crop-handle[data-handle=sw]{cursor:nesw-resize}
|
|
47
|
+
.opf-crop[data-tool=focus] .opf-crop-handle{display:none}
|
|
48
|
+
.opf-crop[data-tool=focus] .opf-crop-box{cursor:crosshair}
|
|
49
|
+
.opf-crop-focus{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;border:3px solid #ffd45a;border-radius:50%;box-shadow:0 0 0 2px #0008;pointer-events:none}
|
|
50
|
+
.opf-crop-focus::before,.opf-crop-focus::after{content:"";position:absolute;background:#ffd45a;left:50%;top:50%}
|
|
51
|
+
.opf-crop-focus::before{width:2px;height:40px;margin:-20px 0 0 -1px}.opf-crop-focus::after{height:2px;width:40px;margin:-1px 0 0 -20px}
|
|
52
|
+
.opf-crop-focus[hidden]{display:none}
|
|
53
|
+
.opf-crop-bar{position:relative;z-index:1;flex:none;display:flex;flex-direction:column;gap:8px;padding:10px 12px 12px;background:#26233a;border-top:1px solid #3d3957;max-height:48%;overflow:auto;touch-action:pan-y}
|
|
54
|
+
.opf-crop-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px}
|
|
55
|
+
.opf-crop-row[hidden]{display:none}
|
|
56
|
+
.opf-crop label{display:inline-flex;align-items:center;gap:6px;font-size:12px}
|
|
57
|
+
.opf-crop select,.opf-crop input[type=number]{min-height:34px;padding:4px 8px;font:inherit;color:#f4f2fa;background:#171524;border:1px solid #55507a;border-radius:6px}
|
|
58
|
+
.opf-crop input[type=number]{width:84px}
|
|
59
|
+
.opf-crop input[type=range]{width:min(220px,50vw);accent-color:#9d92f2}
|
|
60
|
+
.opf-crop button{min-height:34px;padding:5px 12px;font:inherit;font-weight:600;color:#f4f2fa;background:#37335a;border:1px solid #5b5686;border-radius:6px;cursor:pointer}
|
|
61
|
+
.opf-crop button[aria-pressed=true]{background:#6559cf;border-color:#a79cf6}
|
|
62
|
+
.opf-crop button.opf-crop-apply{background:#6559cf;border-color:#a79cf6}
|
|
63
|
+
.opf-crop button:disabled{opacity:.5;cursor:default}
|
|
64
|
+
.opf-crop button:focus-visible,.opf-crop select:focus-visible,.opf-crop input:focus-visible{outline:3px solid #ffd45a;outline-offset:2px}
|
|
65
|
+
.opf-crop details{font-size:12px}.opf-crop summary{cursor:pointer;min-height:28px;display:flex;align-items:center}
|
|
66
|
+
.opf-crop-readout{margin:0;font-size:12px;color:#cfcae8;min-height:1.4em}
|
|
67
|
+
.opf-crop-error{margin:0;font-size:12px;color:#ffb4a3}.opf-crop-error:empty{display:none}
|
|
68
|
+
.opf-crop-actions{display:flex;flex-wrap:wrap;gap:8px;margin-left:auto}
|
|
69
|
+
.opf-crop-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
70
|
+
@media (pointer:coarse){.opf-crop button,.opf-crop select,.opf-crop input[type=number]{min-height:44px}.opf-crop select,.opf-crop input[type=number]{font-size:16px}}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
function h(doc, tag, attributes = {}, ...children) {
|
|
74
|
+
const element = doc.createElement(tag);
|
|
75
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
76
|
+
if (value === undefined || value === false) continue;
|
|
77
|
+
if (key === "class") element.className = value;
|
|
78
|
+
else if (key === "text") element.textContent = value;
|
|
79
|
+
else element.setAttribute(key, value === true ? "" : String(value));
|
|
80
|
+
}
|
|
81
|
+
for (const child of children) if (child) element.append(child);
|
|
82
|
+
return element;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureStyle(doc) {
|
|
86
|
+
if (doc.getElementById(STYLE_ID)) return;
|
|
87
|
+
const style = h(doc, "style", { id: STYLE_ID });
|
|
88
|
+
style.textContent = CSS;
|
|
89
|
+
(doc.head ?? doc.documentElement).append(style);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dimensions = (rect) => `${Math.round(rect.width)} × ${Math.round(rect.height)}`;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create the crop tool for a canvas. `root` is the canvas root (positioned), `overlay` its overlay layer (the pill goes
|
|
96
|
+
* there). Options: `editor`, `getImageElement(path)` (the traced `<image>` of a picture, for the frame's shape and the
|
|
97
|
+
* pill's position), `beforeOpen()` (finish an inline edit; return false to stop), `onCommit({ path })`, `onCancel()`,
|
|
98
|
+
* `report(error)`, `fullscreen` (true, false, or omitted: a screen at most 900px wide, or a short canvas, gets a full-screen layer).
|
|
99
|
+
*/
|
|
100
|
+
export function createImageCropper(root, overlay, options) {
|
|
101
|
+
const { editor } = options;
|
|
102
|
+
const doc = root.ownerDocument;
|
|
103
|
+
const win = doc.defaultView;
|
|
104
|
+
ensureStyle(doc);
|
|
105
|
+
|
|
106
|
+
const pill = h(doc, "button", { type: "button", class: "opf-crop-pill", "aria-label": "Crop picture or set its focal point", title: "Crop picture or set focal point", text: "Crop picture", hidden: true });
|
|
107
|
+
// The pill lives on the root: the canvas clears its overlay layer after every edit.
|
|
108
|
+
root.append(pill);
|
|
109
|
+
|
|
110
|
+
let selected = null;
|
|
111
|
+
let session = null;
|
|
112
|
+
let destroyed = false;
|
|
113
|
+
|
|
114
|
+
const frameImage = (path) => options.getImageElement?.(path) ?? null;
|
|
115
|
+
const frameRatio = (path) => {
|
|
116
|
+
const node = frameImage(path);
|
|
117
|
+
const width = Number(node?.getAttribute?.("width"));
|
|
118
|
+
const height = Number(node?.getAttribute?.("height"));
|
|
119
|
+
return width > 0 && height > 0 ? width / height : undefined;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function placePill() {
|
|
123
|
+
const target = selected && !session ? frameImage(selected) : null;
|
|
124
|
+
if (!target || !editor || describeImage(editor.document, selected).error) { pill.hidden = true; return; }
|
|
125
|
+
const box = target.getBoundingClientRect();
|
|
126
|
+
const base = root.getBoundingClientRect();
|
|
127
|
+
if (!box.width || !box.height) { pill.hidden = true; return; }
|
|
128
|
+
pill.hidden = false;
|
|
129
|
+
const left = Math.max(4, Math.min(box.left - base.left + 8, base.width - pill.offsetWidth - 4));
|
|
130
|
+
const top = Math.max(4, Math.min(box.top - base.top + 8, base.height - pill.offsetHeight - 4));
|
|
131
|
+
pill.style.left = `${left}px`;
|
|
132
|
+
pill.style.top = `${top}px`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function open(path, openOptions = {}) {
|
|
136
|
+
if (destroyed || session) return false;
|
|
137
|
+
if (options.beforeOpen && !options.beforeOpen()) return false;
|
|
138
|
+
const image = describeImage(editor.document, path);
|
|
139
|
+
if (image.error) {
|
|
140
|
+
options.report?.(new Error(image.error));
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const opener = doc.activeElement;
|
|
144
|
+
const s = (session = {
|
|
145
|
+
path,
|
|
146
|
+
opener,
|
|
147
|
+
image,
|
|
148
|
+
loaded: null,
|
|
149
|
+
rect: null,
|
|
150
|
+
tool: openOptions.tool === "focus" ? "focus" : "crop",
|
|
151
|
+
aspectId: "free",
|
|
152
|
+
zoom: 1,
|
|
153
|
+
focal: { x: 0.5, y: 0.5 },
|
|
154
|
+
busy: false,
|
|
155
|
+
frame: frameRatio(path),
|
|
156
|
+
scrollLock: null,
|
|
157
|
+
});
|
|
158
|
+
pill.hidden = true;
|
|
159
|
+
const fullscreen = options.fullscreen ?? (win.matchMedia?.("(max-width: 900px)").matches || root.clientHeight < 460);
|
|
160
|
+
const layer = h(doc, "div", { class: "opf-crop", role: "dialog", "aria-modal": "true", "aria-label": "Crop picture", "data-tool": s.tool, "data-opf-component": "image-cropper" });
|
|
161
|
+
if (fullscreen) {
|
|
162
|
+
layer.setAttribute("data-fullscreen", "");
|
|
163
|
+
s.scrollLock = doc.documentElement.style.overflow;
|
|
164
|
+
doc.documentElement.style.overflow = "hidden";
|
|
165
|
+
}
|
|
166
|
+
const stage = h(doc, "div", { class: "opf-crop-stage" });
|
|
167
|
+
const box = h(doc, "div", { class: "opf-crop-box" });
|
|
168
|
+
const img = h(doc, "img", { alt: "", draggable: "false", src: image.assetSrc });
|
|
169
|
+
const rectEl = h(doc, "div", { class: "opf-crop-rect", tabindex: "0", role: "group", "aria-label": "Crop area" });
|
|
170
|
+
for (const handle of HANDLES) rectEl.append(h(doc, "span", { class: "opf-crop-handle", "data-handle": handle, "aria-hidden": "true" }));
|
|
171
|
+
const focusEl = h(doc, "span", { class: "opf-crop-focus", "aria-hidden": "true", hidden: true });
|
|
172
|
+
box.append(img, rectEl, focusEl);
|
|
173
|
+
stage.append(box);
|
|
174
|
+
|
|
175
|
+
const toolCrop = h(doc, "button", { type: "button", "aria-pressed": "true", text: "Crop" });
|
|
176
|
+
const toolFocus = h(doc, "button", { type: "button", "aria-pressed": "false", text: "Focal point" });
|
|
177
|
+
const tools = h(doc, "div", { class: "opf-crop-row", role: "group", "aria-label": "Tool" }, toolCrop, toolFocus);
|
|
178
|
+
const aspectSelect = h(doc, "select", { "aria-label": "Aspect ratio" });
|
|
179
|
+
for (const aspect of CROP_ASPECTS) aspectSelect.append(h(doc, "option", { value: aspect.id, text: aspect.id === "frame" ? "Frame shape" : aspect.label }));
|
|
180
|
+
const aspectLabel = h(doc, "label", {}, "Aspect", aspectSelect);
|
|
181
|
+
const zoomInput = h(doc, "input", { type: "range", min: "100", max: "400", step: "5", value: "100", "aria-label": "Zoom" });
|
|
182
|
+
const zoomLabel = h(doc, "label", { hidden: true }, "Zoom", zoomInput);
|
|
183
|
+
const optionsRow = h(doc, "div", { class: "opf-crop-row" }, tools, aspectLabel, zoomLabel);
|
|
184
|
+
|
|
185
|
+
const numbers = {};
|
|
186
|
+
const exact = h(doc, "details", { class: "opf-crop-exact" }, h(doc, "summary", { text: "Exact crop (pixels)" }));
|
|
187
|
+
const exactRow = h(doc, "div", { class: "opf-crop-row" });
|
|
188
|
+
for (const [key, label] of [["x", "Left"], ["y", "Top"], ["width", "Width"], ["height", "Height"]]) {
|
|
189
|
+
numbers[key] = h(doc, "input", { type: "number", min: "0", step: "1", inputmode: "numeric", "aria-label": label });
|
|
190
|
+
exactRow.append(h(doc, "label", {}, label, numbers[key]));
|
|
191
|
+
}
|
|
192
|
+
exact.append(exactRow);
|
|
193
|
+
if (!fullscreen) exact.setAttribute("open", "");
|
|
194
|
+
const readout = h(doc, "p", { class: "opf-crop-readout", "aria-hidden": "true" });
|
|
195
|
+
const announce = h(doc, "p", { class: "opf-crop-sr", role: "status", "aria-live": "polite" });
|
|
196
|
+
const errorLine = h(doc, "p", { class: "opf-crop-error", role: "alert" });
|
|
197
|
+
const resetButton = h(doc, "button", { type: "button", text: "Reset" });
|
|
198
|
+
const restoreButton = h(doc, "button", { type: "button", text: "Restore original", hidden: true });
|
|
199
|
+
const cancelButton = h(doc, "button", { type: "button", text: "Cancel" });
|
|
200
|
+
const applyButton = h(doc, "button", { type: "button", class: "opf-crop-apply", text: "Apply crop", "aria-keyshortcuts": "Enter" });
|
|
201
|
+
const actions = h(doc, "div", { class: "opf-crop-actions" }, resetButton, restoreButton, cancelButton, applyButton);
|
|
202
|
+
const bar = h(doc, "div", { class: "opf-crop-bar" }, optionsRow, exact, readout, announce, errorLine, h(doc, "div", { class: "opf-crop-row" }, actions));
|
|
203
|
+
layer.append(stage, bar);
|
|
204
|
+
// Full screen: a layer inside the canvas could not rise above the host page's own fixed bars, so it goes on the body.
|
|
205
|
+
(fullscreen ? doc.body : root).append(layer);
|
|
206
|
+
s.layer = layer;
|
|
207
|
+
s.box = box;
|
|
208
|
+
s.rectEl = rectEl;
|
|
209
|
+
|
|
210
|
+
const close = (result) => {
|
|
211
|
+
if (session !== s) return;
|
|
212
|
+
session = null;
|
|
213
|
+
resizeObserver.disconnect();
|
|
214
|
+
layer.remove();
|
|
215
|
+
if (s.scrollLock !== null) doc.documentElement.style.overflow = s.scrollLock;
|
|
216
|
+
placePill();
|
|
217
|
+
(s.opener && doc.contains(s.opener) && !s.opener.hidden ? s.opener : pill)?.focus?.({ preventScroll: true });
|
|
218
|
+
if (result === "cancel") options.onCancel?.();
|
|
219
|
+
};
|
|
220
|
+
s.close = close;
|
|
221
|
+
s.cancel = () => cancelButton.click();
|
|
222
|
+
// A key pressed in the layer belongs to it: the page's undo, find and other shortcuts must not run underneath.
|
|
223
|
+
layer.addEventListener("keydown", (event) => { if (event.ctrlKey || event.metaKey) event.stopPropagation(); });
|
|
224
|
+
|
|
225
|
+
// ---- drawing
|
|
226
|
+
const bounds = () => ({ width: s.loaded.width, height: s.loaded.height });
|
|
227
|
+
const ratio = () => (s.tool === "focus" ? (s.frame ?? aspectRatioFor("original", bounds())) : aspectRatioFor(s.aspectId, { ...bounds(), frame: s.frame }));
|
|
228
|
+
function layout() {
|
|
229
|
+
if (!s.loaded) return;
|
|
230
|
+
const space = stage.getBoundingClientRect();
|
|
231
|
+
const scale = Math.min((space.width - 24) / s.loaded.width, (space.height - 24) / s.loaded.height);
|
|
232
|
+
const factor = Math.max(0.01, scale);
|
|
233
|
+
box.style.width = `${s.loaded.width * factor}px`;
|
|
234
|
+
box.style.height = `${s.loaded.height * factor}px`;
|
|
235
|
+
}
|
|
236
|
+
function draw({ announceNow = false } = {}) {
|
|
237
|
+
const b = bounds();
|
|
238
|
+
const r = s.rect;
|
|
239
|
+
Object.assign(rectEl.style, { left: `${(r.x / b.width) * 100}%`, top: `${(r.y / b.height) * 100}%`, width: `${(r.width / b.width) * 100}%`, height: `${(r.height / b.height) * 100}%` });
|
|
240
|
+
const rounded = roundRect(r, b);
|
|
241
|
+
for (const key of Object.keys(numbers)) if (doc.activeElement !== numbers[key]) numbers[key].value = String(rounded[key]);
|
|
242
|
+
for (const key of ["x", "width"]) numbers[key].max = String(b.width);
|
|
243
|
+
for (const key of ["y", "height"]) numbers[key].max = String(b.height);
|
|
244
|
+
const share = Math.round(((rounded.width * rounded.height) / (b.width * b.height)) * 100);
|
|
245
|
+
const text = `${dimensions(rounded)} px of ${b.width} × ${b.height} (${share}% of the picture)`;
|
|
246
|
+
readout.textContent = text;
|
|
247
|
+
rectEl.setAttribute("aria-label", `Crop area, ${text}, from ${rounded.x}, ${rounded.y}. Arrow keys move it, Shift with arrow keys resizes it, plus and minus scale it.`);
|
|
248
|
+
if (s.tool === "focus") {
|
|
249
|
+
focusEl.hidden = false;
|
|
250
|
+
focusEl.style.left = `${s.focal.x * 100}%`;
|
|
251
|
+
focusEl.style.top = `${s.focal.y * 100}%`;
|
|
252
|
+
} else focusEl.hidden = true;
|
|
253
|
+
applyButton.disabled = s.busy;
|
|
254
|
+
if (announceNow) announce.textContent = text;
|
|
255
|
+
}
|
|
256
|
+
function setError(message) {
|
|
257
|
+
errorLine.textContent = message ?? "";
|
|
258
|
+
}
|
|
259
|
+
function setTool(tool) {
|
|
260
|
+
if (s.tool === tool) return;
|
|
261
|
+
s.tool = tool;
|
|
262
|
+
layer.dataset.tool = tool;
|
|
263
|
+
toolCrop.setAttribute("aria-pressed", String(tool === "crop"));
|
|
264
|
+
toolFocus.setAttribute("aria-pressed", String(tool === "focus"));
|
|
265
|
+
aspectLabel.hidden = tool === "focus";
|
|
266
|
+
zoomLabel.hidden = tool !== "focus";
|
|
267
|
+
exact.hidden = tool === "focus";
|
|
268
|
+
if (tool === "focus") {
|
|
269
|
+
const a = ratio();
|
|
270
|
+
const whole = fitAspect(fullRect(s.loaded.width, s.loaded.height), a);
|
|
271
|
+
s.focal = focalPointOf(s.rect, bounds());
|
|
272
|
+
s.zoom = Math.max(1, Math.min(4, whole.width / Math.max(1, s.rect.width)));
|
|
273
|
+
zoomInput.value = String(Math.round(s.zoom * 100));
|
|
274
|
+
s.rect = focalWindow(bounds(), a, s.focal, s.zoom);
|
|
275
|
+
} else {
|
|
276
|
+
s.aspectId = s.frame ? "frame" : "free";
|
|
277
|
+
aspectSelect.value = s.aspectId;
|
|
278
|
+
}
|
|
279
|
+
setError("");
|
|
280
|
+
draw({ announceNow: true });
|
|
281
|
+
}
|
|
282
|
+
function setAspect(id) {
|
|
283
|
+
s.aspectId = id;
|
|
284
|
+
const a = ratio();
|
|
285
|
+
s.rect = a ? clampRect(fitAspect(s.rect, a), bounds()) : s.rect;
|
|
286
|
+
draw({ announceNow: true });
|
|
287
|
+
}
|
|
288
|
+
function reset() {
|
|
289
|
+
s.rect = fullRect(s.loaded.width, s.loaded.height);
|
|
290
|
+
s.focal = { x: 0.5, y: 0.5 };
|
|
291
|
+
s.zoom = 1;
|
|
292
|
+
zoomInput.value = "100";
|
|
293
|
+
if (s.tool === "focus") s.rect = focalWindow(bounds(), ratio(), s.focal, 1);
|
|
294
|
+
else {
|
|
295
|
+
s.aspectId = "free";
|
|
296
|
+
aspectSelect.value = "free";
|
|
297
|
+
}
|
|
298
|
+
setError("");
|
|
299
|
+
draw({ announceNow: true });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- pointer: move, resize, focal point
|
|
303
|
+
const toSource = (event) => {
|
|
304
|
+
const rect = box.getBoundingClientRect();
|
|
305
|
+
return { x: ((event.clientX - rect.left) / rect.width) * s.loaded.width, y: ((event.clientY - rect.top) / rect.height) * s.loaded.height, scale: s.loaded.width / rect.width };
|
|
306
|
+
};
|
|
307
|
+
let drag = null;
|
|
308
|
+
box.addEventListener("pointerdown", (event) => {
|
|
309
|
+
if (s.busy || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
310
|
+
const handle = event.target.closest?.("[data-handle]")?.getAttribute("data-handle");
|
|
311
|
+
const point = toSource(event);
|
|
312
|
+
if (s.tool === "focus") {
|
|
313
|
+
drag = { id: event.pointerId, mode: "focus" };
|
|
314
|
+
s.focal = { x: Math.max(0, Math.min(1, point.x / s.loaded.width)), y: Math.max(0, Math.min(1, point.y / s.loaded.height)) };
|
|
315
|
+
s.rect = focalWindow(bounds(), ratio(), s.focal, s.zoom);
|
|
316
|
+
} else if (handle) drag = { id: event.pointerId, mode: "resize", handle, start: { ...s.rect }, aspect: ratio() };
|
|
317
|
+
else if (event.target === rectEl || rectEl.contains(event.target)) drag = { id: event.pointerId, mode: "move", start: { ...s.rect }, from: point };
|
|
318
|
+
else return;
|
|
319
|
+
event.preventDefault();
|
|
320
|
+
rectEl.focus({ preventScroll: true });
|
|
321
|
+
// A touch pointer is captured by its target already; capturing it again made Chromium drop the next tap's click.
|
|
322
|
+
if (event.pointerType !== "touch") box.setPointerCapture?.(event.pointerId);
|
|
323
|
+
draw();
|
|
324
|
+
});
|
|
325
|
+
box.addEventListener("pointermove", (event) => {
|
|
326
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
327
|
+
const point = toSource(event);
|
|
328
|
+
if (drag.mode === "focus") {
|
|
329
|
+
s.focal = { x: Math.max(0, Math.min(1, point.x / s.loaded.width)), y: Math.max(0, Math.min(1, point.y / s.loaded.height)) };
|
|
330
|
+
s.rect = focalWindow(bounds(), ratio(), s.focal, s.zoom);
|
|
331
|
+
} else if (drag.mode === "move") s.rect = moveRect(drag.start, point.x - drag.from.x, point.y - drag.from.y, bounds());
|
|
332
|
+
else {
|
|
333
|
+
// Shift keeps the current shape when the aspect is free.
|
|
334
|
+
const aspect = drag.aspect ?? (event.shiftKey ? drag.start.width / drag.start.height : undefined);
|
|
335
|
+
s.rect = resizeRect(drag.start, drag.handle, point, bounds(), { aspect });
|
|
336
|
+
}
|
|
337
|
+
draw();
|
|
338
|
+
});
|
|
339
|
+
const endDrag = (event) => {
|
|
340
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
341
|
+
drag = null;
|
|
342
|
+
if (event.pointerType !== "touch") box.releasePointerCapture?.(event.pointerId);
|
|
343
|
+
draw({ announceNow: true });
|
|
344
|
+
};
|
|
345
|
+
box.addEventListener("pointerup", endDrag);
|
|
346
|
+
box.addEventListener("pointercancel", endDrag);
|
|
347
|
+
|
|
348
|
+
// ---- keyboard
|
|
349
|
+
const step = () => Math.max(1, Math.round(Math.max(s.loaded.width, s.loaded.height) * 0.01));
|
|
350
|
+
rectEl.addEventListener("keydown", (event) => {
|
|
351
|
+
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
|
352
|
+
if (arrows) {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
const amount = step() * (event.altKey ? 0.2 : 1);
|
|
355
|
+
if (s.tool === "focus") {
|
|
356
|
+
const move = (event.shiftKey ? 5 : 1) / 100;
|
|
357
|
+
s.focal = { x: Math.max(0, Math.min(1, s.focal.x + arrows[0] * move)), y: Math.max(0, Math.min(1, s.focal.y + arrows[1] * move)) };
|
|
358
|
+
s.rect = focalWindow(bounds(), ratio(), s.focal, s.zoom);
|
|
359
|
+
} else if (event.shiftKey) {
|
|
360
|
+
const a = ratio();
|
|
361
|
+
const corner = { x: s.rect.x + s.rect.width, y: s.rect.y + s.rect.height };
|
|
362
|
+
let point;
|
|
363
|
+
if (a) {
|
|
364
|
+
const grow = arrows[0] + arrows[1] > 0 ? 1 : -1;
|
|
365
|
+
point = { x: corner.x + grow * amount, y: corner.y + (grow * amount) / a };
|
|
366
|
+
} else point = { x: corner.x + arrows[0] * amount, y: corner.y + arrows[1] * amount };
|
|
367
|
+
s.rect = resizeRect(s.rect, "se", point, bounds(), { aspect: a });
|
|
368
|
+
} else s.rect = moveRect(s.rect, arrows[0] * amount, arrows[1] * amount, bounds());
|
|
369
|
+
draw({ announceNow: true });
|
|
370
|
+
} else if (event.key === "+" || event.key === "=" || event.key === "-" || event.key === "_") {
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
const grow = event.key === "+" || event.key === "=";
|
|
373
|
+
if (s.tool === "focus") setZoom(s.zoom + (grow ? 0.1 : -0.1));
|
|
374
|
+
else {
|
|
375
|
+
const factor = grow ? 0.95 : 1 / 0.95;
|
|
376
|
+
const a = ratio();
|
|
377
|
+
const next = { width: s.rect.width * factor, height: s.rect.height * factor };
|
|
378
|
+
const center = { x: s.rect.x + s.rect.width / 2, y: s.rect.y + s.rect.height / 2 };
|
|
379
|
+
const fitted = a ? fitAspect({ x: center.x - next.width / 2, y: center.y - next.height / 2, ...next }, a) : { x: center.x - next.width / 2, y: center.y - next.height / 2, ...next };
|
|
380
|
+
s.rect = clampRect(fitted, bounds());
|
|
381
|
+
draw({ announceNow: true });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
layer.addEventListener("keydown", (event) => {
|
|
386
|
+
if (event.key === "Escape") {
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
event.stopPropagation();
|
|
389
|
+
cancelButton.click();
|
|
390
|
+
} else if (event.key === "Enter" && !/^(BUTTON|SELECT|SUMMARY)$/.test(event.target.tagName)) {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
applyButton.click();
|
|
393
|
+
} else if (event.key === "Tab") {
|
|
394
|
+
// The layer is modal: Tab stays inside it.
|
|
395
|
+
const focusable = [...layer.querySelectorAll("button:not([disabled]):not([hidden]),select,input,summary,[tabindex='0']")].filter((node) => node.offsetParent !== null || node === rectEl);
|
|
396
|
+
if (!focusable.length) return;
|
|
397
|
+
const first = focusable[0];
|
|
398
|
+
const last = focusable.at(-1);
|
|
399
|
+
if (event.shiftKey && doc.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
400
|
+
else if (!event.shiftKey && doc.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
function setZoom(value) {
|
|
404
|
+
s.zoom = Math.max(1, Math.min(4, Math.round(value * 100) / 100));
|
|
405
|
+
zoomInput.value = String(Math.round(s.zoom * 100));
|
|
406
|
+
s.rect = focalWindow(bounds(), ratio(), s.focal, s.zoom);
|
|
407
|
+
draw({ announceNow: true });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
toolCrop.addEventListener("click", () => setTool("crop"));
|
|
411
|
+
toolFocus.addEventListener("click", () => setTool("focus"));
|
|
412
|
+
aspectSelect.addEventListener("change", () => setAspect(aspectSelect.value));
|
|
413
|
+
zoomInput.addEventListener("input", () => setZoom(Number(zoomInput.value) / 100));
|
|
414
|
+
for (const key of Object.keys(numbers)) {
|
|
415
|
+
numbers[key].addEventListener("change", () => {
|
|
416
|
+
const value = Number(numbers[key].value);
|
|
417
|
+
if (!Number.isFinite(value)) return;
|
|
418
|
+
const a = ratio();
|
|
419
|
+
let next = { ...roundRect(s.rect, bounds()), [key]: value };
|
|
420
|
+
if (a && key === "width") next.height = next.width / a;
|
|
421
|
+
else if (a && key === "height") next.width = next.height * a;
|
|
422
|
+
s.rect = clampRect(next, bounds());
|
|
423
|
+
if (a) s.rect = fitAspect(clampRect(next, bounds()), a);
|
|
424
|
+
draw({ announceNow: true });
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
resetButton.addEventListener("click", reset);
|
|
428
|
+
cancelButton.addEventListener("click", () => close("cancel"));
|
|
429
|
+
restoreButton.addEventListener("click", () => {
|
|
430
|
+
try {
|
|
431
|
+
const done = restoreOriginal(editor, path);
|
|
432
|
+
if (!done) return;
|
|
433
|
+
close("done");
|
|
434
|
+
options.onCommit?.({ path, restored: true });
|
|
435
|
+
} catch (error) {
|
|
436
|
+
setError(error.message);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
applyButton.addEventListener("click", async () => {
|
|
440
|
+
if (s.busy || !s.loaded) return;
|
|
441
|
+
setError("");
|
|
442
|
+
if (isFullRect(roundRect(s.rect, bounds()), bounds()) && s.tool === "crop") {
|
|
443
|
+
setError("The whole picture is selected. Drag a handle to choose what to keep.");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
s.busy = true;
|
|
447
|
+
applyButton.disabled = true;
|
|
448
|
+
applyButton.textContent = "Applying…";
|
|
449
|
+
try {
|
|
450
|
+
const changed = await applyCrop(editor, path, s.rect, { loaded: s.loaded, meta: { tool: s.tool } });
|
|
451
|
+
if (session !== s) return;
|
|
452
|
+
close("done");
|
|
453
|
+
options.onCommit?.({ path, assetId: changed.assetId });
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (session !== s) return;
|
|
456
|
+
s.busy = false;
|
|
457
|
+
applyButton.textContent = "Apply crop";
|
|
458
|
+
applyButton.disabled = false;
|
|
459
|
+
setError(error.message);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---- load the picture
|
|
464
|
+
const resizeObserver = new win.ResizeObserver(() => layout());
|
|
465
|
+
resizeObserver.observe(stage);
|
|
466
|
+
announce.textContent = "Loading the picture…";
|
|
467
|
+
readout.textContent = "Loading the picture…";
|
|
468
|
+
const controls = [toolCrop, toolFocus, aspectSelect, zoomInput, resetButton, applyButton, ...Object.values(numbers)];
|
|
469
|
+
for (const control of controls) control.disabled = true;
|
|
470
|
+
layer.setAttribute("aria-busy", "true");
|
|
471
|
+
try {
|
|
472
|
+
s.loaded = await loadImagePixels(image.assetSrc, { document: doc });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (session !== s) return false;
|
|
475
|
+
close("error");
|
|
476
|
+
options.report?.(error);
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
if (session !== s) return false;
|
|
480
|
+
for (const control of controls) control.disabled = false;
|
|
481
|
+
layer.removeAttribute("aria-busy");
|
|
482
|
+
const frame = s.frame;
|
|
483
|
+
aspectSelect.querySelector('option[value="frame"]').disabled = !frame;
|
|
484
|
+
s.rect = fullRect(s.loaded.width, s.loaded.height);
|
|
485
|
+
layout();
|
|
486
|
+
restoreButton.hidden = !prepareRestore(editor.document, path);
|
|
487
|
+
if (s.tool === "focus") {
|
|
488
|
+
s.tool = "crop";
|
|
489
|
+
setTool("focus");
|
|
490
|
+
}
|
|
491
|
+
draw({ announceNow: true });
|
|
492
|
+
rectEl.focus({ preventScroll: true });
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
pill.addEventListener("click", () => { if (selected) open(selected); });
|
|
497
|
+
const resize = new win.ResizeObserver(() => placePill());
|
|
498
|
+
resize.observe(root);
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
/** Open the crop layer for the picture at `path` (`{ tool: "focus" }` starts with the focal point tool). Resolves to whether it opened. */
|
|
502
|
+
open,
|
|
503
|
+
get isOpen() { return !!session; },
|
|
504
|
+
get path() { return session?.path ?? null; },
|
|
505
|
+
/** Leave the crop layer without changing anything. */
|
|
506
|
+
cancel() { session?.cancel?.(); },
|
|
507
|
+
/** The canvas selected `path`: show the Crop picture button when it is a picture. */
|
|
508
|
+
sync(path) { selected = path ?? null; placePill(); },
|
|
509
|
+
/** The canvas redrew: put the button back on its picture. */
|
|
510
|
+
update() { placePill(); },
|
|
511
|
+
get pill() { return pill; },
|
|
512
|
+
destroy() {
|
|
513
|
+
destroyed = true;
|
|
514
|
+
session?.close?.("cancel");
|
|
515
|
+
resize.disconnect();
|
|
516
|
+
pill.remove();
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -20,10 +20,13 @@ export declare const runtimePolicy: Readonly<{
|
|
|
20
20
|
deterministicLocalExecution: true;
|
|
21
21
|
}>;
|
|
22
22
|
|
|
23
|
+
/** RFC 6902 operations, executed by core's `@openpresentation/opf/patch`. Paths are JSON Pointers or dotted OPF paths. */
|
|
23
24
|
export type JsonPatchOperation =
|
|
24
25
|
| { op: "add"; path: string; value: unknown }
|
|
25
26
|
| { op: "replace"; path: string; value: unknown }
|
|
26
27
|
| { op: "remove"; path: string }
|
|
28
|
+
| { op: "move"; from: string; path: string }
|
|
29
|
+
| { op: "copy"; from: string; path: string }
|
|
27
30
|
| { op: "test"; path: string; value: unknown };
|
|
28
31
|
|
|
29
32
|
export interface OPFValidationSummary {
|
|
@@ -51,7 +54,7 @@ export interface EditorChange {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
export interface EditorEvent {
|
|
54
|
-
type: "patch" | "undo" | "redo";
|
|
57
|
+
type: "patch" | "undo" | "redo" | "restore";
|
|
55
58
|
patches: JsonPatchOperation[];
|
|
56
59
|
inversePatches?: JsonPatchOperation[];
|
|
57
60
|
redoPatches?: JsonPatchOperation[];
|
|
@@ -60,6 +63,9 @@ export interface EditorEvent {
|
|
|
60
63
|
snapshot: EditorSnapshot;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
export interface EditorHistoryEntry { patches: JsonPatchOperation[]; inversePatches: JsonPatchOperation[]; meta?: Record<string, unknown> }
|
|
67
|
+
export interface EditorHistory { undo: EditorHistoryEntry[]; redo: EditorHistoryEntry[] }
|
|
68
|
+
|
|
63
69
|
export interface EditorSession {
|
|
64
70
|
paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
65
71
|
composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
|
|
@@ -75,6 +81,10 @@ export interface EditorSession {
|
|
|
75
81
|
setGroupComposition(path: string, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
76
82
|
setCatalog(path: string | string[], catalogKind: string, id: string, meta?: Record<string, unknown>): EditorChange;
|
|
77
83
|
applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>): EditorChange;
|
|
84
|
+
/** The undo and redo stacks as plain data (oldest entry first), for hosts that persist work. */
|
|
85
|
+
exportHistory(): EditorHistory;
|
|
86
|
+
/** Replace the document and, optionally, the history in one step (one `restore` event). A history that does not replay against the document throws `invalid-history` before anything changes. */
|
|
87
|
+
restoreState(state: { document: unknown; undo?: EditorHistoryEntry[]; redo?: EditorHistoryEntry[] }, meta?: Record<string, unknown>): EditorChange;
|
|
78
88
|
undo(meta?: Record<string, unknown>): EditorChange | null;
|
|
79
89
|
redo(meta?: Record<string, unknown>): EditorChange | null;
|
|
80
90
|
}
|