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.
@@ -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
- /** False for atoms with no width/height concept (e.g. formula, sized by KaTeX from source/font-size) - hides the Resize +/- buttons and drag handle, keeping Edit/Delete. */
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
- * Context menu scope reduction: media no longer gets a generic right-click
17
- * menu - it gets this dedicated overlay, anchored to the atom's own live
18
- * element rect (the same ResizeObserver/scroll-tracking pattern
19
- * TableResizeHandles.tsx already uses for anchoring UI to a live element,
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 HANDLE_SIZE = 12;
15
- const MIN_ATOM_SIZE = 24;
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
- * Context menu scope reduction: media no longer gets a generic right-click
18
- * menu - it gets this dedicated overlay, anchored to the atom's own live
19
- * element rect (the same ResizeObserver/scroll-tracking pattern
20
- * TableResizeHandles.tsx already uses for anchoring UI to a live element,
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 left = Math.min(Math.max(margin, rect.left), Math.max(margin, viewportWidth - panelWidth - margin));
52
- const overflowsBottom = rect.bottom + 6 + 120 > viewportHeight - margin;
53
- const top = overflowsBottom ? Math.max(margin, rect.top - 6 - 120) : rect.bottom + 6;
54
- setPlacement({ left, top });
55
- setHandlePosition({ left: rect.right - HANDLE_SIZE / 2, top: rect.bottom - HANDLE_SIZE / 2 });
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 delta = Math.max(event.clientX - drag.startClientX, event.clientY - drag.startClientY);
76
- const liveWidth = Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth + delta));
77
- const liveHeight = Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight + (delta * drag.startHeight) / drag.startWidth));
78
- atomElement.style.width = `${liveWidth}px`;
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 delta = Math.max(event.clientX - drag.startClientX, event.clientY - drag.startClientY);
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
- // Committing triggers the renderer's own re-render from authoritative
96
- // model state, which overwrites the live-preview inline style above -
97
- // same reasoning as TableResizeHandles.tsx's commit-on-release.
98
- onResizeTo(finalWidth, finalHeight);
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) && target !== handleRef.current)
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", { ref: handleRef, "data-srte-media-resize-handle": "true", role: "separator", "aria-orientation": "horizontal", "aria-label": "Resize media", onPointerDown: (event) => {
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
- const rect = atomElement.getBoundingClientRect();
127
- dragRef.current = { startClientX: event.clientX, startClientY: event.clientY, startWidth: rect.width, startHeight: rect.height };
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
- left: handlePosition?.left ?? 0,
132
- top: handlePosition?.top ?? 0,
133
- visibility: handlePosition ? "visible" : "hidden",
197
+ visibility: "visible",
134
198
  width: HANDLE_SIZE,
135
199
  height: HANDLE_SIZE,
136
- borderRadius: "50%",
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: "nwse-resize",
140
- zIndex: 71,
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: 70,
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
- 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", children: children })] });
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) {