smartrte-react 1.0.0-beta.1 → 1.0.0-beta.10

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.
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
3
+ import { getFixedPositioningOrigin } from "./fixedPositioning.js";
3
4
  const HEX_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
4
5
  const normalizeHex = (value) => {
5
6
  if (!HEX_PATTERN.test(value))
@@ -138,7 +139,8 @@ export function ColorPickerPopover({ x, y, label, initialValue = "#000000", rece
138
139
  const overflowsBottom = y + height > viewportHeight - margin;
139
140
  const top = overflowsBottom ? y - height : y;
140
141
  const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - margin));
141
- setPlacement({ left: clampedLeft, top: clampedTop });
142
+ const origin = getFixedPositioningOrigin(el);
143
+ setPlacement({ left: clampedLeft - origin.left, top: clampedTop - origin.top });
142
144
  }, [x, y]);
143
145
  const stage = (nextHsv) => {
144
146
  stagedRef.current = true;
@@ -0,0 +1,35 @@
1
+ export type MediaAlign = "left" | "center" | "right" | undefined;
2
+ export interface MediaDetailsDraft {
3
+ alt: string;
4
+ href: string;
5
+ openInNewTab: boolean;
6
+ width: number | undefined;
7
+ borderRadius: number | undefined;
8
+ align: MediaAlign;
9
+ licenseDescription: string;
10
+ licenseSourceUrl: string;
11
+ licenseType: string;
12
+ licenseVersion: string;
13
+ licenseAttribution: string;
14
+ }
15
+ export interface MediaDetailsPopoverProps {
16
+ x: number;
17
+ y: number;
18
+ initial: MediaDetailsDraft;
19
+ onApply: (draft: MediaDetailsDraft) => void;
20
+ onCancel: () => void;
21
+ }
22
+ /**
23
+ * "Media details" - replaces the crude window.prompt("Alt text") edit path
24
+ * (docs/bugs/media-details-old-editor-field-parity.md) with a real settings
25
+ * panel matching the old editor's own field set, following this project's
26
+ * established compact-popover pattern (position:fixed + JS-measured
27
+ * placement, Apply/Cancel commit - same shape as TableBorderPopover for a
28
+ * multi-field dialog like this one, rather than LinkEditorPopover's simpler
29
+ * live-Enter-to-apply form).
30
+ *
31
+ * Opened directly by an image's right-click interaction; the details panel
32
+ * is the image editing surface, while resize handles remain available
33
+ * independently around the selected image.
34
+ */
35
+ export declare function MediaDetailsPopover({ x, y, initial, onApply, onCancel }: MediaDetailsPopoverProps): import("react/jsx-runtime").JSX.Element;
@@ -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
+ }
@@ -138,13 +138,13 @@ export function MediaManager(props) {
138
138
  padding: "6px 10px",
139
139
  borderRadius: 6,
140
140
  border: "1px solid var(--srte-border)",
141
- background: activeTab === "upload" ? "var(--srte-surface-subtle)" : "var(--srte-input-bg)",
141
+ background: activeTab === "upload" ? "var(--srte-muted)" : "var(--srte-input-bg)",
142
142
  color: "var(--srte-input-text)",
143
143
  }, children: "Upload" }), _jsx("button", { type: "button", onClick: () => setActiveTab("library"), style: {
144
144
  padding: "6px 10px",
145
145
  borderRadius: 6,
146
146
  border: "1px solid var(--srte-border)",
147
- background: activeTab === "library" ? "var(--srte-surface-subtle)" : "var(--srte-input-bg)",
147
+ background: activeTab === "library" ? "var(--srte-muted)" : "var(--srte-input-bg)",
148
148
  color: "var(--srte-input-text)",
149
149
  }, children: "Library" })] }), _jsx("button", { type: "button", "aria-label": "Close media library", onClick: onClose, children: "\u2715" })] }), error && (_jsx("div", { style: { color: "var(--srte-danger)", padding: "8px 14px" }, children: error })), activeTab === "upload" ? (_jsxs("div", { style: { padding: 16 }, children: [_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", multiple: true, style: { display: "none" }, onChange: (e) => handleUploadFiles(e.currentTarget.files) }), _jsx("div", { onClick: () => fileInputRef.current?.click(), onDragOver: (e) => {
150
150
  e.preventDefault();
@@ -157,7 +157,7 @@ export function MediaManager(props) {
157
157
  padding: 24,
158
158
  textAlign: "center",
159
159
  color: "var(--srte-text-muted)",
160
- background: "var(--srte-surface-subtle)",
160
+ background: "var(--srte-muted)",
161
161
  cursor: uploading ? "default" : "pointer",
162
162
  opacity: uploading ? 0.7 : 1,
163
163
  }, children: uploading ? "Uploading…" : "Click or drag images to upload" })] })) : (_jsxs("div", { style: {
@@ -207,7 +207,7 @@ export function MediaManager(props) {
207
207
  height: 24,
208
208
  border: "1px solid var(--srte-border)",
209
209
  borderRadius: 999,
210
- background: "var(--srte-surface-subtle)",
210
+ background: "var(--srte-muted)",
211
211
  color: "var(--srte-input-text)",
212
212
  cursor: "pointer",
213
213
  flex: "0 0 auto",
@@ -215,7 +215,7 @@ export function MediaManager(props) {
215
215
  fontSize: 10,
216
216
  padding: "1px 5px",
217
217
  borderRadius: 999,
218
- background: "var(--srte-surface-subtle)",
218
+ background: "var(--srte-muted)",
219
219
  color: "var(--srte-text-muted)",
220
220
  }, children: tag }, tag))) }))] }, it.id || it.url))) })] })), infoItem && (_jsx("div", { style: {
221
221
  position: "fixed",
@@ -4,8 +4,14 @@ 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;
13
+ /** False for atoms with no editable fields at all (divider, page break) - hides the Alt/Size/Source info block and the Edit button, keeping only Delete. */
14
+ editable?: boolean;
9
15
  onEdit: () => void;
10
16
  onResize: (by: number) => void;
11
17
  onResizeTo: (width: number, height: number) => void;
@@ -13,24 +19,9 @@ export interface MediaOverlayProps {
13
19
  onDismiss: () => void;
14
20
  }
15
21
  /**
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.
22
+ * Media resize handles stay anchored to the selected media element and
23
+ * preview size changes live, committing one canonical resize operation when
24
+ * the pointer is released. The quick-action menu is optional because image
25
+ * right-clicks use MediaDetailsPopover directly.
35
26
  */
36
- export declare function MediaOverlay({ atomElement, alt, width, height, src, resizable, onEdit, onResize, onResizeTo, onDelete, onDismiss }: MediaOverlayProps): import("react/jsx-runtime").JSX.Element;
27
+ export declare function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu, resizable, editable, 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, editable = 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",
@@ -159,5 +224,5 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
159
224
  event.preventDefault();
160
225
  onDismiss();
161
226
  }
162
- }, children: [_jsxs("div", { style: { fontSize: 12, marginBottom: 8, display: "grid", gap: 2, opacity: 0.85 }, children: [_jsxs("div", { children: [_jsx("strong", { children: "Alt text:" }), " ", alt || "(none)"] }), width && height && _jsxs("div", { children: [_jsx("strong", { children: "Size:" }), " ", Math.round(width), "\u00D7", Math.round(height)] }), src && _jsxs("div", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [_jsx("strong", { children: "Source:" }), " ", src] })] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [_jsx("button", { type: "button", style: buttonStyle, onClick: onEdit, children: "Edit" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(20), children: "Resize +" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(-20), children: "Resize \u03B2\u02C6\u2019" }), _jsx("button", { type: "button", style: { ...buttonStyle, color: "var(--srte-danger)", marginLeft: "auto" }, onClick: onDelete, children: "Delete" })] })] })] }));
227
+ }, children: [editable && _jsxs("div", { style: { fontSize: 12, marginBottom: 8, display: "grid", gap: 2, opacity: 0.85 }, children: [_jsxs("div", { children: [_jsx("strong", { children: "Alt text:" }), " ", alt || "(none)"] }), width && height && _jsxs("div", { children: [_jsx("strong", { children: "Size:" }), " ", Math.round(width), "\u00D7", Math.round(height)] }), src && _jsxs("div", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [_jsx("strong", { children: "Source:" }), " ", src] })] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [editable && _jsx("button", { type: "button", style: buttonStyle, onClick: onEdit, children: "Edit" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(20), children: "Resize +" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(-20), children: "Resize \u03B2\u02C6\u2019" }), _jsx("button", { type: "button", style: { ...buttonStyle, color: "var(--srte-danger)", marginLeft: "auto" }, onClick: onDelete, children: "Delete" })] })] })] }));
163
228
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import { ColorPickerPopover } from "./ColorPickerPopover.js";
4
+ import { getFixedPositioningOrigin } from "./fixedPositioning.js";
4
5
  // Widths start at 2px, not 1px - table_cell's border-collapse table (see
