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.
@@ -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
- : setListStyle(runtime.editor.document, { ...selectedList, listId: rootId }, { style, checkable }, context);
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)), style, checkable,
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 && _jsx(ToolbarButton, { icon: "bulletedList", label: "Bulleted list", ariaLabel: "Bulleted list", pressed: listStyleActive("disc"), disabled: readOnly, onClick: () => toggleList("disc") }), t.numberedList && _jsx(ToolbarButton, { icon: "numberedList", label: "Numbered list", ariaLabel: "Numbered list", pressed: listStyleActive("decimal"), disabled: readOnly, onClick: () => toggleList("decimal") }), t.checklist && _jsx(ToolbarButton, { icon: "checklist", label: "Checklist", ariaLabel: "Checklist", pressed: listStyleActive("disc", true), disabled: readOnly, 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) => {
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 viewportWidth = window.innerWidth;
135
- const viewportHeight = window.innerHeight;
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, viewportWidth - width - margin));
139
- const overflowsBottom = y + height > viewportHeight - margin;
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, viewportHeight - 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 viewportWidth = window.innerWidth;
72
- const viewportHeight = window.innerHeight;
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, viewportWidth - width - margin));
76
- const overflowsBottom = y + height > viewportHeight - margin;
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, viewportHeight - 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 viewportWidth = window.innerWidth;
161
- const viewportHeight = window.innerHeight;
162
- const left = Math.min(Math.max(margin, triggerRect.left), Math.max(margin, viewportWidth - menuWidth - margin));
163
- const overflowsBottom = triggerRect.bottom + 6 + menuHeight > viewportHeight - margin;
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 menuWidth = menu?.offsetWidth ?? Math.min(280, window.innerWidth - 16);
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 viewport instead of
224
- // being allowed to run off it or get clipped by an ancestor.
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, viewportWidth - menuWidth - margin));
227
- const overflowsBottom = triggerRect.bottom + 6 + menuHeight > viewportHeight - margin;
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 common
13
- * perf/isolation optimization on large scrollable containers, e.g. a
14
- * second, different Sootr dialog reported still misplacing every toolbar
15
- * dropdown ("More list tools", "More to insert", "Save a copy", "More
16
- * text styles") despite already running the transform-only fix above.
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 common
13
- * perf/isolation optimization on large scrollable containers, e.g. a
14
- * second, different Sootr dialog reported still misplacing every toolbar
15
- * dropdown ("More list tools", "More to insert", "Save a copy", "More
16
- * text styles") despite already running the transform-only fix above.
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
+ }