smartrte-react 1.0.0-beta.5 → 1.0.0-beta.6

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-beta.6
4
+
5
+ - Replace the media Edit overlay's plain alt-text prompt with a full "Media details" panel: link (with open-in-new-tab), corner radius, alignment, and license fields (description, source URL, type, version, attribution), matching the pre-canonical editor's own edit-panel field set. A library-picked image's existing license metadata is now carried onto the inserted atom instead of being discarded. Depends on `smartrte-core@1.0.0-beta.3`.
6
+
3
7
  ## 1.0.0-beta.5
4
8
 
5
9
  - Add an opt-in `renderFormulaHtml` prop (on `ClassicEditor`/`CanonicalAuthorityEditor`) that bakes real KaTeX-rendered HTML into the formula elements of `onChange`'s exported HTML, instead of an empty placeholder — for hosts that display saved content directly (e.g. a read-only preview panel) without also running KaTeX against it. Depends on `smartrte-core@1.0.0-beta.2`.
@@ -19,6 +19,7 @@ import { SpecialCharacterPopover } from "./SpecialCharacterPopover.js";
19
19
  import { TableBorderPopover, BORDER_WIDTH_PRESETS } from "./TableBorderPopover.js";
20
20
  import { TableResizeHandles } from "./TableResizeHandles.js";
21
21
  import { MediaOverlay } from "./MediaOverlay.js";
22
+ import { MediaDetailsPopover } from "./MediaDetailsPopover.js";
22
23
  import { ContextMenu } from "./ContextMenu.js";
23
24
  import { ToolbarButton, ToolbarDropdown, ToolbarGroup, ToolbarMenuItem, MobileMoreMenu } from "./ToolbarPrimitives.js";
24
25
  import { exportDocxDocument, importStyledDocxDocument, importPdfDocument } from "smartrte-core/foundation";
@@ -129,6 +130,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
129
130
  const [recentColors, setRecentColors] = useState({ text: [], background: [], border: [] });
130
131
  const [tableSizePopover, setTableSizePopover] = useState(null);
131
132
  const [tableBorderPopover, setTableBorderPopover] = useState(null);
133
+ const [mediaDetailsPopover, setMediaDetailsPopover] = useState(null);
132
134
  const borderPreviewCheckpointRef = useRef(null);
133
135
  const [formulaLibraryPopover, setFormulaLibraryPopover] = useState(null);
134
136
  const [specialCharPopover, setSpecialCharPopover] = useState(null);
@@ -140,6 +142,8 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
140
142
  // since nothing was ever applied to the model).
141
143
  const colorPreviewCheckpointRef = useRef(null);
142
144
  const [contextMenu, setContextMenu] = useState(null);
145
+ const [mediaContextMenu, setMediaContextMenu] = useState(null);
146
+ const [mediaResizeTarget, setMediaResizeTarget] = useState(null);
143
147
  // Tracks the exact caret position (path+offset) the link overlay was
144
148
  // last dismissed at (Escape/outside click) - suppresses an instant
145
149
  // re-popup while the caret hasn't moved, without needing a separate
@@ -348,6 +352,15 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
348
352
  // node.type.includes("formula") for its own correct "Formula source"
349
353
  // prompt.
350
354
  const resizableAtomSelected = mediaAtomSelected && selectedAtomNode?.type !== "formula" && selectedAtomNode?.type !== "block_formula";
355
+ const selectedAtomId = atomSelected && "nodeId" in currentAtomScope ? currentAtomScope.nodeId : null;
356
+ useEffect(() => {
357
+ if (mediaContextMenu && (!mediaAtomSelected || mediaContextMenu.nodeId !== selectedAtomId))
358
+ setMediaContextMenu(null);
359
+ }, [mediaContextMenu, mediaAtomSelected, selectedAtomId]);
360
+ useEffect(() => {
361
+ if (mediaResizeTarget && (!mediaAtomSelected || mediaResizeTarget.nodeId !== selectedAtomId))
362
+ setMediaResizeTarget(null);
363
+ }, [mediaResizeTarget, mediaAtomSelected, selectedAtomId]);
351
364
  // Context menu scope reduction: link no longer gets a right-click menu -
352
365
  // LinkEditorPopover (already built and wired for the toolbar's Link