5
6
  // theme.ts's th/td rule) already renders a 1px ambient default gridline on
6
7
  // every cell. A custom border tied at 1px loses CSS border-collapse's
@@ -75,7 +76,8 @@ export function TableBorderPopover({ x, y, initial, recentColors, onPreview, onA
75
76
  const overflowsBottom = y + height > viewportHeight - margin;
76
77
  const top = overflowsBottom ? y - height : y;
77
78
  const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - margin));
78
- setPlacement({ left: clampedLeft, top: clampedTop });
79
+ const origin = getFixedPositioningOrigin(el);
80
+ setPlacement({ left: clampedLeft - origin.left, top: clampedTop - origin.top });
79
81
  }, [x, y]);
80
82
  useEffect(() => {
81
83
  const dismissIfOutside = (event) => {
@@ -134,6 +134,28 @@ export function TableResizeHandles({ tableElement, onResizeColumn, onResizeRow }
134
134
  if (rowElement)
135
135
  rowElement.style.height = `${target}px`;
136
136
  const actualSize = rowElement ? Math.round(rowElement.getBoundingClientRect().height) : target;
137
+ // If the row below is already sitting at its own natural content
138
+ // floor right now, it never actually had this space to begin with
139
+ // - a *previous* down-drag on this same boundary that hit this
140
+ // row's floor grew the table's total instead of taking space from
141
+ // it (see docs/bugs/row-resize-blocked-when-every-row-is-at-its-
142
+ // floor.md), and that growth is real table height, not this row's
143
+ // slack. Handing it back to the row here would inflate a row that
144
+ // never gave anything up, and a user dragging the border back to
145
+ // where it started would find the table permanently bloated with
146
+ // no way back except Undo (docs/bugs/row-resize-round-trip-
147
+ // inflates-neighbor.md). Measure the row's own floor by
148
+ // temporarily clearing its inline height override; only
149
+ // redistribute into it when it's currently holding real slack
150
+ // above that floor.
151
+ if (nextRowElement) {
152
+ const previousNextHeight = nextRowElement.style.height;
153
+ nextRowElement.style.height = "";
154
+ const nextFloor = Math.round(nextRowElement.getBoundingClientRect().height);
155
+ nextRowElement.style.height = previousNextHeight;
156
+ if (drag.nextStartSize <= nextFloor + 1)
157
+ return { size: actualSize, nextSize: drag.nextStartSize };
158
+ }
137
159
  return { size: actualSize, nextSize: Math.round(drag.nextStartSize - (actualSize - drag.startSize)) };
138
160
  }
139
161
  // Dragging down: the row below is asked to shrink to pay for the