smartrte-react 1.0.0-beta.5 → 1.0.0-beta.8
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/CHANGELOG.md +15 -0
- package/README.md +94 -0
- package/dist/components/CanonicalAuthorityEditor.d.ts +15 -1
- package/dist/components/CanonicalAuthorityEditor.js +198 -46
- package/dist/components/MediaDetailsPopover.d.ts +35 -0
- package/dist/components/MediaDetailsPopover.js +136 -0
- package/dist/components/MediaOverlay.d.ts +10 -21
- package/dist/components/MediaOverlay.js +128 -63
- package/dist/components/ToolbarPrimitives.d.ts +14 -0
- package/dist/components/ToolbarPrimitives.js +45 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/standalone/classic-editor-embed.js +1 -1
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +6 -2
- package/dist/toolbarTools.d.ts +89 -0
- package/dist/toolbarTools.js +16 -0
- package/package.json +2 -2
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { normalizeLinkInput } from "smartrte-core/legacy";
|
|
4
|
+
const LICENSE_TYPE_PRESETS = ["CC BY", "CC BY-SA", "CC BY-ND", "CC BY-NC", "CC BY-NC-SA", "CC BY-NC-ND", "CC0 (Public Domain)", "All rights reserved"];
|
|
5
|
+
const inputStyle = {
|
|
6
|
+
width: "100%", height: 34, boxSizing: "border-box", marginTop: 4, padding: "0 9px",
|
|
7
|
+
border: "1px solid var(--srte-input-border)", borderRadius: 8, outline: "none",
|
|
8
|
+
background: "var(--srte-input-bg)", color: "var(--srte-input-text)", font: "inherit", fontSize: 13,
|
|
9
|
+
};
|
|
10
|
+
const selectStyle = { ...inputStyle, cursor: "pointer" };
|
|
11
|
+
const labelStyle = { display: "block", fontSize: 12, fontWeight: 600 };
|
|
12
|
+
const buttonStyle = {
|
|
13
|
+
minHeight: 34, padding: "0 12px", border: "1px solid var(--srte-input-border)", borderRadius: 8,
|
|
14
|
+
background: "var(--srte-input-bg)", color: "var(--srte-menu-text)", cursor: "pointer", fontWeight: 500, fontSize: 13,
|
|
15
|
+
};
|
|
16
|
+
const sectionHeaderStyle = {
|
|
17
|
+
fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em", opacity: 0.7,
|
|
18
|
+
marginTop: 4, paddingTop: 10, borderTop: "1px solid var(--srte-border)",
|
|
19
|
+
};
|
|
20
|
+
const alignButton = (current, value, label, onClick) => (_jsx("button", { type: "button", "aria-label": label, "aria-pressed": current === value, onClick: onClick, style: {
|
|
21
|
+
...buttonStyle,
|
|
22
|
+
flex: 1,
|
|
23
|
+
minHeight: 32,
|
|
24
|
+
padding: 0,
|
|
25
|
+
background: current === value ? "var(--srte-accent-bg)" : "var(--srte-input-bg)",
|
|
26
|
+
borderColor: current === value ? "var(--srte-primary)" : "var(--srte-input-border)",
|
|
27
|
+
color: current === value ? "var(--srte-primary-pressed)" : "var(--srte-menu-text)",
|
|
28
|
+
}, children: label }, label));
|
|
29
|
+
/**
|
|
30
|
+
* "Media details" - replaces the crude window.prompt("Alt text") edit path
|
|
31
|
+
* (docs/bugs/media-details-old-editor-field-parity.md) with a real settings
|
|
32
|
+
* panel matching the old editor's own field set, following this project's
|
|
33
|
+
* established compact-popover pattern (position:fixed + JS-measured
|
|
34
|
+
* placement, Apply/Cancel commit - same shape as TableBorderPopover for a
|
|
35
|
+
* multi-field dialog like this one, rather than LinkEditorPopover's simpler
|
|
36
|
+
* live-Enter-to-apply form).
|
|
37
|
+
*
|
|
38
|
+
* Opened directly by an image's right-click interaction; the details panel
|
|
39
|
+
* is the image editing surface, while resize handles remain available
|
|
40
|
+
* independently around the selected image.
|
|
41
|
+
*/
|
|
42
|
+
export function MediaDetailsPopover({ x, y, initial, onApply, onCancel }) {
|
|
43
|
+
const [draft, setDraft] = useState(initial);
|
|
44
|
+
const [licenseTypeCustom, setLicenseTypeCustom] = useState(!!initial.licenseType && !LICENSE_TYPE_PRESETS.includes(initial.licenseType));
|
|
45
|
+
const [error, setError] = useState("");
|
|
46
|
+
const rootRef = useRef(null);
|
|
47
|
+
const [placement, setPlacement] = useState(null);
|
|
48
|
+
const onCancelRef = useRef(onCancel);
|
|
49
|
+
onCancelRef.current = onCancel;
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
const el = rootRef.current;
|
|
52
|
+
if (!el)
|
|
53
|
+
return;
|
|
54
|
+
const margin = 8;
|
|
55
|
+
const { width, height } = el.getBoundingClientRect();
|
|
56
|
+
const viewportWidth = window.innerWidth;
|
|
57
|
+
const viewportHeight = window.innerHeight;
|
|
58
|
+
const overflowsRight = x + width > viewportWidth - margin;
|
|
59
|
+
const left = overflowsRight ? x - width : x;
|
|
60
|
+
const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - width - margin));
|
|
61
|
+
const overflowsBottom = y + height > viewportHeight - margin;
|
|
62
|
+
const top = overflowsBottom ? Math.max(margin, y - height) : y;
|
|
63
|
+
const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - height - margin));
|
|
64
|
+
setPlacement({ left: clampedLeft, top: clampedTop });
|
|
65
|
+
}, [x, y]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const dismissIfOutside = (event) => {
|
|
68
|
+
const target = event.target;
|
|
69
|
+
const elementTarget = target instanceof Element ? target : target.parentElement;
|
|
70
|
+
// Resize handles live outside this dialog and must receive their own
|
|
71
|
+
// pointer gesture without the editor closing on the first pointerdown.
|
|
72
|
+
if (elementTarget?.closest("[data-srte-media-resize-handle-direction]"))
|
|
73
|
+
return;
|
|
74
|
+
if (!rootRef.current?.contains(target))
|
|
75
|
+
onCancelRef.current();
|
|
76
|
+
};
|
|
77
|
+
window.addEventListener("pointerdown", dismissIfOutside, true);
|
|
78
|
+
window.addEventListener("mousedown", dismissIfOutside, true);
|
|
79
|
+
return () => {
|
|
80
|
+
window.removeEventListener("pointerdown", dismissIfOutside, true);
|
|
81
|
+
window.removeEventListener("mousedown", dismissIfOutside, true);
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const onWindowKeyDown = (event) => { if (event.key === "Escape")
|
|
86
|
+
onCancelRef.current(); };
|
|
87
|
+
window.addEventListener("keydown", onWindowKeyDown);
|
|
88
|
+
return () => window.removeEventListener("keydown", onWindowKeyDown);
|
|
89
|
+
}, []);
|
|
90
|
+
const update = (patch) => setDraft((current) => ({ ...current, ...patch }));
|
|
91
|
+
const apply = () => {
|
|
92
|
+
if (!draft.alt.trim()) {
|
|
93
|
+
setError("Enter alt text.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const trimmedHref = draft.href.trim();
|
|
97
|
+
if (!trimmedHref) {
|
|
98
|
+
onApply({ ...draft, href: "" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const normalized = normalizeLinkInput(trimmedHref);
|
|
102
|
+
if (!normalized.href) {
|
|
103
|
+
setError("Enter a safe, valid web URL, email address, phone number, or page anchor for the link, or clear it.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
onApply({ ...draft, href: normalized.href });
|
|
107
|
+
};
|
|
108
|
+
return (_jsxs("div", { ref: rootRef, "data-srte-media-details-popover": "true", role: "dialog", "aria-label": "Media details", style: {
|
|
109
|
+
position: "fixed",
|
|
110
|
+
left: placement?.left ?? x,
|
|
111
|
+
top: placement?.top ?? y,
|
|
112
|
+
visibility: placement ? "visible" : "hidden",
|
|
113
|
+
zIndex: 72,
|
|
114
|
+
width: 320,
|
|
115
|
+
maxWidth: "calc(100vw - 16px)",
|
|
116
|
+
maxHeight: "calc(100vh - 16px)",
|
|
117
|
+
overflowY: "auto",
|
|
118
|
+
boxSizing: "border-box",
|
|
119
|
+
background: "var(--srte-menu-bg)",
|
|
120
|
+
color: "var(--srte-menu-text)",
|
|
121
|
+
border: "1px solid var(--srte-border)",
|
|
122
|
+
borderRadius: 12,
|
|
123
|
+
boxShadow: "var(--srte-menu-shadow)",
|
|
124
|
+
padding: 14,
|
|
125
|
+
display: "flex",
|
|
126
|
+
flexDirection: "column",
|
|
127
|
+
gap: 10,
|
|
128
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between" }, children: [_jsx("div", { style: { fontWeight: 650 }, children: "Media details" }), _jsx("button", { type: "button", "aria-label": "Close media details", onClick: onCancel, style: { ...buttonStyle, minWidth: 26, minHeight: 26, padding: 0, border: 0, background: "transparent", fontSize: 18 }, children: "\u00D7" })] }), _jsxs("label", { style: labelStyle, children: ["Alt text", _jsx("input", { "data-srte-media-alt-input": "true", value: draft.alt, onChange: (event) => { update({ alt: event.target.value }); setError(""); }, style: { ...inputStyle, borderColor: error ? "var(--srte-danger)" : "var(--srte-input-border)" } })] }), error && _jsx("div", { role: "alert", style: { color: "var(--srte-danger)", fontSize: 12 }, children: error }), _jsxs("label", { style: labelStyle, children: ["Link", _jsx("input", { "data-srte-media-href-input": "true", value: draft.href, placeholder: "Paste a URL", inputMode: "url", autoCapitalize: "none", autoCorrect: "off", spellCheck: false, onChange: (event) => { update({ href: event.target.value }); setError(""); }, style: { ...inputStyle, borderColor: error ? "var(--srte-danger)" : "var(--srte-input-border)" } })] }), _jsxs("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13, cursor: "pointer" }, children: [_jsx("input", { "data-srte-media-new-tab-input": "true", type: "checkbox", checked: draft.openInNewTab, disabled: !draft.href.trim(), onChange: (event) => update({ openInNewTab: event.target.checked }) }), "Open in a new tab"] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs("label", { style: { ...labelStyle, flex: 1 }, children: ["Width (px)", _jsx("input", { "data-srte-media-width-input": "true", type: "number", min: 16, value: draft.width ?? "", onChange: (event) => update({ width: event.target.value === "" ? undefined : Math.max(16, Number(event.target.value)) }), style: inputStyle })] }), _jsxs("label", { style: { ...labelStyle, flex: 1 }, children: ["Corner radius (px)", _jsx("input", { "data-srte-media-radius-input": "true", type: "number", min: 0, value: draft.borderRadius ?? "", onChange: (event) => update({ borderRadius: event.target.value === "" ? undefined : Math.max(0, Number(event.target.value)) }), style: inputStyle })] })] }), _jsxs("div", { style: labelStyle, children: ["Align", _jsxs("div", { style: { display: "flex", gap: 6, marginTop: 4 }, children: [alignButton(draft.align, undefined, "None", () => update({ align: undefined })), alignButton(draft.align, "left", "Left", () => update({ align: "left" })), alignButton(draft.align, "center", "Center", () => update({ align: "center" })), alignButton(draft.align, "right", "Right", () => update({ align: "right" }))] })] }), _jsx("div", { style: sectionHeaderStyle, children: "License" }), _jsxs("label", { style: labelStyle, children: ["Description", _jsx("input", { "data-srte-media-license-description-input": "true", value: draft.licenseDescription, onChange: (event) => update({ licenseDescription: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Source URL", _jsx("input", { "data-srte-media-license-source-input": "true", value: draft.licenseSourceUrl, onChange: (event) => update({ licenseSourceUrl: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Type", _jsxs("select", { "data-srte-media-license-type-select": "true", value: licenseTypeCustom ? "custom" : draft.licenseType, onChange: (event) => {
|
|
129
|
+
if (event.target.value === "custom") {
|
|
130
|
+
setLicenseTypeCustom(true);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setLicenseTypeCustom(false);
|
|
134
|
+
update({ licenseType: event.target.value });
|
|
135
|
+
}, style: selectStyle, children: [_jsx("option", { value: "", children: "(none)" }), LICENSE_TYPE_PRESETS.map((preset) => _jsx("option", { value: preset, children: preset }, preset)), _jsx("option", { value: "custom", children: "Custom\u2026" })] }), licenseTypeCustom && (_jsx("input", { "data-srte-media-license-type-custom-input": "true", value: draft.licenseType, placeholder: "License type", onChange: (event) => update({ licenseType: event.target.value }), style: inputStyle }))] }), _jsxs("label", { style: labelStyle, children: ["Version", _jsx("input", { "data-srte-media-license-version-input": "true", value: draft.licenseVersion, onChange: (event) => update({ licenseVersion: event.target.value }), style: inputStyle })] }), _jsxs("label", { style: labelStyle, children: ["Attribution", _jsx("input", { "data-srte-media-license-attribution-input": "true", value: draft.licenseAttribution, onChange: (event) => update({ licenseAttribution: event.target.value }), style: inputStyle })] }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 7, marginTop: 4 }, children: [_jsx("button", { type: "button", onClick: onCancel, style: buttonStyle, children: "Cancel" }), _jsx("button", { type: "button", onClick: apply, style: { ...buttonStyle, borderColor: "var(--srte-primary)", background: "var(--srte-primary)", color: "var(--srte-on-primary)" }, children: "Apply" })] })] }));
|
|
136
|
+
}
|
|
@@ -4,7 +4,11 @@ export interface MediaOverlayProps {
|
|
|
4
4
|
width?: number;
|
|
5
5
|
height?: number;
|
|
6
6
|
src?: string;
|
|
7
|
-
|
|
7
|
+
x?: number;
|
|
8
|
+
y?: number;
|
|
9
|
+
/** Render only the handles when the media details editor owns the interaction surface. */
|
|
10
|
+
showMenu?: boolean;
|
|
11
|
+
/** False for atoms with no width/height concept (e.g. formula, sized by KaTeX from source/font-size) - hides resize controls and handles, keeping Edit/Delete. */
|
|
8
12
|
resizable?: boolean;
|
|
9
13
|
onEdit: () => void;
|
|
10
14
|
onResize: (by: number) => void;
|
|
@@ -13,24 +17,9 @@ export interface MediaOverlayProps {
|
|
|
13
17
|
onDismiss: () => void;
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* per §1's investigation) instead of click coordinates, appearing whenever
|
|
21
|
-
* an atom is selected. The caller controls visibility by conditionally
|
|
22
|
-
* rendering this component based on atomSelected - when the selection
|
|
23
|
-
* moves off the atom, the component unmounts on its own, which is the
|
|
24
|
-
* "dismiss when selection moves away" requirement with no extra logic
|
|
25
|
-
* needed here.
|
|
26
|
-
*
|
|
27
|
-
* Post-batch follow-up ("images doesn't have a resizer"): the Resize +/-
|
|
28
|
-
* buttons were the only resize affordance - no direct drag handle existed
|
|
29
|
-
* for atoms, unlike tables. Added a corner drag handle at the atom's own
|
|
30
|
-
* bottom-right corner, reusing TableResizeHandles.tsx's exact live-preview-
|
|
31
|
-
* during-drag / commit-on-release pattern (mutate the real element's style
|
|
32
|
-
* directly while dragging for immediate feedback, commit via a real command
|
|
33
|
-
* only on release) rather than inventing a different interaction model for
|
|
34
|
-
* the second resizable-thing in this codebase.
|
|
20
|
+
* Media resize handles stay anchored to the selected media element and
|
|
21
|
+
* preview size changes live, committing one canonical resize operation when
|
|
22
|
+
* the pointer is released. The quick-action menu is optional because image
|
|
23
|
+
* right-clicks use MediaDetailsPopover directly.
|
|
35
24
|
*/
|
|
36
|
-
export declare function MediaOverlay({ atomElement, alt, width, height, src, resizable, onEdit, onResize, onResizeTo, onDelete, onDismiss }: MediaOverlayProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu, resizable, onEdit, onResize, onResizeTo, onDelete, onDismiss }: MediaOverlayProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
const RESIZE_DIRECTIONS = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
|
4
|
+
const HANDLE_SIZE = 10;
|
|
5
|
+
const MIN_ATOM_SIZE = 24;
|
|
3
6
|
const buttonStyle = {
|
|
4
7
|
minHeight: 32,
|
|
5
8
|
padding: "0 10px",
|
|
@@ -11,35 +14,75 @@ const buttonStyle = {
|
|
|
11
14
|
fontWeight: 500,
|
|
12
15
|
fontSize: 13,
|
|
13
16
|
};
|
|
14
|
-
const
|
|
15
|
-
|
|
17
|
+
const cursorFor = (direction) => {
|
|
18
|
+
if (direction === "n" || direction === "s")
|
|
19
|
+
return "ns-resize";
|
|
20
|
+
if (direction === "e" || direction === "w")
|
|
21
|
+
return "ew-resize";
|
|
22
|
+
return direction === "ne" || direction === "sw" ? "nesw-resize" : "nwse-resize";
|
|
23
|
+
};
|
|
24
|
+
const isCorner = (direction) => direction.length === 2;
|
|
25
|
+
const visibleBoundsFor = (atomElement, modelWidth, modelHeight) => {
|
|
26
|
+
const rect = atomElement.getBoundingClientRect();
|
|
27
|
+
const editorElement = atomElement.closest(".srte-editor");
|
|
28
|
+
const editorRect = editorElement?.getBoundingClientRect();
|
|
29
|
+
const inset = HANDLE_SIZE / 2;
|
|
30
|
+
// Broken or still-loading images can expose only a tiny fallback box.
|
|
31
|
+
// Use the canonical dimensions for that case, but use the real rendered
|
|
32
|
+
// dimensions whenever the image has a meaningful layout box.
|
|
33
|
+
const imageWidth = rect.width >= MIN_ATOM_SIZE ? rect.width : modelWidth && modelWidth >= MIN_ATOM_SIZE ? modelWidth : rect.width;
|
|
34
|
+
const imageHeight = rect.height >= MIN_ATOM_SIZE ? rect.height : modelHeight && modelHeight >= MIN_ATOM_SIZE ? modelHeight : rect.height;
|
|
35
|
+
const left = Math.max(rect.left, editorRect ? editorRect.left + inset : rect.left);
|
|
36
|
+
const top = Math.max(rect.top, editorRect ? editorRect.top + inset : rect.top);
|
|
37
|
+
const right = Math.min(rect.left + imageWidth, editorRect ? editorRect.right - inset : rect.left + imageWidth);
|
|
38
|
+
const bottom = Math.min(rect.top + imageHeight, editorRect ? editorRect.bottom - inset : rect.top + imageHeight);
|
|
39
|
+
if (right <= left || bottom <= top)
|
|
40
|
+
return null;
|
|
41
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
42
|
+
};
|
|
43
|
+
const dimensionsForDrag = (drag, clientX, clientY) => {
|
|
44
|
+
const deltaX = clientX - drag.startClientX;
|
|
45
|
+
const deltaY = clientY - drag.startClientY;
|
|
46
|
+
const widthDelta = drag.direction.includes("e") ? deltaX : drag.direction.includes("w") ? -deltaX : 0;
|
|
47
|
+
const heightDelta = drag.direction.includes("s") ? deltaY : drag.direction.includes("n") ? -deltaY : 0;
|
|
48
|
+
if (!isCorner(drag.direction)) {
|
|
49
|
+
return {
|
|
50
|
+
width: Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth + widthDelta)),
|
|
51
|
+
height: Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight + heightDelta)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Corner handles keep the same aspect ratio as the current image. Use the
|
|
55
|
+
// axis with the larger proportional movement so dragging either diagonal
|
|
56
|
+
// direction feels natural, including when one pointer axis barely moves.
|
|
57
|
+
const widthScale = (drag.startWidth + widthDelta) / drag.startWidth;
|
|
58
|
+
const heightScale = (drag.startHeight + heightDelta) / drag.startHeight;
|
|
59
|
+
const scale = Math.max(MIN_ATOM_SIZE / drag.startWidth, MIN_ATOM_SIZE / drag.startHeight, Math.abs(widthDelta / drag.startWidth) >= Math.abs(heightDelta / drag.startHeight) ? widthScale : heightScale);
|
|
60
|
+
return {
|
|
61
|
+
width: Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth * scale)),
|
|
62
|
+
height: Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight * scale)),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const boundsForDirection = (bounds, direction) => {
|
|
66
|
+
const half = HANDLE_SIZE / 2;
|
|
67
|
+
const centerX = bounds.left + bounds.width / 2 - half;
|
|
68
|
+
const centerY = bounds.top + bounds.height / 2 - half;
|
|
69
|
+
const right = bounds.left + bounds.width - half;
|
|
70
|
+
const bottom = bounds.top + bounds.height - half;
|
|
71
|
+
const left = direction.includes("w") ? bounds.left - half : direction.includes("e") ? right : centerX;
|
|
72
|
+
const top = direction.includes("n") ? bounds.top - half : direction.includes("s") ? bottom : centerY;
|
|
73
|
+
return { left, top };
|
|
74
|
+
};
|
|
16
75
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* per §1's investigation) instead of click coordinates, appearing whenever
|
|
22
|
-
* an atom is selected. The caller controls visibility by conditionally
|
|
23
|
-
* rendering this component based on atomSelected - when the selection
|
|
24
|
-
* moves off the atom, the component unmounts on its own, which is the
|
|
25
|
-
* "dismiss when selection moves away" requirement with no extra logic
|
|
26
|
-
* needed here.
|
|
27
|
-
*
|
|
28
|
-
* Post-batch follow-up ("images doesn't have a resizer"): the Resize +/-
|
|
29
|
-
* buttons were the only resize affordance - no direct drag handle existed
|
|
30
|
-
* for atoms, unlike tables. Added a corner drag handle at the atom's own
|
|
31
|
-
* bottom-right corner, reusing TableResizeHandles.tsx's exact live-preview-
|
|
32
|
-
* during-drag / commit-on-release pattern (mutate the real element's style
|
|
33
|
-
* directly while dragging for immediate feedback, commit via a real command
|
|
34
|
-
* only on release) rather than inventing a different interaction model for
|
|
35
|
-
* the second resizable-thing in this codebase.
|
|
76
|
+
* Media resize handles stay anchored to the selected media element and
|
|
77
|
+
* preview size changes live, committing one canonical resize operation when
|
|
78
|
+
* the pointer is released. The quick-action menu is optional because image
|
|
79
|
+
* right-clicks use MediaDetailsPopover directly.
|
|
36
80
|
*/
|
|
37
|
-
export function MediaOverlay({ atomElement, alt, width, height, src, resizable = true, onEdit, onResize, onResizeTo, onDelete, onDismiss }) {
|
|
81
|
+
export function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu = true, resizable = true, onEdit, onResize, onResizeTo, onDelete, onDismiss }) {
|
|
38
82
|
const rootRef = useRef(null);
|
|
39
|
-
const handleRef = useRef(null);
|
|
40
|
-
const [placement, setPlacement] = useState(null);
|
|
41
|
-
const [handlePosition, setHandlePosition] = useState(null);
|
|
42
83
|
const dragRef = useRef(null);
|
|
84
|
+
const [placement, setPlacement] = useState(null);
|
|
85
|
+
const [bounds, setBounds] = useState(null);
|
|
43
86
|
const [dragging, setDragging] = useState(false);
|
|
44
87
|
useLayoutEffect(() => {
|
|
45
88
|
const recompute = () => {
|
|
@@ -48,22 +91,37 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
|
|
|
48
91
|
const viewportWidth = atomElement.ownerDocument.defaultView?.innerWidth ?? 0;
|
|
49
92
|
const viewportHeight = atomElement.ownerDocument.defaultView?.innerHeight ?? 0;
|
|
50
93
|
const panelWidth = rootRef.current?.getBoundingClientRect().width ?? 260;
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
94
|
+
const panelHeight = rootRef.current?.getBoundingClientRect().height ?? 160;
|
|
95
|
+
const preferredLeft = x ?? rect.left;
|
|
96
|
+
const preferredTop = y ?? rect.bottom + 6;
|
|
97
|
+
const left = preferredLeft + panelWidth > viewportWidth - margin ? preferredLeft - panelWidth : preferredLeft;
|
|
98
|
+
const top = preferredTop + panelHeight > viewportHeight - margin ? preferredTop - panelHeight : preferredTop;
|
|
99
|
+
setPlacement({
|
|
100
|
+
left: Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - panelWidth - margin)),
|
|
101
|
+
top: Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - panelHeight - margin)),
|
|
102
|
+
});
|
|
103
|
+
// Handles are fixed siblings of the contenteditable surface, so they
|
|
104
|
+
// do not inherit the editor's overflow clipping. Use the visible
|
|
105
|
+
// intersection with the editor viewport to keep every handle inside
|
|
106
|
+
// that boundary when a tall image extends below the scroll area.
|
|
107
|
+
setBounds(visibleBoundsFor(atomElement, width, height));
|
|
56
108
|
};
|
|
57
109
|
recompute();
|
|
58
110
|
const observer = new ResizeObserver(recompute);
|
|
59
111
|
observer.observe(atomElement);
|
|
60
112
|
const ownerWindow = atomElement.ownerDocument.defaultView;
|
|
61
113
|
ownerWindow?.addEventListener("scroll", recompute, true);
|
|
114
|
+
ownerWindow?.addEventListener("resize", recompute);
|
|
62
115
|
return () => {
|
|
63
116
|
observer.disconnect();
|
|
64
117
|
ownerWindow?.removeEventListener("scroll", recompute, true);
|
|
118
|
+
ownerWindow?.removeEventListener("resize", recompute);
|
|
65
119
|
};
|
|
66
|
-
}, [atomElement]);
|
|
120
|
+
}, [atomElement, height, width, x, y]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (showMenu && placement)
|
|
123
|
+
rootRef.current?.focus();
|
|
124
|
+
}, [placement, showMenu]);
|
|
67
125
|
useEffect(() => {
|
|
68
126
|
const ownerWindow = atomElement.ownerDocument.defaultView;
|
|
69
127
|
if (!ownerWindow)
|
|
@@ -72,46 +130,45 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
|
|
|
72
130
|
const drag = dragRef.current;
|
|
73
131
|
if (!drag)
|
|
74
132
|
return;
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
atomElement
|
|
79
|
-
atomElement.style.height = `${liveHeight}px`;
|
|
80
|
-
if (handleRef.current) {
|
|
81
|
-
const rect = atomElement.getBoundingClientRect();
|
|
82
|
-
handleRef.current.style.left = `${rect.right - HANDLE_SIZE / 2}px`;
|
|
83
|
-
handleRef.current.style.top = `${rect.bottom - HANDLE_SIZE / 2}px`;
|
|
84
|
-
}
|
|
133
|
+
const dimensions = dimensionsForDrag(drag, event.clientX, event.clientY);
|
|
134
|
+
atomElement.style.width = `${dimensions.width}px`;
|
|
135
|
+
atomElement.style.height = `${dimensions.height}px`;
|
|
136
|
+
setBounds(visibleBoundsFor(atomElement));
|
|
85
137
|
};
|
|
86
138
|
const handleUp = (event) => {
|
|
87
139
|
const drag = dragRef.current;
|
|
88
140
|
if (!drag)
|
|
89
141
|
return;
|
|
90
|
-
const
|
|
91
|
-
const finalWidth = Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth + delta));
|
|
92
|
-
const finalHeight = Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight + (delta * drag.startHeight) / drag.startWidth));
|
|
142
|
+
const dimensions = dimensionsForDrag(drag, event.clientX, event.clientY);
|
|
93
143
|
dragRef.current = null;
|
|
94
144
|
setDragging(false);
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
// The model transaction below becomes authoritative. Clear the live
|
|
146
|
+
// preview first so a stale inline style cannot override later updates.
|
|
147
|
+
atomElement.style.removeProperty("width");
|
|
148
|
+
atomElement.style.removeProperty("height");
|
|
149
|
+
onResizeTo(dimensions.width, dimensions.height);
|
|
99
150
|
};
|
|
100
151
|
ownerWindow.addEventListener("pointermove", handleMove);
|
|
101
152
|
ownerWindow.addEventListener("pointerup", handleUp);
|
|
153
|
+
ownerWindow.addEventListener("pointercancel", handleUp);
|
|
102
154
|
return () => {
|
|
103
155
|
ownerWindow.removeEventListener("pointermove", handleMove);
|
|
104
156
|
ownerWindow.removeEventListener("pointerup", handleUp);
|
|
157
|
+
ownerWindow.removeEventListener("pointercancel", handleUp);
|
|
105
158
|
};
|
|
106
159
|
}, [atomElement, onResizeTo]);
|
|
107
160
|
const onDismissRef = useRef(onDismiss);
|
|
108
161
|
onDismissRef.current = onDismiss;
|
|
109
162
|
useEffect(() => {
|
|
163
|
+
if (!showMenu)
|
|
164
|
+
return;
|
|
110
165
|
const dismissIfOutside = (event) => {
|
|
111
166
|
const target = event.target;
|
|
167
|
+
const elementTarget = target instanceof Element ? target : target.parentElement;
|
|
168
|
+
const isResizeHandle = Boolean(elementTarget?.closest("[data-srte-media-resize-handle-direction]"));
|
|
112
169
|
if (dragRef.current)
|
|
113
170
|
return;
|
|
114
|
-
if (rootRef.current && !rootRef.current.contains(target) && !atomElement.contains(target) &&
|
|
171
|
+
if (rootRef.current && !rootRef.current.contains(target) && !atomElement.contains(target) && !isResizeHandle)
|
|
115
172
|
onDismissRef.current();
|
|
116
173
|
};
|
|
117
174
|
window.addEventListener("pointerdown", dismissIfOutside, true);
|
|
@@ -120,31 +177,39 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
|
|
|
120
177
|
window.removeEventListener("pointerdown", dismissIfOutside, true);
|
|
121
178
|
window.removeEventListener("mousedown", dismissIfOutside, true);
|
|
122
179
|
};
|
|
123
|
-
}, [atomElement]);
|
|
124
|
-
return (_jsxs(_Fragment, { children: [resizable && _jsx("div", {
|
|
180
|
+
}, [atomElement, showMenu]);
|
|
181
|
+
return (_jsxs(_Fragment, { children: [resizable && bounds && RESIZE_DIRECTIONS.map((direction) => (_jsx("div", { "data-srte-media-resize-handle": "true", "data-srte-media-resize-handle-direction": direction, role: "separator", "aria-label": `Resize media ${direction}`, onPointerDown: (event) => {
|
|
125
182
|
event.preventDefault();
|
|
126
|
-
|
|
127
|
-
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
const startWidth = width && width >= MIN_ATOM_SIZE ? width : bounds.width;
|
|
185
|
+
const startHeight = height && height >= MIN_ATOM_SIZE ? height : bounds.height;
|
|
186
|
+
dragRef.current = {
|
|
187
|
+
direction,
|
|
188
|
+
startClientX: event.clientX,
|
|
189
|
+
startClientY: event.clientY,
|
|
190
|
+
startWidth,
|
|
191
|
+
startHeight,
|
|
192
|
+
};
|
|
128
193
|
setDragging(true);
|
|
129
194
|
}, style: {
|
|
195
|
+
...boundsForDirection(bounds, direction),
|
|
130
196
|
position: "fixed",
|
|
131
|
-
|
|
132
|
-
top: handlePosition?.top ?? 0,
|
|
133
|
-
visibility: handlePosition ? "visible" : "hidden",
|
|
197
|
+
visibility: "visible",
|
|
134
198
|
width: HANDLE_SIZE,
|
|
135
199
|
height: HANDLE_SIZE,
|
|
136
|
-
|
|
200
|
+
boxSizing: "border-box",
|
|
201
|
+
borderRadius: 3,
|
|
137
202
|
background: dragging ? "var(--srte-primary)" : "var(--srte-menu-bg)",
|
|
138
203
|
border: "2px solid var(--srte-primary)",
|
|
139
|
-
cursor:
|
|
140
|
-
zIndex:
|
|
204
|
+
cursor: cursorFor(direction),
|
|
205
|
+
zIndex: 81,
|
|
141
206
|
touchAction: "none",
|
|
142
|
-
} }), _jsxs("div", { ref: rootRef, "data-srte-media-overlay": "true", role: "dialog", "aria-label": "Media actions", style: {
|
|
207
|
+
} }, direction))), showMenu && _jsxs("div", { ref: rootRef, "data-srte-media-overlay": "true", role: "dialog", "aria-label": "Media actions", tabIndex: -1, style: {
|
|
143
208
|
position: "fixed",
|
|
144
|
-
left: placement?.left ?? 0,
|
|
145
|
-
top: placement?.top ?? 0,
|
|
209
|
+
left: placement?.left ?? x ?? 0,
|
|
210
|
+
top: placement?.top ?? y ?? 0,
|
|
146
211
|
visibility: placement ? "visible" : "hidden",
|
|
147
|
-
zIndex:
|
|
212
|
+
zIndex: 80,
|
|
148
213
|
width: 260,
|
|
149
214
|
maxWidth: "calc(100vw - 16px)",
|
|
150
215
|
boxSizing: "border-box",
|
|
@@ -135,6 +135,20 @@ export declare function ToolbarGroup({ priority, children }: {
|
|
|
135
135
|
* primary row starts shedding groups - this is plain CSS visibility
|
|
136
136
|
* toggling of pre-rendered content, not JS media-query logic, matching how
|
|
137
137
|
* the rest of this responsive system already works.
|
|
138
|
+
*
|
|
139
|
+
* The panel itself renders `position: fixed` with JS-measured coordinates,
|
|
140
|
+
* the same fix and reasoning as `ToolbarDropdown` (docs/bugs/
|
|
141
|
+
* toolbar-dropdown-clipped-by-host-overflow-hidden.md) - this component was
|
|
142
|
+
* NOT covered by that fix at the time (incorrectly assumed to be
|
|
143
|
+
* unaffected since it already had its own separate mobile CSS), but it has
|
|
144
|
+
* the exact same `position: absolute` vulnerability to a host's own
|
|
145
|
+
* `overflow: hidden` container, and since this is the ONLY toolbar
|
|
146
|
+
* overflow affordance on narrow viewports (every ToolbarDropdown hides
|
|
147
|
+
* entirely below the mobile breakpoint), a clipped host is more likely to
|
|
148
|
+
* matter here, not less. Confirmed live: a real narrow host wrapping the
|
|
149
|
+
* editor in `overflow: hidden` computed this panel at `left: -214px` -
|
|
150
|
+
* mostly off-screen, functionally invisible despite being technically
|
|
151
|
+
* present in the DOM.
|
|
138
152
|
*/
|
|
139
153
|
export declare function MobileMoreMenu({ children }: {
|
|
140
154
|
children: React.ReactNode;
|
|
@@ -176,9 +176,53 @@ export function ToolbarGroup({ priority, children }) {
|
|
|
176
176
|
* primary row starts shedding groups - this is plain CSS visibility
|
|
177
177
|
* toggling of pre-rendered content, not JS media-query logic, matching how
|
|
178
178
|
* the rest of this responsive system already works.
|
|
179
|
+
*
|
|
180
|
+
* The panel itself renders `position: fixed` with JS-measured coordinates,
|
|
181
|
+
* the same fix and reasoning as `ToolbarDropdown` (docs/bugs/
|
|
182
|
+
* toolbar-dropdown-clipped-by-host-overflow-hidden.md) - this component was
|
|
183
|
+
* NOT covered by that fix at the time (incorrectly assumed to be
|
|
184
|
+
* unaffected since it already had its own separate mobile CSS), but it has
|
|
185
|
+
* the exact same `position: absolute` vulnerability to a host's own
|
|
186
|
+
* `overflow: hidden` container, and since this is the ONLY toolbar
|
|
187
|
+
* overflow affordance on narrow viewports (every ToolbarDropdown hides
|
|
188
|
+
* entirely below the mobile breakpoint), a clipped host is more likely to
|
|
189
|
+
* matter here, not less. Confirmed live: a real narrow host wrapping the
|
|
190
|
+
* editor in `overflow: hidden` computed this panel at `left: -214px` -
|
|
191
|
+
* mostly off-screen, functionally invisible despite being technically
|
|
192
|
+
* present in the DOM.
|
|
179
193
|
*/
|
|
180
194
|
export function MobileMoreMenu({ children }) {
|
|
181
195
|
const detailsRef = useRef(null);
|
|
182
196
|
useDismissDetailsOnOutsideClick(detailsRef);
|
|
183
|
-
|
|
197
|
+
const [placement, setPlacement] = useState(null);
|
|
198
|
+
useLayoutEffect(() => {
|
|
199
|
+
const details = detailsRef.current;
|
|
200
|
+
if (!details)
|
|
201
|
+
return;
|
|
202
|
+
const recompute = () => {
|
|
203
|
+
if (!details.open) {
|
|
204
|
+
setPlacement(null);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const margin = 8;
|
|
208
|
+
const triggerRect = details.getBoundingClientRect();
|
|
209
|
+
const menu = details.querySelector(":scope > .srte-menu");
|
|
210
|
+
const menuWidth = menu?.offsetWidth ?? Math.min(280, window.innerWidth - 16);
|
|
211
|
+
const menuHeight = menu?.offsetHeight ?? 0;
|
|
212
|
+
const viewportWidth = window.innerWidth;
|
|
213
|
+
const viewportHeight = window.innerHeight;
|
|
214
|
+
// Right-aligned to the trigger, matching this menu's original
|
|
215
|
+
// CSS-only `right: 0` intent - clamped into the viewport instead of
|
|
216
|
+
// being allowed to run off it or get clipped by an ancestor.
|
|
217
|
+
const preferredLeft = triggerRect.right - menuWidth;
|
|
218
|
+
const left = Math.min(Math.max(margin, preferredLeft), Math.max(margin, viewportWidth - menuWidth - margin));
|
|
219
|
+
const overflowsBottom = triggerRect.bottom + 6 + menuHeight > viewportHeight - margin;
|
|
220
|
+
const top = overflowsBottom ? Math.max(margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
|
|
221
|
+
setPlacement({ left, top });
|
|
222
|
+
};
|
|
223
|
+
recompute();
|
|
224
|
+
details.addEventListener("toggle", recompute);
|
|
225
|
+
return () => details.removeEventListener("toggle", recompute);
|
|
226
|
+
}, []);
|
|
227
|
+
return _jsxs("details", { ref: detailsRef, className: "srte-toolbar-menu srte-mobile-more", children: [_jsx("summary", { className: "srte-tool-button srte-menu-trigger", "aria-label": "More tools", onMouseDown: (event) => event.preventDefault(), children: toolbarIcons.more }), _jsx("div", { className: "srte-menu", role: "menu", "data-srte-menu-fixed": "true", style: { left: placement?.left ?? 0, top: placement?.top ?? 0, visibility: placement ? "visible" : "hidden" }, children: children })] });
|
|
184
228
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ export { ClassicEditor } from './components/ClassicEditorAuthority.js';
|
|
|
2
2
|
export type { ClassicEditorProps } from './components/ClassicEditorAuthority.js';
|
|
3
3
|
export { CanonicalAuthorityEditor } from './components/CanonicalAuthorityEditor.js';
|
|
4
4
|
export type { CanonicalAuthorityEditorProps } from './components/CanonicalAuthorityEditor.js';
|
|
5
|
+
export type { EditorCapabilityPreset } from './capabilityPresets.js';
|
|
6
|
+
export type { ToolbarTools } from './toolbarTools.js';
|
|
7
|
+
export { DEFAULT_TOOLBAR_TOOLS, resolveToolbarTools } from './toolbarTools.js';
|
|
5
8
|
export { CanonicalEditorRuntime, createCanonicalEditorRuntime } from './canonicalEditorRuntime.js';
|
|
6
9
|
export type { SmartEditorChange, SmartEditorCheckpoint, SmartEditorHandle } from './canonicalEditorRuntime.js';
|
|
7
10
|
export type { MediaManagerAdapter, MediaItem, MediaSearchQuery } from './components/MediaManager.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ClassicEditor } from './components/ClassicEditorAuthority.js';
|
|
2
2
|
export { CanonicalAuthorityEditor } from './components/CanonicalAuthorityEditor.js';
|
|
3
|
+
export { DEFAULT_TOOLBAR_TOOLS, resolveToolbarTools } from './toolbarTools.js';
|
|
3
4
|
export { CanonicalEditorRuntime, createCanonicalEditorRuntime } from './canonicalEditorRuntime.js';
|
|
4
5
|
export { DefaultMediaPicker } from './components/MediaPicker.js';
|
|
5
6
|
export { SRTE_DEFAULT_CSS, ensureStyleSheet } from './theme.js';
|
|
@@ -44,7 +44,7 @@ function ClassicEditorHost(props, ref) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
catch { }
|
|
47
|
-
}, placeholder: props.placeholder, minHeight: props.minHeight, maxHeight: props.maxHeight, readOnly: props.readOnly, table: props.table, media: props.media, formula: props.formula, showFontSize: props.showFontSize, theme: props.theme, className: props.className }) }));
|
|
47
|
+
}, placeholder: props.placeholder, minHeight: props.minHeight, maxHeight: props.maxHeight, readOnly: props.readOnly, table: props.table, media: props.media, formula: props.formula, showFontSize: props.showFontSize, tools: props.tools, theme: props.theme, className: props.className }) }));
|
|
48
48
|
}
|
|
49
49
|
const ClassicEditorHostWithRef = React.forwardRef(ClassicEditorHost);
|
|
50
50
|
function initClassicEditor(opts) {
|