353
366
  // button, per §1's investigation) auto-appears, anchored to the link's
@@ -803,13 +816,10 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
803
816
  * the same way Tab special-cases list.indent/outdent's dynamic params.
804
817
  */
805
818
  /**
806
- * Context menu scope reduction: table-only now. Marks (already on the
807
- * toolbar) and atom/link (moved to their own dedicated overlays -
808
- * MediaOverlay below, and the auto-triggered LinkEditorPopover - see
809
- * linkAnchorElement) no longer register contextMenu contributions at
810
- * all (marks/plugin.ts, atom/plugin.ts) or get hardcoded item blocks
811
- * here - only table plugin contributions (the generic loop) and the
812
- * cell-colour items remain.
819
+ * The generic plugin context menu remains table-scoped. Images use their
820
+ * direct right-click details editor below, other media keeps its dedicated
821
+ * quick-action menu, while marks stay on the toolbar and links use the
822
+ * auto-triggered LinkEditorPopover.
813
823
  */
814
824
  const resolveContextMenuItems = () => {
815
825
  const many = () => ids(128);
@@ -1028,6 +1038,19 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1028
1038
  insertBlockAtom("block_image", {
1029
1039
  src: item.url, alt: item.alt || item.title || "Image", decorative: false, status: "ready",
1030
1040
  ...(item.width ? { width: item.width } : {}), ...(item.height ? { height: item.height } : {}),
1041
+ // docs/bugs/media-details-old-editor-field-parity.md: item.license was
1042
+ // already fetched and shown in MediaManager's own library-browser info
1043
+ // panel (MediaManager.tsx), but had nowhere to persist once inserted -
1044
+ // the atom schema had no license attrs at all, so this real,
1045
+ // already-available data was silently discarded at exactly this call
1046
+ // site. `licenseVersion` has no MediaItem.license equivalent (its
1047
+ // shape has no separate version field) - left for the user to fill in
1048
+ // via the media details panel, same as a freshly uploaded image.
1049
+ ...(item.license?.workName || item.license?.licenseText
1050
+ ? { licenseDescription: [item.license.workName, item.license.licenseText].filter(Boolean).join(" - ") } : {}),
1051
+ ...(item.license?.sourceUrl ? { licenseSourceUrl: item.license.sourceUrl } : {}),
1052
+ ...(item.license?.licenseType ? { licenseType: item.license.licenseType } : {}),
1053
+ ...(item.license?.author ? { licenseAttribution: item.license.author } : {}),
1031
1054
  }, createNodeId());
1032
1055
  };
1033
1056
  const commentAuthorId = authorId || "anonymous";
@@ -1211,6 +1234,27 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1211
1234
  runtime.focus();
1212
1235
  setLinkPopover(null);
1213
1236
  };
