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 +4 -0
- package/dist/components/CanonicalAuthorityEditor.js +139 -23
- package/dist/components/MediaDetailsPopover.d.ts +35 -0
- package/dist/components/MediaDetailsPopover.js +136 -0
- package/dist/components/MediaOverlay.d.ts +10 -21
- package/dist/components/MediaOverlay.js +128 -63
- package/package.json +2 -2
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
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
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
|
-
//
|
|
1442
|
-
// appeared - right-clicking media looked
|
|
1443
|
-
// nothing. Mirrors clickListener's own
|
|
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
|
-
//
|
|
1484
|
-
//
|
|
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
|
-
|
|
7
|
+
x?: number;
|
|
8
|
+
y?: number;
|
|
9
|
+
/** Render only the handles when the media details editor owns the interaction surface. */
|
|
10
|
+
showMenu?: boolean;
|
|
11
|
+
/** False for atoms with no width/height concept (e.g. formula, sized by KaTeX from source/font-size) - hides resize controls and handles, keeping Edit/Delete. */
|
|
8
12
|
resizable?: boolean;
|
|
9
13
|
onEdit: () => void;
|
|
10
14
|
onResize: (by: number) => void;
|
|
@@ -13,24 +17,9 @@ export interface MediaOverlayProps {
|
|
|
13
17
|
onDismiss: () => void;
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* per §1's investigation) instead of click coordinates, appearing whenever
|
|
21
|
-
* an atom is selected. The caller controls visibility by conditionally
|
|
22
|
-
* rendering this component based on atomSelected - when the selection
|
|
23
|
-
* moves off the atom, the component unmounts on its own, which is the
|
|
24
|
-
* "dismiss when selection moves away" requirement with no extra logic
|
|
25
|
-
* needed here.
|
|
26
|
-
*
|
|
27
|
-
* Post-batch follow-up ("images doesn't have a resizer"): the Resize +/-
|
|
28
|
-
* buttons were the only resize affordance - no direct drag handle existed
|
|
29
|
-
* for atoms, unlike tables. Added a corner drag handle at the atom's own
|
|
30
|
-
* bottom-right corner, reusing TableResizeHandles.tsx's exact live-preview-
|
|
31
|
-
* during-drag / commit-on-release pattern (mutate the real element's style
|
|
32
|
-
* directly while dragging for immediate feedback, commit via a real command
|
|
33
|
-
* only on release) rather than inventing a different interaction model for
|
|
34
|
-
* the second resizable-thing in this codebase.
|
|
20
|
+
* Media resize handles stay anchored to the selected media element and
|
|
21
|
+
* preview size changes live, committing one canonical resize operation when
|
|
22
|
+
* the pointer is released. The quick-action menu is optional because image
|
|
23
|
+
* right-clicks use MediaDetailsPopover directly.
|
|
35
24
|
*/
|
|
36
|
-
export declare function MediaOverlay({ atomElement, alt, width, height, src, resizable, onEdit, onResize, onResizeTo, onDelete, onDismiss }: MediaOverlayProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu, resizable, onEdit, onResize, onResizeTo, onDelete, onDismiss }: MediaOverlayProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
const RESIZE_DIRECTIONS = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
|
4
|
+
const HANDLE_SIZE = 10;
|
|
5
|
+
const MIN_ATOM_SIZE = 24;
|
|
3
6
|
const buttonStyle = {
|
|
4
7
|
minHeight: 32,
|
|
5
8
|
padding: "0 10px",
|
|
@@ -11,35 +14,75 @@ const buttonStyle = {
|
|
|
11
14
|
fontWeight: 500,
|
|
12
15
|
fontSize: 13,
|
|
13
16
|
};
|
|
14
|
-
const
|
|
15
|
-
|
|
17
|
+
const cursorFor = (direction) => {
|
|
18
|
+
if (direction === "n" || direction === "s")
|
|
19
|
+
return "ns-resize";
|
|
20
|
+
if (direction === "e" || direction === "w")
|
|
21
|
+
return "ew-resize";
|
|
22
|
+
return direction === "ne" || direction === "sw" ? "nesw-resize" : "nwse-resize";
|
|
23
|
+
};
|
|
24
|
+
const isCorner = (direction) => direction.length === 2;
|
|
25
|
+
const visibleBoundsFor = (atomElement, modelWidth, modelHeight) => {
|
|
26
|
+
const rect = atomElement.getBoundingClientRect();
|
|
27
|
+
const editorElement = atomElement.closest(".srte-editor");
|
|
28
|
+
const editorRect = editorElement?.getBoundingClientRect();
|
|
29
|
+
const inset = HANDLE_SIZE / 2;
|
|
30
|
+
// Broken or still-loading images can expose only a tiny fallback box.
|
|
31
|
+
// Use the canonical dimensions for that case, but use the real rendered
|
|
32
|
+
// dimensions whenever the image has a meaningful layout box.
|
|
33
|
+
const imageWidth = rect.width >= MIN_ATOM_SIZE ? rect.width : modelWidth && modelWidth >= MIN_ATOM_SIZE ? modelWidth : rect.width;
|
|
34
|
+
const imageHeight = rect.height >= MIN_ATOM_SIZE ? rect.height : modelHeight && modelHeight >= MIN_ATOM_SIZE ? modelHeight : rect.height;
|
|
35
|
+
const left = Math.max(rect.left, editorRect ? editorRect.left + inset : rect.left);
|
|
36
|
+
const top = Math.max(rect.top, editorRect ? editorRect.top + inset : rect.top);
|
|
37
|
+
const right = Math.min(rect.left + imageWidth, editorRect ? editorRect.right - inset : rect.left + imageWidth);
|
|
38
|
+
const bottom = Math.min(rect.top + imageHeight, editorRect ? editorRect.bottom - inset : rect.top + imageHeight);
|
|
39
|
+
if (right <= left || bottom <= top)
|
|
40
|
+
return null;
|
|
41
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
42
|
+
};
|
|
43
|
+
const dimensionsForDrag = (drag, clientX, clientY) => {
|
|
44
|
+
const deltaX = clientX - drag.startClientX;
|
|
45
|
+
const deltaY = clientY - drag.startClientY;
|
|
46
|
+
const widthDelta = drag.direction.includes("e") ? deltaX : drag.direction.includes("w") ? -deltaX : 0;
|
|
47
|
+
const heightDelta = drag.direction.includes("s") ? deltaY : drag.direction.includes("n") ? -deltaY : 0;
|
|
48
|
+
if (!isCorner(drag.direction)) {
|
|
49
|
+
return {
|
|
50
|
+
width: Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth + widthDelta)),
|
|
51
|
+
height: Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight + heightDelta)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Corner handles keep the same aspect ratio as the current image. Use the
|
|
55
|
+
// axis with the larger proportional movement so dragging either diagonal
|
|
56
|
+
// direction feels natural, including when one pointer axis barely moves.
|
|
57
|
+
const widthScale = (drag.startWidth + widthDelta) / drag.startWidth;
|
|
58
|
+
const heightScale = (drag.startHeight + heightDelta) / drag.startHeight;
|
|
59
|
+
const scale = Math.max(MIN_ATOM_SIZE / drag.startWidth, MIN_ATOM_SIZE / drag.startHeight, Math.abs(widthDelta / drag.startWidth) >= Math.abs(heightDelta / drag.startHeight) ? widthScale : heightScale);
|
|
60
|
+
return {
|
|
61
|
+
width: Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth * scale)),
|
|
62
|
+
height: Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight * scale)),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const boundsForDirection = (bounds, direction) => {
|
|
66
|
+
const half = HANDLE_SIZE / 2;
|
|
67
|
+
const centerX = bounds.left + bounds.width / 2 - half;
|
|
68
|
+
const centerY = bounds.top + bounds.height / 2 - half;
|
|
69
|
+
const right = bounds.left + bounds.width - half;
|
|
70
|
+
const bottom = bounds.top + bounds.height - half;
|
|
71
|
+
const left = direction.includes("w") ? bounds.left - half : direction.includes("e") ? right : centerX;
|
|
72
|
+
const top = direction.includes("n") ? bounds.top - half : direction.includes("s") ? bottom : centerY;
|
|
73
|
+
return { left, top };
|
|
74
|
+
};
|
|
16
75
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* per §1's investigation) instead of click coordinates, appearing whenever
|
|
22
|
-
* an atom is selected. The caller controls visibility by conditionally
|
|
23
|
-
* rendering this component based on atomSelected - when the selection
|
|
24
|
-
* moves off the atom, the component unmounts on its own, which is the
|
|
25
|
-
* "dismiss when selection moves away" requirement with no extra logic
|
|
26
|
-
* needed here.
|
|
27
|
-
*
|
|
28
|
-
* Post-batch follow-up ("images doesn't have a resizer"): the Resize +/-
|
|
29
|
-
* buttons were the only resize affordance - no direct drag handle existed
|
|
30
|
-
* for atoms, unlike tables. Added a corner drag handle at the atom's own
|
|
31
|
-
* bottom-right corner, reusing TableResizeHandles.tsx's exact live-preview-
|
|
32
|
-
* during-drag / commit-on-release pattern (mutate the real element's style
|
|
33
|
-
* directly while dragging for immediate feedback, commit via a real command
|
|
34
|
-
* only on release) rather than inventing a different interaction model for
|
|
35
|
-
* the second resizable-thing in this codebase.
|
|
76
|
+
* Media resize handles stay anchored to the selected media element and
|
|
77
|
+
* preview size changes live, committing one canonical resize operation when
|
|
78
|
+
* the pointer is released. The quick-action menu is optional because image
|
|
79
|
+
* right-clicks use MediaDetailsPopover directly.
|
|
36
80
|
*/
|
|
37
|
-
export function MediaOverlay({ atomElement, alt, width, height, src, resizable = true, onEdit, onResize, onResizeTo, onDelete, onDismiss }) {
|
|
81
|
+
export function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu = true, resizable = true, onEdit, onResize, onResizeTo, onDelete, onDismiss }) {
|
|
38
82
|
const rootRef = useRef(null);
|
|
39
|
-
const handleRef = useRef(null);
|
|
40
|
-
const [placement, setPlacement] = useState(null);
|
|
41
|
-
const [handlePosition, setHandlePosition] = useState(null);
|
|
42
83
|
const dragRef = useRef(null);
|
|
84
|
+
const [placement, setPlacement] = useState(null);
|
|
85
|
+
const [bounds, setBounds] = useState(null);
|
|
43
86
|
const [dragging, setDragging] = useState(false);
|
|
44
87
|
useLayoutEffect(() => {
|
|
45
88
|
const recompute = () => {
|
|
@@ -48,22 +91,37 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
|
|
|
48
91
|
const viewportWidth = atomElement.ownerDocument.defaultView?.innerWidth ?? 0;
|
|
49
92
|
const viewportHeight = atomElement.ownerDocument.defaultView?.innerHeight ?? 0;
|
|
50
93
|
const panelWidth = rootRef.current?.getBoundingClientRect().width ?? 260;
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
94
|
+
const panelHeight = rootRef.current?.getBoundingClientRect().height ?? 160;
|
|
95
|
+
const preferredLeft = x ?? rect.left;
|
|
96
|
+
const preferredTop = y ?? rect.bottom + 6;
|
|
97
|
+
const left = preferredLeft + panelWidth > viewportWidth - margin ? preferredLeft - panelWidth : preferredLeft;
|
|
98
|
+
const top = preferredTop + panelHeight > viewportHeight - margin ? preferredTop - panelHeight : preferredTop;
|
|
99
|
+
setPlacement({
|
|
100
|
+
left: Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - panelWidth - margin)),
|
|
101
|
+
top: Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - panelHeight - margin)),
|
|
102
|
+
});
|
|
103
|
+
// Handles are fixed siblings of the contenteditable surface, so they
|
|
104
|
+
// do not inherit the editor's overflow clipping. Use the visible
|
|
105
|
+
// intersection with the editor viewport to keep every handle inside
|
|
106
|
+
// that boundary when a tall image extends below the scroll area.
|
|
107
|
+
setBounds(visibleBoundsFor(atomElement, width, height));
|
|
56
108
|
};
|
|
57
109
|
recompute();
|
|
58
110
|
const observer = new ResizeObserver(recompute);
|
|
59
111
|
observer.observe(atomElement);
|
|
60
112
|
const ownerWindow = atomElement.ownerDocument.defaultView;
|
|
61
113
|
ownerWindow?.addEventListener("scroll", recompute, true);
|
|
114
|
+
ownerWindow?.addEventListener("resize", recompute);
|
|
62
115
|
return () => {
|
|
63
116
|
observer.disconnect();
|
|
64
117
|
ownerWindow?.removeEventListener("scroll", recompute, true);
|
|
118
|
+
ownerWindow?.removeEventListener("resize", recompute);
|
|
65
119
|
};
|
|
66
|
-
}, [atomElement]);
|
|
120
|
+
}, [atomElement, height, width, x, y]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (showMenu && placement)
|
|
123
|
+
rootRef.current?.focus();
|
|
124
|
+
}, [placement, showMenu]);
|
|
67
125
|
useEffect(() => {
|
|
68
126
|
const ownerWindow = atomElement.ownerDocument.defaultView;
|
|
69
127
|
if (!ownerWindow)
|
|
@@ -72,46 +130,45 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
|
|
|
72
130
|
const drag = dragRef.current;
|
|
73
131
|
if (!drag)
|
|
74
132
|
return;
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
atomElement
|
|
79
|
-
atomElement.style.height = `${liveHeight}px`;
|
|
80
|
-
if (handleRef.current) {
|
|
81
|
-
const rect = atomElement.getBoundingClientRect();
|
|
82
|
-
handleRef.current.style.left = `${rect.right - HANDLE_SIZE / 2}px`;
|
|
83
|
-
handleRef.current.style.top = `${rect.bottom - HANDLE_SIZE / 2}px`;
|
|
84
|
-
}
|
|
133
|
+
const dimensions = dimensionsForDrag(drag, event.clientX, event.clientY);
|
|
134
|
+
atomElement.style.width = `${dimensions.width}px`;
|
|
135
|
+
atomElement.style.height = `${dimensions.height}px`;
|
|
136
|
+
setBounds(visibleBoundsFor(atomElement));
|
|
85
137
|
};
|
|
86
138
|
const handleUp = (event) => {
|
|
87
139
|
const drag = dragRef.current;
|
|
88
140
|
if (!drag)
|
|
89
141
|
return;
|
|
90
|
-
const
|
|
91
|
-
const finalWidth = Math.max(MIN_ATOM_SIZE, Math.round(drag.startWidth + delta));
|
|
92
|
-
const finalHeight = Math.max(MIN_ATOM_SIZE, Math.round(drag.startHeight + (delta * drag.startHeight) / drag.startWidth));
|
|
142
|
+
const dimensions = dimensionsForDrag(drag, event.clientX, event.clientY);
|
|
93
143
|
dragRef.current = null;
|
|
94
144
|
setDragging(false);
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
// The model transaction below becomes authoritative. Clear the live
|
|
146
|
+
// preview first so a stale inline style cannot override later updates.
|
|
147
|
+
atomElement.style.removeProperty("width");
|
|
148
|
+
atomElement.style.removeProperty("height");
|
|
149
|
+
onResizeTo(dimensions.width, dimensions.height);
|
|
99
150
|
};
|
|
100
151
|
ownerWindow.addEventListener("pointermove", handleMove);
|
|
101
152
|
ownerWindow.addEventListener("pointerup", handleUp);
|
|
153
|
+
ownerWindow.addEventListener("pointercancel", handleUp);
|
|
102
154
|
return () => {
|
|
103
155
|
ownerWindow.removeEventListener("pointermove", handleMove);
|
|
104
156
|
ownerWindow.removeEventListener("pointerup", handleUp);
|
|
157
|
+
ownerWindow.removeEventListener("pointercancel", handleUp);
|
|
105
158
|
};
|
|
106
159
|
}, [atomElement, onResizeTo]);
|
|
107
160
|
const onDismissRef = useRef(onDismiss);
|
|
108
161
|
onDismissRef.current = onDismiss;
|
|
109
162
|
useEffect(() => {
|
|
163
|
+
if (!showMenu)
|
|
164
|
+
return;
|
|
110
165
|
const dismissIfOutside = (event) => {
|
|
111
166
|
const target = event.target;
|
|
167
|
+
const elementTarget = target instanceof Element ? target : target.parentElement;
|
|
168
|
+
const isResizeHandle = Boolean(elementTarget?.closest("[data-srte-media-resize-handle-direction]"));
|
|
112
169
|
if (dragRef.current)
|
|
113
170
|
return;
|
|
114
|
-
if (rootRef.current && !rootRef.current.contains(target) && !atomElement.contains(target) &&
|
|
171
|
+
if (rootRef.current && !rootRef.current.contains(target) && !atomElement.contains(target) && !isResizeHandle)
|
|
115
172
|
onDismissRef.current();
|
|
116
173
|
};
|
|
117
174
|
window.addEventListener("pointerdown", dismissIfOutside, true);
|
|
@@ -120,31 +177,39 @@ export function MediaOverlay({ atomElement, alt, width, height, src, resizable =
|
|
|
120
177
|
window.removeEventListener("pointerdown", dismissIfOutside, true);
|
|
121
178
|
window.removeEventListener("mousedown", dismissIfOutside, true);
|
|
122
179
|
};
|
|
123
|
-
}, [atomElement]);
|
|
124
|
-
return (_jsxs(_Fragment, { children: [resizable && _jsx("div", {
|
|
180
|
+
}, [atomElement, showMenu]);
|
|
181
|
+
return (_jsxs(_Fragment, { children: [resizable && bounds && RESIZE_DIRECTIONS.map((direction) => (_jsx("div", { "data-srte-media-resize-handle": "true", "data-srte-media-resize-handle-direction": direction, role: "separator", "aria-label": `Resize media ${direction}`, onPointerDown: (event) => {
|
|
125
182
|
event.preventDefault();
|
|
126
|
-
|
|
127
|
-
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
const startWidth = width && width >= MIN_ATOM_SIZE ? width : bounds.width;
|
|
185
|
+
const startHeight = height && height >= MIN_ATOM_SIZE ? height : bounds.height;
|
|
186
|
+
dragRef.current = {
|
|
187
|
+
direction,
|
|
188
|
+
startClientX: event.clientX,
|
|
189
|
+
startClientY: event.clientY,
|
|
190
|
+
startWidth,
|
|
191
|
+
startHeight,
|
|
192
|
+
};
|
|
128
193
|
setDragging(true);
|
|
129
194
|
}, style: {
|
|
195
|
+
...boundsForDirection(bounds, direction),
|
|
130
196
|
position: "fixed",
|
|
131
|
-
|
|
132
|
-
top: handlePosition?.top ?? 0,
|
|
133
|
-
visibility: handlePosition ? "visible" : "hidden",
|
|
197
|
+
visibility: "visible",
|
|
134
198
|
width: HANDLE_SIZE,
|
|
135
199
|
height: HANDLE_SIZE,
|
|
136
|
-
|
|
200
|
+
boxSizing: "border-box",
|
|
201
|
+
borderRadius: 3,
|
|
137
202
|
background: dragging ? "var(--srte-primary)" : "var(--srte-menu-bg)",
|
|
138
203
|
border: "2px solid var(--srte-primary)",
|
|
139
|
-
cursor:
|
|
140
|
-
zIndex:
|
|
204
|
+
cursor: cursorFor(direction),
|
|
205
|
+
zIndex: 81,
|
|
141
206
|
touchAction: "none",
|
|
142
|
-
} }), _jsxs("div", { ref: rootRef, "data-srte-media-overlay": "true", role: "dialog", "aria-label": "Media actions", style: {
|
|
207
|
+
} }, direction))), showMenu && _jsxs("div", { ref: rootRef, "data-srte-media-overlay": "true", role: "dialog", "aria-label": "Media actions", tabIndex: -1, style: {
|
|
143
208
|
position: "fixed",
|
|
144
|
-
left: placement?.left ?? 0,
|
|
145
|
-
top: placement?.top ?? 0,
|
|
209
|
+
left: placement?.left ?? x ?? 0,
|
|
210
|
+
top: placement?.top ?? y ?? 0,
|
|
146
211
|
visibility: placement ? "visible" : "hidden",
|
|
147
|
-
zIndex:
|
|
212
|
+
zIndex: 80,
|
|
148
213
|
width: 260,
|
|
149
214
|
maxWidth: "calc(100vw - 16px)",
|
|
150
215
|
boxSizing: "border-box",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smartrte-react",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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.
|
|
87
|
+
"smartrte-core": "^1.0.0-beta.3"
|
|
88
88
|
},
|
|
89
89
|
"scripts": {
|
|
90
90
|
"build": "rm -rf dist && tsc -p tsconfig.json",
|