smartrte-react 1.0.0-beta.11 → 1.0.0-beta.13
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 +11 -0
- package/LICENSE +21 -0
- package/README.md +239 -750
- package/dist/components/CanonicalAuthorityEditor.js +76 -4
- package/dist/components/ColorPickerPopover.js +6 -7
- package/dist/components/TableBorderPopover.js +6 -7
- package/dist/components/ToolbarPrimitives.d.ts +11 -1
- package/dist/components/ToolbarPrimitives.js +15 -16
- package/dist/components/fixedPositioning.d.ts +53 -5
- package/dist/components/fixedPositioning.js +64 -6
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +55 -0
- package/package.json +2 -2
|
@@ -129,6 +129,17 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
|
|
|
129
129
|
const [linkPopover, setLinkPopover] = useState(null);
|
|
130
130
|
const [colorPopover, setColorPopover] = useState(null);
|
|
131
131
|
const [recentColors, setRecentColors] = useState({ text: [], background: [], border: [] });
|
|
132
|
+
/**
|
|
133
|
+
* "Let both bullet and number list tool icon work as toggle and on click
|
|
134
|
+
* text should [adopt] respective preset list" - the plain Bulleted
|
|
135
|
+
* list/Numbered list buttons always applied a generic disc/decimal
|
|
136
|
+
* marker, ignoring whatever glyph preset (e.g. "❖ ➢ ■") the user had
|
|
137
|
+
* already picked from the "List preset" dropdown elsewhere in the same
|
|
138
|
+
* session. Session-only React state, matching recentColors' own
|
|
139
|
+
* documented "no localStorage" convention above - not persisted across
|
|
140
|
+
* reloads.
|
|
141
|
+
*/
|
|
142
|
+
const [lastListPreset, setLastListPreset] = useState({ bullet: null, ordered: null });
|
|
132
143
|
const [tableSizePopover, setTableSizePopover] = useState(null);
|
|
133
144
|
const [tableBorderPopover, setTableBorderPopover] = useState(null);
|
|
134
145
|
const [mediaDetailsPopover, setMediaDetailsPopover] = useState(null);
|
|
@@ -493,6 +504,13 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
|
|
|
493
504
|
const currentListPreset = currentListParts.length === 1 && typeof rootList?.attrs?.preset === "string"
|
|
494
505
|
? rootList.attrs.preset
|
|
495
506
|
: "";
|
|
507
|
+
// The Bulleted/Numbered list buttons' own split-control style pickers -
|
|
508
|
+
// scoped to one kind each, so unlike the shared "List preset" select above
|
|
509
|
+
// (which lists both kinds together) each only shows/reflects presets of
|
|
510
|
+
// its own kind, and reads as unset (native placeholder) when the active
|
|
511
|
+
// preset (if any) belongs to the other kind.
|
|
512
|
+
const currentBulletPreset = SMART_LIST_PRESETS.find((preset) => preset.id === currentListPreset)?.kind === "bullet" ? currentListPreset : "";
|
|
513
|
+
const currentOrderedPreset = SMART_LIST_PRESETS.find((preset) => preset.id === currentListPreset)?.kind === "ordered" ? currentListPreset : "";
|
|
496
514
|
const toggleCheckedItems = () => {
|
|
497
515
|
if (currentListScope.kind !== "list-selection" || currentList?.attrs?.checkable !== true)
|
|
498
516
|
return;
|
|
@@ -540,17 +558,30 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
|
|
|
540
558
|
const toggleList = (style, checkable = false) => {
|
|
541
559
|
const selectedList = listScope();
|
|
542
560
|
const context = blockContext();
|
|
561
|
+
// A checklist's marker is a checkbox, not a bullet/number glyph - never
|
|
562
|
+
// substitute a remembered preset for it. Otherwise, reapply whichever
|
|
563
|
+
// preset the user last picked from the "List preset" dropdown for this
|
|
564
|
+
// same kind (bullet/ordered), instead of always resetting to the
|
|
565
|
+
// generic disc/decimal marker - see lastListPreset's own doc comment.
|
|
566
|
+
// setListStyle's own params type has no `preset` field (it's a
|
|
567
|
+
// deliberately distinct command from setListPreset - see setListPreset
|
|
568
|
+
// itself clearing `style` and vice versa), so this is a real branch,
|
|
569
|
+
// not just a differently-shaped params object.
|
|
570
|
+
const rememberedPreset = checkable ? null : lastListPreset[style === "decimal" ? "ordered" : "bullet"];
|
|
543
571
|
if (selectedList.kind === "list-selection") {
|
|
544
572
|
const rootId = outermostListId(selectedList.listId);
|
|
545
573
|
const list = findNode(runtime.editor.document, rootId);
|
|
546
574
|
const sameStyle = listActiveKind(list) === (style === "decimal" ? "ordered" : "bullet") && Boolean(list?.attrs?.checkable) === checkable;
|
|
575
|
+
const target = { ...selectedList, listId: rootId };
|
|
547
576
|
const operations = sameStyle
|
|
548
577
|
// Toggling an already-active style off is deliberately scoped to just
|
|
549
578
|
// the current item (selectedList), not the whole list.
|
|
550
579
|
? unwrapList(runtime.editor.document, selectedList, { splitListIds: ids(4) }, context)
|
|
551
580
|
// Applying a genuinely different style/type is a whole-list decision
|
|
552
581
|
// regardless of how deep the cursor is nested.
|
|
553
|
-
:
|
|
582
|
+
: rememberedPreset
|
|
583
|
+
? setListPreset(runtime.editor.document, target, { preset: rememberedPreset }, context)
|
|
584
|
+
: setListStyle(runtime.editor.document, target, { style, checkable }, context);
|
|
554
585
|
runtime.executeOperations(operations, { preserveSelectionById: true });
|
|
555
586
|
return;
|
|
556
587
|
}
|
|
@@ -560,9 +591,45 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
|
|
|
560
591
|
// per block, on top of one list per originally-flat contiguous group —
|
|
561
592
|
// over-provision generously rather than compute the exact worst case.
|
|
562
593
|
runtime.executeOperations(createList(runtime.editor.document, selectedBlocks, {
|
|
563
|
-
listIds: ids(Math.max(1, count * 2)), itemIds: ids(Math.max(1, count)),
|
|
594
|
+
listIds: ids(Math.max(1, count * 2)), itemIds: ids(Math.max(1, count)), checkable,
|
|
595
|
+
...(rememberedPreset ? { preset: rememberedPreset } : { style }),
|
|
564
596
|
}, context), { preserveSelectionById: true });
|
|
565
597
|
};
|
|
598
|
+
/**
|
|
599
|
+
* The Bulleted/Numbered list buttons' own split-control style picker
|
|
600
|
+
* (a native `<select>`, not a custom-positioned dropdown - see the
|
|
601
|
+
* `.srte-split-control` styling) - lets the user choose a specific preset
|
|
602
|
+
* directly from the button itself, instead of only ever getting the
|
|
603
|
+
* plain/remembered default from a click and having to visit the separate
|
|
604
|
+
* "More list tools" menu to pick a named style. Applies to an existing
|
|
605
|
+
* list (whole-list, like the shared "List preset" select) or creates a
|
|
606
|
+
* brand-new list with that preset when nothing is selected yet - unlike
|
|
607
|
+
* that shared select, which only ever edits an already-existing list.
|
|
608
|
+
* Scope is resolved fresh here (not from the outer currentListScope/
|
|
609
|
+
* currentListParts consts) to match toggleList's own convention of
|
|
610
|
+
* re-resolving at click time rather than trusting a render-time snapshot.
|
|
611
|
+
*/
|
|
612
|
+
const applyListPreset = (preset) => {
|
|
613
|
+
const pickedKind = SMART_LIST_PRESETS.find((candidate) => candidate.id === preset)?.kind;
|
|
614
|
+
const selectedList = listScope();
|
|
615
|
+
const context = blockContext();
|
|
616
|
+
if (selectedList.kind === "list-selection") {
|
|
617
|
+
const parts = listSelectionParts(selectedList);
|
|
618
|
+
if (parts.length !== 1)
|
|
619
|
+
return;
|
|
620
|
+
const rootId = outermostListId(parts[0].listId);
|
|
621
|
+
runtime.executeOperations(setListPreset(runtime.editor.document, { ...parts[0], listId: rootId }, { preset }, context), { preserveSelectionById: true });
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
const selectedBlocks = blockScope();
|
|
625
|
+
const count = selectedBlocks.kind === "block-range" ? selectedBlocks.blockIds.length : 1;
|
|
626
|
+
runtime.executeOperations(createList(runtime.editor.document, selectedBlocks, {
|
|
627
|
+
listIds: ids(Math.max(1, count * 2)), itemIds: ids(Math.max(1, count)), checkable: false, preset,
|
|
628
|
+
}, context), { preserveSelectionById: true });
|
|
629
|
+
}
|
|
630
|
+
if (pickedKind)
|
|
631
|
+
setLastListPreset((previous) => ({ ...previous, [pickedKind]: preset }));
|
|
632
|
+
};
|
|
566
633
|
const runList = (action) => {
|
|
567
634
|
const scope = listScope();
|
|
568
635
|
const context = blockContext();
|
|
@@ -1517,6 +1584,9 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
|
|
|
1517
1584
|
// the cursor is nested — see outermostListId.
|
|
1518
1585
|
const rootId = outermostListId(currentListParts[0].listId);
|
|
1519
1586
|
runtime.executeOperations(setListPreset(runtime.editor.document, { ...currentListParts[0], listId: rootId }, { preset }, blockContext()), { preserveSelectionById: true });
|
|
1587
|
+
const pickedKind = SMART_LIST_PRESETS.find((candidate) => candidate.id === preset)?.kind;
|
|
1588
|
+
if (pickedKind)
|
|
1589
|
+
setLastListPreset((previous) => ({ ...previous, [pickedKind]: preset }));
|
|
1520
1590
|
}, style: { width: "100%" }, children: [_jsx("option", { value: "", children: "List preset" }), SMART_LIST_PRESETS.map((preset) => _jsx("option", { value: preset.id, children: preset.kind === "bullet" ? `Bullet · ${preset.label}` : `Number · ${preset.label}` }, preset.id))] }) });
|
|
1521
1591
|
const listToolsMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "checkSquare", label: "Check selected items", pressed: currentListScope.kind === "list-selection" && currentListScope.items.every((item) => findNode(runtime.editor.document, item.itemId)?.attrs?.checked === true), disabled: readOnly || currentList?.attrs?.checkable !== true, onClick: toggleCheckedItems }), _jsx(ToolbarMenuItem, { icon: "indent", label: "Indent list item", disabled: readOnly || !canIndent, onClick: () => runList("indent") }), _jsx(ToolbarMenuItem, { icon: "outdent", label: "Outdent list item", disabled: readOnly || currentListParts.length === 0, onClick: () => runList("outdent") }), _jsx(ToolbarMenuItem, { icon: "moveUp", label: "Move item up", disabled: readOnly || !canMoveUp, onClick: () => runList("up") }), _jsx(ToolbarMenuItem, { icon: "moveDown", label: "Move item down", disabled: readOnly || !canMoveDown, onClick: () => runList("down") }), _jsx(ToolbarMenuItem, { icon: "restart", label: "Restart numbering", disabled: readOnly || !orderedList, onClick: restartNumbering }), _jsx(ToolbarMenuItem, { icon: "continueNumbering", label: "Continue numbering", disabled: readOnly || !orderedList, onClick: () => runtime.executeOperations(continueListNumbering(runtime.editor.document, currentListScope, {}, blockContext()), { preserveSelectionById: true }) })] });
|
|
1522
1592
|
// Remove link/Insert formula/Special characters also get a standalone
|
|
@@ -1560,10 +1630,12 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
|
|
|
1560
1630
|
} }), t.backgroundColor && _jsx(ToolbarButton, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
|
|
1561
1631
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
1562
1632
|
setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
|
|
1563
|
-
} }), t.fontSize && _jsx(ToolbarButton, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), t.fontFamily && _jsx(ToolbarButton, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true }), showMoreTextStyles && _jsx(ToolbarDropdown, { icon: "textColor", label: "More text styles", priority: 2, children: textStylesMenuItems })] }), _jsxs(ToolbarGroup, { children: [t.blockType && _jsxs("select", { "aria-label": "Block type", title: "Block type", value: currentBlockType, disabled: readOnly, onChange: (event) => transactBlock(setBlockTypeCommand(runtime.editor.document, blockScope(), {
|
|
1633
|
+
} }), t.fontSize && _jsx(ToolbarButton, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), t.fontFamily && _jsx(ToolbarButton, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true }), showMoreTextStyles && _jsx(ToolbarDropdown, { icon: "textColor", label: "More text styles", priority: 2, children: textStylesMenuItems })] }), _jsxs(ToolbarGroup, { children: [t.blockType && _jsxs("select", { className: "srte-block-type-select", "aria-label": "Block type", title: "Block type", value: currentBlockType, disabled: readOnly, onChange: (event) => transactBlock(setBlockTypeCommand(runtime.editor.document, blockScope(), {
|
|
1564
1634
|
type: event.target.value === "paragraph" ? "paragraph" : event.target.value === "code_block" ? "code_block" : "heading",
|
|
1565
1635
|
attrs: event.target.value.startsWith("heading-") ? { level: Number(event.target.value.slice(8)) } : {},
|
|
1566
|
-
}, blockContext())), children: [_jsx("option", { value: "paragraph", children: "Paragraph" }), Array.from({ length: 6 }, (_, index) => _jsxs("option", { value: `heading-${index + 1}`, children: ["Heading ", index + 1] }, index + 1)), _jsx("option", { value: "code_block", children: "Code block" })] }), t.alignLeft && _jsx(ToolbarButton, { icon: "alignLeft", label: "Align left", ariaLabel: "Align left", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "left" } }, blockContext())) }), t.alignCenter && _jsx(ToolbarButton, { icon: "alignCenter", label: "Align center", ariaLabel: "Align center", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "center" } }, blockContext())) }), t.alignRight && _jsx(ToolbarButton, { icon: "alignRight", label: "Align right", ariaLabel: "Align right", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "right" } }, blockContext())) }), t.alignJustify && _jsx(ToolbarButton, { icon: "alignJustify", label: "Justify", ariaLabel: "Align justify", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "justify" } }, blockContext())) }), t.lineHeight && _jsx(ToolbarDropdown, { icon: "lineHeight", label: "Line spacing", priority: 2, children: lineHeightMenuItems }), t.quote && _jsx(ToolbarButton, { icon: "quote", label: "Quote", ariaLabel: "Blockquote", disabled: readOnly, onClick: toggleBlockquote }), _jsx(ToolbarDropdown, { icon: "moveUp", label: "More paragraph tools", priority: 2, children: paragraphToolsMenuItems })] }), _jsxs(ToolbarGroup, { children: [t.bulletedList &&
|
|
1636
|
+
}, blockContext())), children: [_jsx("option", { value: "paragraph", children: "Paragraph" }), Array.from({ length: 6 }, (_, index) => _jsxs("option", { value: `heading-${index + 1}`, children: ["Heading ", index + 1] }, index + 1)), _jsx("option", { value: "code_block", children: "Code block" })] }), t.alignLeft && _jsx(ToolbarButton, { icon: "alignLeft", label: "Align left", ariaLabel: "Align left", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "left" } }, blockContext())) }), t.alignCenter && _jsx(ToolbarButton, { icon: "alignCenter", label: "Align center", ariaLabel: "Align center", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "center" } }, blockContext())) }), t.alignRight && _jsx(ToolbarButton, { icon: "alignRight", label: "Align right", ariaLabel: "Align right", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "right" } }, blockContext())) }), t.alignJustify && _jsx(ToolbarButton, { icon: "alignJustify", label: "Justify", ariaLabel: "Align justify", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "justify" } }, blockContext())) }), t.lineHeight && _jsx(ToolbarDropdown, { icon: "lineHeight", label: "Line spacing", priority: 2, children: lineHeightMenuItems }), t.quote && _jsx(ToolbarButton, { icon: "quote", label: "Quote", ariaLabel: "Blockquote", disabled: readOnly, onClick: toggleBlockquote }), _jsx(ToolbarDropdown, { icon: "moveUp", label: "More paragraph tools", priority: 2, children: paragraphToolsMenuItems })] }), _jsxs(ToolbarGroup, { children: [t.bulletedList && (t.listPreset ? _jsxs("span", { className: "srte-split-control", children: [_jsx(ToolbarButton, { icon: "bulletedList", label: "Bulleted list", ariaLabel: "Bulleted list", pressed: listStyleActive("disc"), disabled: readOnly, narrowIconOnly: true, onClick: () => toggleList("disc") }), _jsxs("select", { "aria-label": "Bulleted list style", title: "Bulleted list style", disabled: readOnly || (currentListScope.kind === "list-selection" && currentListParts.length !== 1), value: currentBulletPreset, onChange: (event) => { const preset = event.target.value; if (preset)
|
|
1637
|
+
applyListPreset(preset); }, children: [_jsx("option", { value: "", children: "Bulleted list style" }), SMART_LIST_PRESETS.filter((preset) => preset.kind === "bullet").map((preset) => _jsx("option", { value: preset.id, children: preset.label }, preset.id))] })] }) : _jsx(ToolbarButton, { icon: "bulletedList", label: "Bulleted list", ariaLabel: "Bulleted list", pressed: listStyleActive("disc"), disabled: readOnly, narrowIconOnly: true, onClick: () => toggleList("disc") })), t.numberedList && (t.listPreset ? _jsxs("span", { className: "srte-split-control", children: [_jsx(ToolbarButton, { icon: "numberedList", label: "Numbered list", ariaLabel: "Numbered list", pressed: listStyleActive("decimal"), disabled: readOnly, narrowIconOnly: true, onClick: () => toggleList("decimal") }), _jsxs("select", { "aria-label": "Numbered list style", title: "Numbered list style", disabled: readOnly || (currentListScope.kind === "list-selection" && currentListParts.length !== 1), value: currentOrderedPreset, onChange: (event) => { const preset = event.target.value; if (preset)
|
|
1638
|
+
applyListPreset(preset); }, children: [_jsx("option", { value: "", children: "Numbered list style" }), SMART_LIST_PRESETS.filter((preset) => preset.kind === "ordered").map((preset) => _jsx("option", { value: preset.id, children: preset.label }, preset.id))] })] }) : _jsx(ToolbarButton, { icon: "numberedList", label: "Numbered list", ariaLabel: "Numbered list", pressed: listStyleActive("decimal"), disabled: readOnly, narrowIconOnly: true, onClick: () => toggleList("decimal") })), t.checklist && _jsx(ToolbarButton, { icon: "checklist", label: "Checklist", ariaLabel: "Checklist", pressed: listStyleActive("disc", true), disabled: readOnly, narrowIconOnly: true, onClick: () => toggleList("disc", true) }), _jsxs(ToolbarDropdown, { icon: "restart", label: "More list tools", priority: 2, children: [listPresetSelect, t.listPreset && _jsx("div", { className: "srte-menu-separator" }), listToolsMenuItems] })] }), _jsxs(ToolbarGroup, { children: [t.link && _jsx(ToolbarButton, { icon: "link", label: "Link", ariaLabel: "Insert or edit link", disabled: readOnly, onClick: openLinkPopover }), showImageTool && _jsx(ToolbarButton, { icon: "image", label: "Image", ariaLabel: "Insert image", disabled: readOnly, onClick: () => setMediaKind("image") }), t.removeLink && _jsx(ToolbarButton, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), t.insertFormula && _jsx(ToolbarButton, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
|
|
1567
1639
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
1568
1640
|
setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
|
|
1569
1641
|
} }), t.specialCharacters && _jsx(ToolbarButton, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
-
import { getFixedPositioningOrigin } from "./fixedPositioning.js";
|
|
3
|
+
import { getFixedPositioningOrigin, getPositioningBounds } from "./fixedPositioning.js";
|
|
4
4
|
const HEX_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
5
5
|
const normalizeHex = (value) => {
|
|
6
6
|
if (!HEX_PATTERN.test(value))
|
|
@@ -131,14 +131,13 @@ export function ColorPickerPopover({ x, y, label, initialValue = "#000000", rece
|
|
|
131
131
|
return;
|
|
132
132
|
const margin = 8;
|
|
133
133
|
const { width, height } = el.getBoundingClientRect();
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const overflowsRight = x + width > viewportWidth - margin;
|
|
134
|
+
const bounds = getPositioningBounds(el);
|
|
135
|
+
const overflowsRight = x + width > bounds.right - margin;
|
|
137
136
|
const left = overflowsRight ? x - width : x;
|
|
138
|
-
const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin,
|
|
139
|
-
const overflowsBottom = y + height >
|
|
137
|
+
const clampedLeft = Math.min(Math.max(bounds.left + margin, left), Math.max(bounds.left + margin, bounds.right - width - margin));
|
|
138
|
+
const overflowsBottom = y + height > bounds.bottom - margin;
|
|
140
139
|
const top = overflowsBottom ? y - height : y;
|
|
141
|
-
const clampedTop = Math.min(Math.max(margin, top), Math.max(margin,
|
|
140
|
+
const clampedTop = Math.min(Math.max(bounds.top + margin, top), Math.max(bounds.top + margin, bounds.bottom - margin));
|
|
142
141
|
const origin = getFixedPositioningOrigin(el);
|
|
143
142
|
setPlacement({ left: clampedLeft - origin.left, top: clampedTop - origin.top });
|
|
144
143
|
}, [x, y]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
3
|
import { ColorPickerPopover } from "./ColorPickerPopover.js";
|
|
4
|
-
import { getFixedPositioningOrigin } from "./fixedPositioning.js";
|
|
4
|
+
import { getFixedPositioningOrigin, getPositioningBounds } from "./fixedPositioning.js";
|
|
5
5
|
// Widths start at 2px, not 1px - table_cell's border-collapse table (see
|
|
6
6
|
// theme.ts's th/td rule) already renders a 1px ambient default gridline on
|
|
7
7
|
// every cell. A custom border tied at 1px loses CSS border-collapse's
|
|
@@ -68,14 +68,13 @@ export function TableBorderPopover({ x, y, initial, recentColors, onPreview, onA
|
|
|
68
68
|
return;
|
|
69
69
|
const margin = 8;
|
|
70
70
|
const { width, height } = el.getBoundingClientRect();
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const overflowsRight = x + width > viewportWidth - margin;
|
|
71
|
+
const bounds = getPositioningBounds(el);
|
|
72
|
+
const overflowsRight = x + width > bounds.right - margin;
|
|
74
73
|
const left = overflowsRight ? x - width : x;
|
|
75
|
-
const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin,
|
|
76
|
-
const overflowsBottom = y + height >
|
|
74
|
+
const clampedLeft = Math.min(Math.max(bounds.left + margin, left), Math.max(bounds.left + margin, bounds.right - width - margin));
|
|
75
|
+
const overflowsBottom = y + height > bounds.bottom - margin;
|
|
77
76
|
const top = overflowsBottom ? y - height : y;
|
|
78
|
-
const clampedTop = Math.min(Math.max(margin, top), Math.max(margin,
|
|
77
|
+
const clampedTop = Math.min(Math.max(bounds.top + margin, top), Math.max(bounds.top + margin, bounds.bottom - margin));
|
|
79
78
|
const origin = getFixedPositioningOrigin(el);
|
|
80
79
|
setPlacement({ left: clampedLeft - origin.left, top: clampedTop - origin.top });
|
|
81
80
|
}, [x, y]);
|
|
@@ -66,7 +66,7 @@ export declare const toolbarIcons: {
|
|
|
66
66
|
};
|
|
67
67
|
export type ToolbarIconKey = keyof typeof toolbarIcons;
|
|
68
68
|
/** A single always-visible toolbar control: icon + plain-language label together, per the redesign's core principle. */
|
|
69
|
-
export declare function ToolbarButton({ icon, label, ariaLabel, pressed, disabled, title, onClick, iconOnly, widePromote }: {
|
|
69
|
+
export declare function ToolbarButton({ icon, label, ariaLabel, pressed, disabled, title, onClick, iconOnly, widePromote, narrowIconOnly }: {
|
|
70
70
|
icon: ToolbarIconKey;
|
|
71
71
|
label: string;
|
|
72
72
|
ariaLabel?: string;
|
|
@@ -84,6 +84,16 @@ export declare function ToolbarButton({ icon, label, ariaLabel, pressed, disable
|
|
|
84
84
|
* docs/bugs/toolbar-priority-collapse-fixed-threshold-no-wide-promotion.md.
|
|
85
85
|
*/
|
|
86
86
|
widePromote?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Unlike `iconOnly` (always icon-only), this drops the label text only
|
|
89
|
+
* below theme.ts's 479px container-width breakpoint - the button shows
|
|
90
|
+
* icon + label at every wider width, matching every other always-visible
|
|
91
|
+
* button, and falls back to icon-only (title/aria-label still carry the
|
|
92
|
+
* name, so it stays a real tooltip) only once space is genuinely tight.
|
|
93
|
+
* A CSS-only toggle (see `[data-srte-narrow-icon-only]` in theme.ts), not
|
|
94
|
+
* JS viewport state, matching how every other responsive tier here works.
|
|
95
|
+
*/
|
|
96
|
+
narrowIconOnly?: boolean;
|
|
87
97
|
}): import("react/jsx-runtime").JSX.Element;
|
|
88
98
|
/** One row inside an open dropdown menu - closes the dropdown after acting, matching ContextMenu.tsx's own dismiss-on-action convention. */
|
|
89
99
|
export declare function ToolbarMenuItem({ icon, label, ariaLabel, pressed, disabled, title, onClick, widePromote }: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
3
|
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, AlignVerticalSpaceAround, ALargeSmall, Baseline, Bold, CheckSquare, ChevronDown, Code, Columns3, Download, Eye, FileJson, History, Image as ImageIcon, IndentDecrease, IndentIncrease, Italic, Link2, ListChecks, ListOrdered, ListRestart, List as ListIcon, MessageSquare, MessageSquarePlus, MoreHorizontal, MoveDown, MoveLeft, MoveRight, MoveUp, Music, Omega, PanelTop, PenLine, Pilcrow, Quote, Redo2, Rows3, ScissorsLineDashed, SeparatorHorizontal, Square, Sigma, Strikethrough, Subscript, Superscript, Table2, Trash2, Type, Underline, Undo2, Unlink2, Upload, Video, Pencil, ZoomIn, ZoomOut, } from "lucide-react";
|
|
4
|
-
import { getFixedPositioningOrigin } from "./fixedPositioning.js";
|
|
4
|
+
import { getFixedPositioningOrigin, getPositioningBounds } from "./fixedPositioning.js";
|
|
5
5
|
/**
|
|
6
6
|
* The toolbar's icon set (Phase: Direction B toolbar redesign, 2026-08-29).
|
|
7
7
|
* Lucide, per the design proposal's §11 recommendation - MIT-licensed,
|
|
@@ -80,8 +80,8 @@ export const toolbarIcons = {
|
|
|
80
80
|
lineHeight: _jsx(AlignVerticalSpaceAround, { ...stroke }),
|
|
81
81
|
};
|
|
82
82
|
/** A single always-visible toolbar control: icon + plain-language label together, per the redesign's core principle. */
|
|
83
|
-
export function ToolbarButton({ icon, label, ariaLabel, pressed, disabled, title, onClick, iconOnly, widePromote }) {
|
|
84
|
-
return _jsxs("button", { type: "button", className: "srte-tool-button", "aria-label": ariaLabel ?? label, "aria-pressed": pressed, title: title ?? ariaLabel ?? label, disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: onClick, ...(widePromote ? { "data-srte-wide-promote": "true" } : {}), children: [toolbarIcons[icon], !iconOnly && _jsx("span", { children: label })] });
|
|
83
|
+
export function ToolbarButton({ icon, label, ariaLabel, pressed, disabled, title, onClick, iconOnly, widePromote, narrowIconOnly }) {
|
|
84
|
+
return _jsxs("button", { type: "button", className: "srte-tool-button", "aria-label": ariaLabel ?? label, "aria-pressed": pressed, title: title ?? ariaLabel ?? label, disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: onClick, ...(widePromote ? { "data-srte-wide-promote": "true" } : {}), ...(narrowIconOnly ? { "data-srte-narrow-icon-only": "true" } : {}), children: [toolbarIcons[icon], !iconOnly && _jsx("span", { children: label })] });
|
|
85
85
|
}
|
|
86
86
|
/** One row inside an open dropdown menu - closes the dropdown after acting, matching ContextMenu.tsx's own dismiss-on-action convention. */
|
|
87
87
|
export function ToolbarMenuItem({ icon, label, ariaLabel, pressed, disabled, title, onClick, widePromote }) {
|
|
@@ -157,11 +157,10 @@ export function ToolbarDropdown({ icon, label, priority, children }) {
|
|
|
157
157
|
const menu = details.querySelector(":scope > .srte-menu");
|
|
158
158
|
const menuWidth = menu?.offsetWidth ?? 210;
|
|
159
159
|
const menuHeight = menu?.offsetHeight ?? 0;
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const top = overflowsBottom ? Math.max(margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
|
|
160
|
+
const bounds = getPositioningBounds(details);
|
|
161
|
+
const left = Math.min(Math.max(bounds.left + margin, triggerRect.left), Math.max(bounds.left + margin, bounds.right - menuWidth - margin));
|
|
162
|
+
const overflowsBottom = triggerRect.bottom + 6 + menuHeight > bounds.bottom - margin;
|
|
163
|
+
const top = overflowsBottom ? Math.max(bounds.top + margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
|
|
165
164
|
const origin = getFixedPositioningOrigin(details);
|
|
166
165
|
setPlacement({ left: left - origin.left, top: top - origin.top });
|
|
167
166
|
};
|
|
@@ -215,17 +214,17 @@ export function MobileMoreMenu({ children }) {
|
|
|
215
214
|
const margin = 8;
|
|
216
215
|
const triggerRect = details.getBoundingClientRect();
|
|
217
216
|
const menu = details.querySelector(":scope > .srte-menu");
|
|
218
|
-
const
|
|
217
|
+
const bounds = getPositioningBounds(details);
|
|
218
|
+
const menuWidth = menu?.offsetWidth ?? Math.min(280, bounds.right - bounds.left - 16);
|
|
219
219
|
const menuHeight = menu?.offsetHeight ?? 0;
|
|
220
|
-
const viewportWidth = window.innerWidth;
|
|
221
|
-
const viewportHeight = window.innerHeight;
|
|
222
220
|
// Right-aligned to the trigger, matching this menu's original
|
|
223
|
-
// CSS-only `right: 0` intent - clamped into the
|
|
224
|
-
// being allowed to run off
|
|
221
|
+
// CSS-only `right: 0` intent - clamped into the effective bounds
|
|
222
|
+
// instead of being allowed to run off them or get clipped by an
|
|
223
|
+
// ancestor.
|
|
225
224
|
const preferredLeft = triggerRect.right - menuWidth;
|
|
226
|
-
const left = Math.min(Math.max(margin, preferredLeft), Math.max(margin,
|
|
227
|
-
const overflowsBottom = triggerRect.bottom + 6 + menuHeight >
|
|
228
|
-
const top = overflowsBottom ? Math.max(margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
|
|
225
|
+
const left = Math.min(Math.max(bounds.left + margin, preferredLeft), Math.max(bounds.left + margin, bounds.right - menuWidth - margin));
|
|
226
|
+
const overflowsBottom = triggerRect.bottom + 6 + menuHeight > bounds.bottom - margin;
|
|
227
|
+
const top = overflowsBottom ? Math.max(bounds.top + margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
|
|
229
228
|
const origin = getFixedPositioningOrigin(details);
|
|
230
229
|
setPlacement({ left: left - origin.left, top: top - origin.top });
|
|
231
230
|
};
|
|
@@ -9,11 +9,23 @@
|
|
|
9
9
|
* (`translate-x-[-50%] translate-y-[-50%]`), confirmed live via a Sootr
|
|
10
10
|
* host rendering the editor inside such a dialog
|
|
11
11
|
* (docs/bugs/toolbar-menu-misplaced-inside-transformed-ancestor.md).
|
|
12
|
-
* - `contain: paint | strict | content` (CSS Containment) - a
|
|
13
|
-
* perf/isolation optimization on large scrollable containers, e.g.
|
|
14
|
-
* second, different Sootr dialog reported still misplacing every
|
|
15
|
-
* dropdown ("More list tools", "More to insert", "Save a copy",
|
|
16
|
-
* text styles") despite already running the transform-only fix
|
|
12
|
+
* - `contain: layout | paint | strict | content` (CSS Containment) - a
|
|
13
|
+
* common perf/isolation optimization on large scrollable containers, e.g.
|
|
14
|
+
* a second, different Sootr dialog reported still misplacing every
|
|
15
|
+
* toolbar dropdown ("More list tools", "More to insert", "Save a copy",
|
|
16
|
+
* "More text styles") despite already running the transform-only fix
|
|
17
|
+
* above. `layout` alone (without `paint`) was missed in that first pass -
|
|
18
|
+
* only checked for `paint`/`strict`/`content` - and confirmed directly in
|
|
19
|
+
* real Chromium to establish the containing block on its own regardless:
|
|
20
|
+
* a `position: fixed` descendant of a bare `contain: layout` ancestor (no
|
|
21
|
+
* transform, no paint) resolves its `top`/`left` against that ancestor's
|
|
22
|
+
* box, not the viewport. A THIRD Sootr dialog using exactly this
|
|
23
|
+
* (`contain: layout`, no `paint`) reported the same misplacement symptom
|
|
24
|
+
* again - not a clipping/invisibility symptom (see `getPositioningBounds`
|
|
25
|
+
* below for that distinct failure mode), just landing in the wrong place
|
|
26
|
+
* with no error, since the previous check silently treated this ancestor
|
|
27
|
+
* as if it weren't a containing block at all and fell through to
|
|
28
|
+
* `{ left: 0, top: 0 }` (viewport-relative) origin math instead.
|
|
17
29
|
*
|
|
18
30
|
* Deliberately NOT checking bare `container-type` (CSS Containers) -
|
|
19
31
|
* despite implying `contain: layout style` per spec, verified directly in
|
|
@@ -40,3 +52,39 @@ export declare function getFixedPositioningOrigin(el: HTMLElement): {
|
|
|
40
52
|
left: number;
|
|
41
53
|
top: number;
|
|
42
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* Correcting the coordinate origin (above) is only half the fix - every
|
|
57
|
+
* overlay in this codebase also clamps its own placement so a menu never
|
|
58
|
+
* renders off-screen, and every one of those clamps was computed against
|
|
59
|
+
* `window.innerWidth`/`innerHeight`, the *full browser viewport*. That's
|
|
60
|
+
* wrong when the containing-block ancestor is one with `contain: paint`
|
|
61
|
+
* (or `strict`/`content`): CSS guarantees nothing painted by a descendant -
|
|
62
|
+
* including a `position: fixed` one whose containing block it is - can
|
|
63
|
+
* ever be visible outside that ancestor's own border box, no matter what
|
|
64
|
+
* `left`/`top` says. Reported live: a toolbar dropdown near the middle of
|
|
65
|
+
* a `contain: paint` dialog rendered hugging the dialog's own right edge
|
|
66
|
+
* (barely fit), and one further right ("More to insert") was positioned
|
|
67
|
+
* entirely outside the dialog's box and so never painted at all - both
|
|
68
|
+
* fully explained by clamping against the wide browser window instead of
|
|
69
|
+
* the actual, much narrower, clipping ancestor.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately NOT applied for a containing block established only by
|
|
72
|
+
* `transform`/`perspective`/`filter`/`backdrop-filter`/`will-change`, or by
|
|
73
|
+
* `contain: layout` with no `paint`/`strict`/`content` alongside it - none
|
|
74
|
+
* of those clip overflow (`layout` containment only isolates layout
|
|
75
|
+
* calculations, it does not imply paint containment - see the spec note
|
|
76
|
+
* above `findFixedPositioningContainer`), so a menu is free to visually
|
|
77
|
+
* extend past that ancestor's box exactly as it could before any of this
|
|
78
|
+
* positioning logic existed. Verified directly: constraining
|
|
79
|
+
* to such an ancestor's bounds regardless made a mobile "More tools" menu
|
|
80
|
+
* genuinely too tall to fit inside a short transformed host overlap its
|
|
81
|
+
* own trigger instead of just extending past the host's edge (harmless,
|
|
82
|
+
* since nothing there clips it) - a real regression from being too
|
|
83
|
+
* conservative, not a fix.
|
|
84
|
+
*/
|
|
85
|
+
export declare function getPositioningBounds(el: HTMLElement): {
|
|
86
|
+
left: number;
|
|
87
|
+
top: number;
|
|
88
|
+
right: number;
|
|
89
|
+
bottom: number;
|
|
90
|
+
};
|
|
@@ -9,11 +9,23 @@
|
|
|
9
9
|
* (`translate-x-[-50%] translate-y-[-50%]`), confirmed live via a Sootr
|
|
10
10
|
* host rendering the editor inside such a dialog
|
|
11
11
|
* (docs/bugs/toolbar-menu-misplaced-inside-transformed-ancestor.md).
|
|
12
|
-
* - `contain: paint | strict | content` (CSS Containment) - a
|
|
13
|
-
* perf/isolation optimization on large scrollable containers, e.g.
|
|
14
|
-
* second, different Sootr dialog reported still misplacing every
|
|
15
|
-
* dropdown ("More list tools", "More to insert", "Save a copy",
|
|
16
|
-
* text styles") despite already running the transform-only fix
|
|
12
|
+
* - `contain: layout | paint | strict | content` (CSS Containment) - a
|
|
13
|
+
* common perf/isolation optimization on large scrollable containers, e.g.
|
|
14
|
+
* a second, different Sootr dialog reported still misplacing every
|
|
15
|
+
* toolbar dropdown ("More list tools", "More to insert", "Save a copy",
|
|
16
|
+
* "More text styles") despite already running the transform-only fix
|
|
17
|
+
* above. `layout` alone (without `paint`) was missed in that first pass -
|
|
18
|
+
* only checked for `paint`/`strict`/`content` - and confirmed directly in
|
|
19
|
+
* real Chromium to establish the containing block on its own regardless:
|
|
20
|
+
* a `position: fixed` descendant of a bare `contain: layout` ancestor (no
|
|
21
|
+
* transform, no paint) resolves its `top`/`left` against that ancestor's
|
|
22
|
+
* box, not the viewport. A THIRD Sootr dialog using exactly this
|
|
23
|
+
* (`contain: layout`, no `paint`) reported the same misplacement symptom
|
|
24
|
+
* again - not a clipping/invisibility symptom (see `getPositioningBounds`
|
|
25
|
+
* below for that distinct failure mode), just landing in the wrong place
|
|
26
|
+
* with no error, since the previous check silently treated this ancestor
|
|
27
|
+
* as if it weren't a containing block at all and fell through to
|
|
28
|
+
* `{ left: 0, top: 0 }` (viewport-relative) origin math instead.
|
|
17
29
|
*
|
|
18
30
|
* Deliberately NOT checking bare `container-type` (CSS Containers) -
|
|
19
31
|
* despite implying `contain: layout style` per spec, verified directly in
|
|
@@ -44,7 +56,7 @@ export function findFixedPositioningContainer(el) {
|
|
|
44
56
|
(style.filter && style.filter !== "none") ||
|
|
45
57
|
(style.backdropFilter && style.backdropFilter !== "none") ||
|
|
46
58
|
(style.willChange && /transform|perspective|filter/.test(style.willChange)) ||
|
|
47
|
-
(style.contain && /\b(paint|strict|content)\b/.test(style.contain))) {
|
|
59
|
+
(style.contain && /\b(layout|paint|strict|content)\b/.test(style.contain))) {
|
|
48
60
|
return node;
|
|
49
61
|
}
|
|
50
62
|
node = node.parentElement;
|
|
@@ -58,3 +70,49 @@ export function getFixedPositioningOrigin(el) {
|
|
|
58
70
|
const rect = container.getBoundingClientRect();
|
|
59
71
|
return { left: rect.left, top: rect.top };
|
|
60
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Correcting the coordinate origin (above) is only half the fix - every
|
|
75
|
+
* overlay in this codebase also clamps its own placement so a menu never
|
|
76
|
+
* renders off-screen, and every one of those clamps was computed against
|
|
77
|
+
* `window.innerWidth`/`innerHeight`, the *full browser viewport*. That's
|
|
78
|
+
* wrong when the containing-block ancestor is one with `contain: paint`
|
|
79
|
+
* (or `strict`/`content`): CSS guarantees nothing painted by a descendant -
|
|
80
|
+
* including a `position: fixed` one whose containing block it is - can
|
|
81
|
+
* ever be visible outside that ancestor's own border box, no matter what
|
|
82
|
+
* `left`/`top` says. Reported live: a toolbar dropdown near the middle of
|
|
83
|
+
* a `contain: paint` dialog rendered hugging the dialog's own right edge
|
|
84
|
+
* (barely fit), and one further right ("More to insert") was positioned
|
|
85
|
+
* entirely outside the dialog's box and so never painted at all - both
|
|
86
|
+
* fully explained by clamping against the wide browser window instead of
|
|
87
|
+
* the actual, much narrower, clipping ancestor.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately NOT applied for a containing block established only by
|
|
90
|
+
* `transform`/`perspective`/`filter`/`backdrop-filter`/`will-change`, or by
|
|
91
|
+
* `contain: layout` with no `paint`/`strict`/`content` alongside it - none
|
|
92
|
+
* of those clip overflow (`layout` containment only isolates layout
|
|
93
|
+
* calculations, it does not imply paint containment - see the spec note
|
|
94
|
+
* above `findFixedPositioningContainer`), so a menu is free to visually
|
|
95
|
+
* extend past that ancestor's box exactly as it could before any of this
|
|
96
|
+
* positioning logic existed. Verified directly: constraining
|
|
97
|
+
* to such an ancestor's bounds regardless made a mobile "More tools" menu
|
|
98
|
+
* genuinely too tall to fit inside a short transformed host overlap its
|
|
99
|
+
* own trigger instead of just extending past the host's edge (harmless,
|
|
100
|
+
* since nothing there clips it) - a real regression from being too
|
|
101
|
+
* conservative, not a fix.
|
|
102
|
+
*/
|
|
103
|
+
export function getPositioningBounds(el) {
|
|
104
|
+
const viewport = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
|
105
|
+
const container = findFixedPositioningContainer(el);
|
|
106
|
+
if (!container)
|
|
107
|
+
return viewport;
|
|
108
|
+
const containerStyle = window.getComputedStyle(container);
|
|
109
|
+
if (!containerStyle.contain || !/\b(paint|strict|content)\b/.test(containerStyle.contain))
|
|
110
|
+
return viewport;
|
|
111
|
+
const rect = container.getBoundingClientRect();
|
|
112
|
+
return {
|
|
113
|
+
left: Math.max(viewport.left, rect.left),
|
|
114
|
+
top: Math.max(viewport.top, rect.top),
|
|
115
|
+
right: Math.min(viewport.right, rect.right),
|
|
116
|
+
bottom: Math.min(viewport.bottom, rect.bottom),
|
|
117
|
+
};
|
|
118
|
+
}
|