1237
+ const openMediaDetails = (scope, node, element) => {
1238
+ if (scope.kind !== "atomic-node")
1239
+ return;
1240
+ const rect = (element || selectedAtomElement)?.getBoundingClientRect();
1241
+ setMediaDetailsPopover({
1242
+ x: rect ? rect.left : 0, y: rect ? rect.bottom + 6 : 0, scope,
1243
+ initial: {
1244
+ alt: String(node.attrs?.alt || ""),
1245
+ href: String(node.attrs?.href || ""),
1246
+ openInNewTab: node.attrs?.target === "_blank",
1247
+ width: typeof node.attrs?.width === "number" ? node.attrs.width : undefined,
1248
+ borderRadius: typeof node.attrs?.borderRadius === "number" ? node.attrs.borderRadius : undefined,
1249
+ align: node.attrs?.align === "left" || node.attrs?.align === "center" || node.attrs?.align === "right" ? node.attrs.align : undefined,
1250
+ licenseDescription: String(node.attrs?.licenseDescription || ""),
1251
+ licenseSourceUrl: String(node.attrs?.licenseSourceUrl || ""),
1252
+ licenseType: String(node.attrs?.licenseType || ""),
1253
+ licenseVersion: String(node.attrs?.licenseVersion || ""),
1254
+ licenseAttribution: String(node.attrs?.licenseAttribution || ""),
1255
+ },
1256
+ });
1257
+ };
1214
1258
  const editSelectedAtom = (resizeBy) => {
1215
1259
  const scope = atomScope();
1216
1260
  if (scope.kind !== "atomic-node")
@@ -1218,16 +1262,59 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1218
1262
  const node = findNode(runtime.editor.document, scope.nodeId);
1219
1263
  if (!node)
1220
1264
  return;
1265
+ if (resizeBy === undefined && !node.type.includes("formula")) {
1266
+ // Images: the old window.prompt("Alt text") one-field edit is
1267
+ // replaced by the full Media details panel (docs/bugs/
1268
+ // media-details-old-editor-field-parity.md) - formulas keep their
1269
+ // existing prompt unchanged below, since this panel's fields
1270
+ // (link/align/radius/license) don't apply to a formula atom at all.
1271
+ openMediaDetails(scope, node);
1272
+ return;
1273
+ }
1221
1274
  const operations = resizeBy === undefined
1222
- ? updateAtom(runtime.editor.document, scope, node.type.includes("formula")
1223
- ? { attrs: { source: window.prompt("Formula source", String(node.attrs?.source || "")) || node.attrs?.source } }
1224
- : { attrs: { alt: window.prompt("Alt text", String(node.attrs?.alt || "")) ?? node.attrs?.alt } }, blockContext())
1275
+ ? updateAtom(runtime.editor.document, scope, { attrs: { source: window.prompt("Formula source", String(node.attrs?.source || "")) || node.attrs?.source } }, blockContext())
1225
1276
  : resizeAtom(runtime.editor.document, scope, {
1226
1277
  width: Math.max(16, Number(node.attrs?.width || 160) + resizeBy),
1227
1278
  height: Math.max(16, Number(node.attrs?.height || 90) + resizeBy),
1228
1279
  }, blockContext());
1229
1280
  runtime.executeOperations(operations, resizeBy === undefined ? {} : { historyGroup: `resize-${scope.nodeId}` });
1230
1281
  };
1282
+ const applyMediaDetails = (draft) => {
1283
+ if (!mediaDetailsPopover)
1284
+ return;
1285
+ const { scope } = mediaDetailsPopover;
1286
+ if (scope.kind !== "atomic-node") {
1287
+ setMediaDetailsPopover(null);
1288
+ return;
1289
+ }
1290
+ const node = findNode(runtime.editor.document, scope.nodeId);
1291
+ const currentWidth = typeof node?.attrs?.width === "number" ? node.attrs.width : undefined;
1292
+ const currentHeight = typeof node?.attrs?.height === "number" ? node.attrs.height : undefined;
1293
+ // Width preserves the original aspect ratio (matches the drag handle
1294
+ // and Resize +/- buttons' own behavior) rather than exposing a separate
1295
+ // Height field the old editor's own screenshot didn't have either.
1296
+ const resizedHeight = draft.width && currentWidth && currentHeight
1297
+ ? Math.round((draft.width * currentHeight) / currentWidth)
1298
+ : currentHeight;
1299
+ setMediaDetailsPopover(null);
1300
+ runtime.executeOperations(updateAtom(runtime.editor.document, scope, {
1301
+ attrs: {
1302
+ alt: draft.alt,
1303
+ href: draft.href || undefined,
1304
+ target: draft.href && draft.openInNewTab ? "_blank" : undefined,
1305
+ width: draft.width ?? currentWidth,
1306
+ height: draft.width ? resizedHeight : currentHeight,
1307
+ borderRadius: draft.borderRadius || undefined,
1308
+ align: draft.align,
1309
+ licenseDescription: draft.licenseDescription || undefined,
1310
+ licenseSourceUrl: draft.licenseSourceUrl || undefined,
1311
+ licenseType: draft.licenseType || undefined,
1312
+ licenseVersion: draft.licenseVersion || undefined,
1313
+ licenseAttribution: draft.licenseAttribution || undefined,
1314
+ },
1315
+ }, blockContext()));
1316
+ runtime.focus();
1317
+ };
1231
1318
  /** MediaOverlay's drag-corner-handle commit - explicit width/height rather than editSelectedAtom's fixed +/-20 increment. */
1232
1319
  const resizeSelectedAtomTo = (width, height) => {
1233
1320
  const scope = atomScope();
@@ -1396,7 +1483,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1396
1483
  setLinkOverlayDismissedAt(runtime.editor.selection.head);
1397
1484
  setLinkPopover(null);
1398
1485
  runtime.focus();
1399
- } }), !readOnly && mediaAtomSelected && selectedAtomElement && _jsx(MediaOverlay, { atomElement: selectedAtomElement, alt: typeof selectedAtomNode?.attrs?.alt === "string" ? selectedAtomNode.attrs.alt : "", width: typeof selectedAtomNode?.attrs?.width === "number" ? selectedAtomNode.attrs.width : undefined, height: typeof selectedAtomNode?.attrs?.height === "number" ? selectedAtomNode.attrs.height : undefined, src: typeof selectedAtomNode?.attrs?.src === "string" ? selectedAtomNode.attrs.src : undefined, resizable: resizableAtomSelected, onEdit: () => editSelectedAtom(), onResize: (by) => editSelectedAtom(by), onResizeTo: resizeSelectedAtomTo, onDelete: deleteSelectedAtom, onDismiss: () => runtime.focus() }), colorPopover && _jsx(ColorPickerPopover, { x: colorPopover.x, y: colorPopover.y, label: colorPopover.target.kind === "mark"
1486
+ } }), !readOnly && mediaResizeTarget && mediaResizeTarget.nodeId === selectedAtomId && mediaAtomSelected && selectedAtomElement && _jsx(MediaOverlay, { atomElement: selectedAtomElement, alt: typeof selectedAtomNode?.attrs?.alt === "string" ? selectedAtomNode.attrs.alt : "", width: typeof selectedAtomNode?.attrs?.width === "number" ? selectedAtomNode.attrs.width : undefined, height: typeof selectedAtomNode?.attrs?.height === "number" ? selectedAtomNode.attrs.height : undefined, src: typeof selectedAtomNode?.attrs?.src === "string" ? selectedAtomNode.attrs.src : undefined, showMenu: false, resizable: resizableAtomSelected, onEdit: () => undefined, onResize: () => undefined, onResizeTo: resizeSelectedAtomTo, onDelete: () => undefined, onDismiss: () => setMediaResizeTarget(null) }), !readOnly && mediaContextMenu && mediaAtomSelected && selectedAtomElement && _jsx(MediaOverlay, { atomElement: selectedAtomElement, alt: typeof selectedAtomNode?.attrs?.alt === "string" ? selectedAtomNode.attrs.alt : "", width: typeof selectedAtomNode?.attrs?.width === "number" ? selectedAtomNode.attrs.width : undefined, height: typeof selectedAtomNode?.attrs?.height === "number" ? selectedAtomNode.attrs.height : undefined, src: typeof selectedAtomNode?.attrs?.src === "string" ? selectedAtomNode.attrs.src : undefined, x: mediaContextMenu.x, y: mediaContextMenu.y, resizable: resizableAtomSelected, onEdit: () => { setMediaContextMenu(null); editSelectedAtom(); }, onResize: (by) => { setMediaContextMenu(null); editSelectedAtom(by); }, onResizeTo: resizeSelectedAtomTo, onDelete: () => { setMediaContextMenu(null); deleteSelectedAtom(); }, onDismiss: () => { setMediaContextMenu(null); runtime.focus(); } }), colorPopover && _jsx(ColorPickerPopover, { x: colorPopover.x, y: colorPopover.y, label: colorPopover.target.kind === "mark"
1400
1487
  ? (colorPopover.target.markId === "textColor" ? "Text colour" : "Background colour")
1401
1488
  : colorPopover.target.attr === "textColor" ? "Cell text colour" : "Cell background colour", ...(colorPopover.initialValue ? { initialValue: colorPopover.initialValue } : {}), recentColors: recentColors[colorBucketFor(colorPopover.target)], onPreview: previewColor, onApply: applyColor, onCancel: cancelColorPopover }), tableSizePopover && _jsx(TableSizePickerPopover, { x: tableSizePopover.x, y: tableSizePopover.y, onInsert: (rows, columns) => {
1402
1489
  setTableSizePopover(null);
@@ -1405,7 +1492,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1405
1492
  }, onCancel: () => {
1406
1493
  setTableSizePopover(null);
1407
1494
  runtime.focus();
1408
- } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), formulaLibraryPopover && _jsx(FormulaLibraryPopover, { x: formulaLibraryPopover.x, y: formulaLibraryPopover.y, onInsert: insertFormulaFromLibrary, onCancel: () => { setFormulaLibraryPopover(null); runtime.focus(); } }), specialCharPopover && _jsx(SpecialCharacterPopover, { x: specialCharPopover.x, y: specialCharPopover.y, recentCharacters: recentSpecialChars, onInsert: insertSpecialCharacter, onCancel: () => { setSpecialCharPopover(null); runtime.focus(); } }), !readOnly && selectedTableElement && _jsx(TableResizeHandles, { tableElement: selectedTableElement, onResizeColumn: resizeTableColumn, onResizeRow: resizeTableRow }), contextMenu && _jsx(ContextMenu, { x: contextMenu.x, y: contextMenu.y, items: resolveContextMenuItems(), onDismiss: () => { setContextMenu(null); runtime.focus(); } }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onMouseDown: (event) => {
1495
+ } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), mediaDetailsPopover && _jsx(MediaDetailsPopover, { x: mediaDetailsPopover.x, y: mediaDetailsPopover.y, initial: mediaDetailsPopover.initial, onApply: applyMediaDetails, onCancel: () => { setMediaDetailsPopover(null); runtime.focus(); } }), formulaLibraryPopover && _jsx(FormulaLibraryPopover, { x: formulaLibraryPopover.x, y: formulaLibraryPopover.y, onInsert: insertFormulaFromLibrary, onCancel: () => { setFormulaLibraryPopover(null); runtime.focus(); } }), specialCharPopover && _jsx(SpecialCharacterPopover, { x: specialCharPopover.x, y: specialCharPopover.y, recentCharacters: recentSpecialChars, onInsert: insertSpecialCharacter, onCancel: () => { setSpecialCharPopover(null); runtime.focus(); } }), !readOnly && selectedTableElement && _jsx(TableResizeHandles, { tableElement: selectedTableElement, onResizeColumn: resizeTableColumn, onResizeRow: resizeTableRow }), contextMenu && _jsx(ContextMenu, { x: contextMenu.x, y: contextMenu.y, items: resolveContextMenuItems(), onDismiss: () => { setContextMenu(null); runtime.focus(); } }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onMouseDown: (event) => {
1409
1496
  // A Ctrl/Cmd+click on a link is meant to open it (onClick below),
1410
1497
  // not move the caret into it - but native mousedown's own default
1411
1498
  // action *is* the caret-repositioning, and mousedown fires (and
@@ -1420,27 +1507,45 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1420
1507
  // completed render in a varying number of passes, so the overlay
1421
1508
  // could already be open before the flag was ever set.)
1422
1509
  const anchor = event.target?.closest("a[href]");
1423
- if (anchor && (event.metaKey || event.ctrlKey))
1510
+ const linkedMedia = event.target?.closest("[data-smart-href]");
1511
+ if ((anchor || linkedMedia) && (event.metaKey || event.ctrlKey))
1424
1512
  event.preventDefault();
1425
1513
  }, onClick: (event) => {
1426
1514
  const anchor = event.target?.closest("a[href]");
1427
- if (!anchor || !(event.metaKey || event.ctrlKey))
1515
+ // An image atom with a Link set (docs/bugs/media-details-old-editor-field-parity.md)
1516
+ // - plain data attributes, not a real <a> wrapper, in the live DOM
1517
+ // (see surface/renderer.ts's own reasoning); Ctrl/Cmd+click opens it
1518
+ // the same way a real text link does, matching this project's only
1519
+ // existing link-opening convention rather than inventing a second one.
1520
+ const linkedMedia = event.target?.closest("[data-smart-href]");
1521
+ const href = anchor?.href || linkedMedia?.getAttribute("data-smart-href");
1522
+ if (href && (event.metaKey || event.ctrlKey)) {
1523
+ event.preventDefault();
1524
+ window.open(href, "_blank", "noopener,noreferrer");
1428
1525
  return;
1429
- event.preventDefault();
1430
- window.open(anchor.href, "_blank", "noopener,noreferrer");
1526
+ }
1527
+ const imageTarget = event.target?.closest("[data-smart-type=\"image\"], [data-smart-type=\"block_image\"]");
1528
+ if (!imageTarget)
1529
+ return;
1530
+ setMediaContextMenu(null);
1531
+ const mappedImage = runtime.surface.renderer?.mapping.domToNode(imageTarget);
1532
+ if (mappedImage)
1533
+ setMediaResizeTarget({ nodeId: mappedImage.nodeId });
1431
1534
  }, onContextMenu: (event) => {
1432
1535
  if (readOnly)
1433
1536
  return;
1434
1537
  event.preventDefault();
1538
+ setMediaContextMenu(null);
1435
1539
  // Atom selection (unlike caret/text placement) is not native
1436
1540
  // browser behavior - it's InputController's own clickListener
1437
1541
  // (surface/input.ts), bound to the "click" DOM event, which never
1438
1542
  // fires for a right-click. Without this, right-clicking an atom
1439
1543
  // directly (no preceding left-click) left whatever selection was
1440
1544
  // already there in place, so atomic-node scope never resolved and
1441
- // MediaOverlay (which shows whenever atomSelected is true) never
1442
- // appeared - right-clicking media looked like right-clicking
1443
- // nothing. Mirrors clickListener's own atom-selection logic
1545
+ // the media interaction (details editor for images, quick-action
1546
+ // menu for other media) never appeared - right-clicking media looked
1547
+ // like right-clicking nothing. Mirrors clickListener's own
1548
+ // atom-selection logic
1444
1549
  // exactly.
1445
1550
  const atomTarget = event.target?.closest("[data-smart-atomic]");
1446
1551
  const mapped = atomTarget ? runtime.surface.renderer?.mapping.domToNode(atomTarget) : null;
@@ -1450,6 +1555,19 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1450
1555
  runtime.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
1451
1556
  runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1452
1557
  }
1558
+ if (mapped.node.type !== "divider" && mapped.node.type !== "unknown") {
1559
+ setContextMenu(null);
1560
+ const isImage = mapped.node.type === "image" || mapped.node.type === "block_image";
1561
+ if (isImage) {
1562
+ const scope = runtime.editor.resolveScope({ want: "atomic-node" });
1563
+ setMediaResizeTarget({ nodeId: mapped.nodeId });
1564
+ if (scope.kind === "atomic-node")
1565
+ openMediaDetails(scope, mapped.node, atomTarget || undefined);
1566
+ return;
1567
+ }
1568
+ setMediaContextMenu({ x: event.clientX, y: event.clientY, nodeId: mapped.nodeId });
1569
+ return;
1570
+ }
1453
1571
  }
1454
1572
  else if (isCollapsedTextSelection(runtime.editor.selection)) {
1455
1573
  // Chromium/Firefox reposition the native caret to the click
@@ -1480,10 +1598,8 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1480
1598
  runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1481
1599
  }
1482
1600
  }
1483
- // Table-only now (context menu scope reduction) - media/link moved
1484
- // to their own dedicated overlays, marks live on the toolbar only.
1485
- // A right-click that doesn't resolve to a table cell opens nothing
1486
- // at all, rather than an empty "No actions here" menu.
1601
+ // A right-click that is not handled by the media branch above and
1602
+ // does not resolve to a table cell opens no generic menu.
1487
1603
  const rightClickTableScope = runtime.editor.resolveScope({ want: "table-grid" });
1488
1604
  if ("kind" in rightClickTableScope && rightClickTableScope.kind === "table-grid") {
1489
1605
  setContextMenu({ x: event.clientX, y: event.clientY });
@@ -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
+ }
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-react",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
4
4
  "description": "A powerful, feature-rich Rich Text Editor for React with support for tables, mathematical formulas (LaTeX/KaTeX), and media management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -84,7 +84,7 @@
84
84
  "lucide-react": "^1.35.0",
85
85
  "mammoth": "^1.11.0",
86
86
  "pdfjs-dist": "^5.4.530",
87
- "smartrte-core": "^1.0.0-beta.2"
87
+ "smartrte-core": "^1.0.0-beta.3"
88
88
  },
89
89
  "scripts": {
90
90
  "build": "rm -rf dist && tsc -p tsconfig.json",