smartrte-react 1.0.0-beta.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
3
+ import { getFixedPositioningOrigin, getPositioningBounds } from "./fixedPositioning.js";
3
4
  const HEX_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
4
5
  const normalizeHex = (value) => {
5
6
  if (!HEX_PATTERN.test(value))
@@ -130,15 +131,15 @@ export function ColorPickerPopover({ x, y, label, initialValue = "#000000", rece
130
131
  return;
131
132
  const margin = 8;
132
133
  const { width, height } = el.getBoundingClientRect();
133
- const viewportWidth = window.innerWidth;
134
- const viewportHeight = window.innerHeight;
135
- const overflowsRight = x + width > viewportWidth - margin;
134
+ const bounds = getPositioningBounds(el);
135
+ const overflowsRight = x + width > bounds.right - margin;
136
136
  const left = overflowsRight ? x - width : x;
137
- const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - width - margin));
138
- 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;
139
139
  const top = overflowsBottom ? y - height : y;
140
- const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - margin));
141
- setPlacement({ left: clampedLeft, top: clampedTop });
140
+ const clampedTop = Math.min(Math.max(bounds.top + margin, top), Math.max(bounds.top + margin, bounds.bottom - margin));
141
+ const origin = getFixedPositioningOrigin(el);
142
+ setPlacement({ left: clampedLeft - origin.left, top: clampedTop - origin.top });
142
143
  }, [x, y]);
143
144
  const stage = (nextHsv) => {
144
145
  stagedRef.current = true;
@@ -10,6 +10,8 @@ export interface MediaOverlayProps {
10
10
  showMenu?: boolean;
11
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. */
12
12
  resizable?: boolean;
13
+ /** False for atoms with no editable fields at all (divider, page break) - hides the Alt/Size/Source info block and the Edit button, keeping only Delete. */
14
+ editable?: boolean;
13
15
  onEdit: () => void;
14
16
  onResize: (by: number) => void;
15
17
  onResizeTo: (width: number, height: number) => void;
@@ -22,4 +24,4 @@ export interface MediaOverlayProps {
22
24
  * the pointer is released. The quick-action menu is optional because image
23
25
  * right-clicks use MediaDetailsPopover directly.
24
26
  */
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;
27
+ export declare function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu, resizable, editable, onEdit, onResize, onResizeTo, onDelete, onDismiss }: MediaOverlayProps): import("react/jsx-runtime").JSX.Element;
@@ -78,7 +78,7 @@ const boundsForDirection = (bounds, direction) => {
78
78
  * the pointer is released. The quick-action menu is optional because image
79
79
  * right-clicks use MediaDetailsPopover directly.
80
80
  */
81
- export function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu = true, resizable = true, onEdit, onResize, onResizeTo, onDelete, onDismiss }) {
81
+ export function MediaOverlay({ atomElement, alt, width, height, src, x, y, showMenu = true, resizable = true, editable = true, onEdit, onResize, onResizeTo, onDelete, onDismiss }) {
82
82
  const rootRef = useRef(null);
83
83
  const dragRef = useRef(null);
84
84
  const [placement, setPlacement] = useState(null);
@@ -224,5 +224,5 @@ export function MediaOverlay({ atomElement, alt, width, height, src, x, y, showM
224
224
  event.preventDefault();
225
225
  onDismiss();
226
226
  }
227
- }, children: [_jsxs("div", { style: { fontSize: 12, marginBottom: 8, display: "grid", gap: 2, opacity: 0.85 }, children: [_jsxs("div", { children: [_jsx("strong", { children: "Alt text:" }), " ", alt || "(none)"] }), width && height && _jsxs("div", { children: [_jsx("strong", { children: "Size:" }), " ", Math.round(width), "\u00D7", Math.round(height)] }), src && _jsxs("div", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [_jsx("strong", { children: "Source:" }), " ", src] })] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [_jsx("button", { type: "button", style: buttonStyle, onClick: onEdit, children: "Edit" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(20), children: "Resize +" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(-20), children: "Resize \u03B2\u02C6\u2019" }), _jsx("button", { type: "button", style: { ...buttonStyle, color: "var(--srte-danger)", marginLeft: "auto" }, onClick: onDelete, children: "Delete" })] })] })] }));
227
+ }, children: [editable && _jsxs("div", { style: { fontSize: 12, marginBottom: 8, display: "grid", gap: 2, opacity: 0.85 }, children: [_jsxs("div", { children: [_jsx("strong", { children: "Alt text:" }), " ", alt || "(none)"] }), width && height && _jsxs("div", { children: [_jsx("strong", { children: "Size:" }), " ", Math.round(width), "\u00D7", Math.round(height)] }), src && _jsxs("div", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [_jsx("strong", { children: "Source:" }), " ", src] })] }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [editable && _jsx("button", { type: "button", style: buttonStyle, onClick: onEdit, children: "Edit" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(20), children: "Resize +" }), resizable && _jsx("button", { type: "button", style: buttonStyle, onClick: () => onResize(-20), children: "Resize \u03B2\u02C6\u2019" }), _jsx("button", { type: "button", style: { ...buttonStyle, color: "var(--srte-danger)", marginLeft: "auto" }, onClick: onDelete, children: "Delete" })] })] })] }));
228
228
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import { ColorPickerPopover } from "./ColorPickerPopover.js";
4
+ import { getFixedPositioningOrigin, getPositioningBounds } from "./fixedPositioning.js";
4
5
  // Widths start at 2px, not 1px - table_cell's border-collapse table (see
5
6
  // theme.ts's th/td rule) already renders a 1px ambient default gridline on
6
7
  // every cell. A custom border tied at 1px loses CSS border-collapse's
@@ -67,15 +68,15 @@ export function TableBorderPopover({ x, y, initial, recentColors, onPreview, onA
67
68
  return;
68
69
  const margin = 8;
69
70
  const { width, height } = el.getBoundingClientRect();
70
- const viewportWidth = window.innerWidth;
71
- const viewportHeight = window.innerHeight;
72
- const overflowsRight = x + width > viewportWidth - margin;
71
+ const bounds = getPositioningBounds(el);
72
+ const overflowsRight = x + width > bounds.right - margin;
73
73
  const left = overflowsRight ? x - width : x;
74
- const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - width - margin));
75
- 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;
76
76
  const top = overflowsBottom ? y - height : y;
77
- const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - margin));
78
- setPlacement({ left: clampedLeft, top: clampedTop });
77
+ const clampedTop = Math.min(Math.max(bounds.top + margin, top), Math.max(bounds.top + margin, bounds.bottom - margin));
78
+ const origin = getFixedPositioningOrigin(el);
79
+ setPlacement({ left: clampedLeft - origin.left, top: clampedTop - origin.top });
79
80
  }, [x, y]);
80
81
  useEffect(() => {
81
82
  const dismissIfOutside = (event) => {
@@ -134,6 +134,28 @@ export function TableResizeHandles({ tableElement, onResizeColumn, onResizeRow }
134
134
  if (rowElement)
135
135
  rowElement.style.height = `${target}px`;
136
136
  const actualSize = rowElement ? Math.round(rowElement.getBoundingClientRect().height) : target;
137
+ // If the row below is already sitting at its own natural content
138
+ // floor right now, it never actually had this space to begin with
139
+ // - a *previous* down-drag on this same boundary that hit this
140
+ // row's floor grew the table's total instead of taking space from
141
+ // it (see docs/bugs/row-resize-blocked-when-every-row-is-at-its-
142
+ // floor.md), and that growth is real table height, not this row's
143
+ // slack. Handing it back to the row here would inflate a row that
144
+ // never gave anything up, and a user dragging the border back to
145
+ // where it started would find the table permanently bloated with
146
+ // no way back except Undo (docs/bugs/row-resize-round-trip-
147
+ // inflates-neighbor.md). Measure the row's own floor by
148
+ // temporarily clearing its inline height override; only
149
+ // redistribute into it when it's currently holding real slack
150
+ // above that floor.
151
+ if (nextRowElement) {
152
+ const previousNextHeight = nextRowElement.style.height;
153
+ nextRowElement.style.height = "";
154
+ const nextFloor = Math.round(nextRowElement.getBoundingClientRect().height);
155
+ nextRowElement.style.height = previousNextHeight;
156
+ if (drag.nextStartSize <= nextFloor + 1)
157
+ return { size: actualSize, nextSize: drag.nextStartSize };
158
+ }
137
159
  return { size: actualSize, nextSize: Math.round(drag.nextStartSize - (actualSize - drag.startSize)) };
138
160
  }
139
161
  // Dragging down: the row below is asked to shrink to pay for the
@@ -60,10 +60,13 @@ export declare const toolbarIcons: {
60
60
  readonly undo: import("react/jsx-runtime").JSX.Element;
61
61
  readonly redo: import("react/jsx-runtime").JSX.Element;
62
62
  readonly more: import("react/jsx-runtime").JSX.Element;
63
+ readonly divider: import("react/jsx-runtime").JSX.Element;
64
+ readonly pageBreak: import("react/jsx-runtime").JSX.Element;
65
+ readonly lineHeight: import("react/jsx-runtime").JSX.Element;
63
66
  };
64
67
  export type ToolbarIconKey = keyof typeof toolbarIcons;
65
68
  /** A single always-visible toolbar control: icon + plain-language label together, per the redesign's core principle. */
66
- 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 }: {
67
70
  icon: ToolbarIconKey;
68
71
  label: string;
69
72
  ariaLabel?: string;
@@ -81,6 +84,16 @@ export declare function ToolbarButton({ icon, label, ariaLabel, pressed, disable
81
84
  * docs/bugs/toolbar-priority-collapse-fixed-threshold-no-wide-promotion.md.
82
85
  */
83
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;
84
97
  }): import("react/jsx-runtime").JSX.Element;
85
98
  /** One row inside an open dropdown menu - closes the dropdown after acting, matching ContextMenu.tsx's own dismiss-on-action convention. */
86
99
  export declare function ToolbarMenuItem({ icon, label, ariaLabel, pressed, disabled, title, onClick, widePromote }: {
@@ -110,9 +123,12 @@ export declare function ToolbarMenuItem({ icon, label, ariaLabel, pressed, disab
110
123
  * anchored inside that container gets visually clipped the moment it needs
111
124
  * to extend past the container's own bounds, which is exactly when a
112
125
  * narrow host is most likely to need the dropdown in the first place.
113
- * `position: fixed`'s containing block is the viewport (barring an
114
- * ancestor with its own transform/filter, not the case here), so it always
115
- * escapes ANY ancestor's overflow clipping regardless of DOM depth.
126
+ * `position: fixed`'s containing block is normally the viewport, so this
127
+ * escapes ANY ancestor's overflow clipping regardless of DOM depth - unless
128
+ * an ancestor has its own transform/perspective/filter, which becomes the
129
+ * containing block instead (see fixedPositioning.ts, and
130
+ * docs/bugs/toolbar-menu-misplaced-inside-transformed-ancestor.md); the
131
+ * placement math below accounts for that case too.
116
132
  */
117
133
  export declare function ToolbarDropdown({ icon, label, priority, children }: {
118
134
  icon?: ToolbarIconKey;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
- import { AlignCenter, AlignJustify, AlignLeft, AlignRight, 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, Square, Sigma, Strikethrough, Subscript, Superscript, Table2, Trash2, Type, Underline, Undo2, Unlink2, Upload, Video, Pencil, ZoomIn, ZoomOut, } from "lucide-react";
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, getPositioningBounds } from "./fixedPositioning.js";
4
5
  /**
5
6
  * The toolbar's icon set (Phase: Direction B toolbar redesign, 2026-08-29).
6
7
  * Lucide, per the design proposal's §11 recommendation - MIT-licensed,
@@ -74,10 +75,13 @@ export const toolbarIcons = {
74
75
  undo: _jsx(Undo2, { ...stroke }),
75
76
  redo: _jsx(Redo2, { ...stroke }),
76
77
  more: _jsx(MoreHorizontal, { ...stroke }),
78
+ divider: _jsx(SeparatorHorizontal, { ...stroke }),
79
+ pageBreak: _jsx(ScissorsLineDashed, { ...stroke }),
80
+ lineHeight: _jsx(AlignVerticalSpaceAround, { ...stroke }),
77
81
  };
78
82
  /** A single always-visible toolbar control: icon + plain-language label together, per the redesign's core principle. */
79
- export function ToolbarButton({ icon, label, ariaLabel, pressed, disabled, title, onClick, iconOnly, widePromote }) {
80
- 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 })] });
81
85
  }
82
86
  /** One row inside an open dropdown menu - closes the dropdown after acting, matching ContextMenu.tsx's own dismiss-on-action convention. */
83
87
  export function ToolbarMenuItem({ icon, label, ariaLabel, pressed, disabled, title, onClick, widePromote }) {
@@ -128,9 +132,12 @@ const useDismissDetailsOnOutsideClick = (ref) => {
128
132
  * anchored inside that container gets visually clipped the moment it needs
129
133
  * to extend past the container's own bounds, which is exactly when a
130
134
  * narrow host is most likely to need the dropdown in the first place.
131
- * `position: fixed`'s containing block is the viewport (barring an
132
- * ancestor with its own transform/filter, not the case here), so it always
133
- * escapes ANY ancestor's overflow clipping regardless of DOM depth.
135
+ * `position: fixed`'s containing block is normally the viewport, so this
136
+ * escapes ANY ancestor's overflow clipping regardless of DOM depth - unless
137
+ * an ancestor has its own transform/perspective/filter, which becomes the
138
+ * containing block instead (see fixedPositioning.ts, and
139
+ * docs/bugs/toolbar-menu-misplaced-inside-transformed-ancestor.md); the
140
+ * placement math below accounts for that case too.
134
141
  */
135
142
  export function ToolbarDropdown({ icon, label, priority, children }) {
136
143
  const detailsRef = useRef(null);
@@ -150,12 +157,12 @@ export function ToolbarDropdown({ icon, label, priority, children }) {
150
157
  const menu = details.querySelector(":scope > .srte-menu");
151
158
  const menuWidth = menu?.offsetWidth ?? 210;
152
159
  const menuHeight = menu?.offsetHeight ?? 0;
153
- const viewportWidth = window.innerWidth;
154
- const viewportHeight = window.innerHeight;
155
- const left = Math.min(Math.max(margin, triggerRect.left), Math.max(margin, viewportWidth - menuWidth - margin));
156
- const overflowsBottom = triggerRect.bottom + 6 + menuHeight > viewportHeight - margin;
157
- const top = overflowsBottom ? Math.max(margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
158
- setPlacement({ left, top });
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;
164
+ const origin = getFixedPositioningOrigin(details);
165
+ setPlacement({ left: left - origin.left, top: top - origin.top });
159
166
  };
160
167
  recompute();
161
168
  details.addEventListener("toggle", recompute);
@@ -207,18 +214,19 @@ export function MobileMoreMenu({ children }) {
207
214
  const margin = 8;
208
215
  const triggerRect = details.getBoundingClientRect();
209
216
  const menu = details.querySelector(":scope > .srte-menu");
210
- 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);
211
219
  const menuHeight = menu?.offsetHeight ?? 0;
212
- const viewportWidth = window.innerWidth;
213
- const viewportHeight = window.innerHeight;
214
220
  // Right-aligned to the trigger, matching this menu's original
215
- // CSS-only `right: 0` intent - clamped into the viewport instead of
216
- // 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.
217
224
  const preferredLeft = triggerRect.right - menuWidth;
218
- const left = Math.min(Math.max(margin, preferredLeft), Math.max(margin, viewportWidth - menuWidth - margin));
219
- const overflowsBottom = triggerRect.bottom + 6 + menuHeight > viewportHeight - margin;
220
- const top = overflowsBottom ? Math.max(margin, triggerRect.top - menuHeight - 6) : triggerRect.bottom + 6;
221
- setPlacement({ left, top });
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;
228
+ const origin = getFixedPositioningOrigin(details);
229
+ setPlacement({ left: left - origin.left, top: top - origin.top });
222
230
  };
223
231
  recompute();
224
232
  details.addEventListener("toggle", recompute);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Several unrelated CSS mechanisms make an ancestor the containing block for
3
+ * `position: fixed` descendants instead of the viewport, all covered here
4
+ * since any of them can appear independently on a real host page:
5
+ *
6
+ * - `transform`, `perspective`, `filter`, `backdrop-filter` (non-`none`), or
7
+ * a `will-change` naming one of those (CSS Transforms' rendering model) -
8
+ * most commonly a Radix/shadcn `Dialog`'s centering transform
9
+ * (`translate-x-[-50%] translate-y-[-50%]`), confirmed live via a Sootr
10
+ * host rendering the editor inside such a dialog
11
+ * (docs/bugs/toolbar-menu-misplaced-inside-transformed-ancestor.md).
12
+ * - `translate`, `rotate`, `scale` (CSS Transforms Level 2's *independent*
13
+ * transform properties, distinct from the legacy composite `transform`
14
+ * property) - a FOURTH real Sootr dialog kept misplacing every dropdown
15
+ * after all three fixes above shipped, still using the exact same
16
+ * `translate-x-[-50%] translate-y-[-50%]` Tailwind centering classes the
17
+ * very first fix (above) was written for - but a current Tailwind/browser
18
+ * combination compiles those classes to the standalone `translate: -50%
19
+ * -50%` CSS property, not `transform: translate(-50%, -50%)`. Confirmed
20
+ * directly in real Chromium: an ancestor with only `translate` set (its
21
+ * own computed `transform` reads `none`) still fully establishes a
22
+ * `position: fixed` containing block, per the CSS Transforms Level 2
23
+ * spec - the original fix's own doc comment described the right *symptom*
24
+ * (a Radix/shadcn Dialog's centering translate) but checked the wrong
25
+ * *property* for it, because at the time it was written the transform
26
+ * ended up composited into `transform` rather than emitted as `translate`
27
+ * directly. Both are checked now, since either can appear depending on
28
+ * the host's exact Tailwind/browser version.
29
+ * - `contain: layout | paint | strict | content` (CSS Containment) - a
30
+ * common perf/isolation optimization on large scrollable containers, e.g.
31
+ * a second, different Sootr dialog reported still misplacing every
32
+ * toolbar dropdown ("More list tools", "More to insert", "Save a copy",
33
+ * "More text styles") despite already running the transform-only fix
34
+ * above. `layout` alone (without `paint`) was missed in that first pass -
35
+ * only checked for `paint`/`strict`/`content` - and confirmed directly in
36
+ * real Chromium to establish the containing block on its own regardless:
37
+ * a `position: fixed` descendant of a bare `contain: layout` ancestor (no
38
+ * transform, no paint) resolves its `top`/`left` against that ancestor's
39
+ * box, not the viewport. A THIRD Sootr dialog using exactly this
40
+ * (`contain: layout`, no `paint`) reported the same misplacement symptom
41
+ * again - not a clipping/invisibility symptom (see `getPositioningBounds`
42
+ * below for that distinct failure mode), just landing in the wrong place
43
+ * with no error, since the previous check silently treated this ancestor
44
+ * as if it weren't a containing block at all and fell through to
45
+ * `{ left: 0, top: 0 }` (viewport-relative) origin math instead.
46
+ *
47
+ * Deliberately NOT checking bare `container-type` (CSS Containers) -
48
+ * despite implying `contain: layout style` per spec, verified directly in
49
+ * real Chromium that `container-type: inline-size` alone does *not* make
50
+ * an ancestor a `position: fixed` containing block in practice (a
51
+ * position already correctly computed without any special-casing stayed
52
+ * correct; adding a check for it actively broke that same case). Only
53
+ * `contain` itself is checked, matching what's actually verified to
54
+ * matter, not everything that could theoretically imply it.
55
+ *
56
+ * When any of these apply, `left`/`top` set on a fixed-positioned menu/
57
+ * popover are resolved against that ancestor's border box instead of the
58
+ * viewport, silently producing coordinates that are internally consistent
59
+ * but land far from the trigger with no error.
60
+ *
61
+ * `getBoundingClientRect()` always returns viewport-relative coordinates
62
+ * regardless of any of the above, so every placement calculation in this
63
+ * codebase computes coordinates in viewport space first, then subtracts
64
+ * this origin - the real containing block's own viewport-relative top-left -
65
+ * so the applied `left`/`top` land correctly under any containing block.
66
+ */
67
+ export declare function findFixedPositioningContainer(el: HTMLElement): HTMLElement | null;
68
+ export declare function getFixedPositioningOrigin(el: HTMLElement): {
69
+ left: number;
70
+ top: number;
71
+ };
72
+ /**
73
+ * Correcting the coordinate origin (above) is only half the fix - every
74
+ * overlay in this codebase also clamps its own placement so a menu never
75
+ * renders off-screen, and every one of those clamps was computed against
76
+ * `window.innerWidth`/`innerHeight`, the *full browser viewport*. That's
77
+ * wrong when the containing-block ancestor is one with `contain: paint`
78
+ * (or `strict`/`content`): CSS guarantees nothing painted by a descendant -
79
+ * including a `position: fixed` one whose containing block it is - can
80
+ * ever be visible outside that ancestor's own border box, no matter what
81
+ * `left`/`top` says. Reported live: a toolbar dropdown near the middle of
82
+ * a `contain: paint` dialog rendered hugging the dialog's own right edge
83
+ * (barely fit), and one further right ("More to insert") was positioned
84
+ * entirely outside the dialog's box and so never painted at all - both
85
+ * fully explained by clamping against the wide browser window instead of
86
+ * the actual, much narrower, clipping ancestor.
87
+ *
88
+ * Deliberately NOT applied for a containing block established only by
89
+ * `transform`/`translate`/`rotate`/`scale`/`perspective`/`filter`/
90
+ * `backdrop-filter`/`will-change`, or by `contain: layout` with no
91
+ * `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 declare function getPositioningBounds(el: HTMLElement): {
104
+ left: number;
105
+ top: number;
106
+ right: number;
107
+ bottom: number;
108
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Several unrelated CSS mechanisms make an ancestor the containing block for
3
+ * `position: fixed` descendants instead of the viewport, all covered here
4
+ * since any of them can appear independently on a real host page:
5
+ *
6
+ * - `transform`, `perspective`, `filter`, `backdrop-filter` (non-`none`), or
7
+ * a `will-change` naming one of those (CSS Transforms' rendering model) -
8
+ * most commonly a Radix/shadcn `Dialog`'s centering transform
9
+ * (`translate-x-[-50%] translate-y-[-50%]`), confirmed live via a Sootr
10
+ * host rendering the editor inside such a dialog
11
+ * (docs/bugs/toolbar-menu-misplaced-inside-transformed-ancestor.md).
12
+ * - `translate`, `rotate`, `scale` (CSS Transforms Level 2's *independent*
13
+ * transform properties, distinct from the legacy composite `transform`
14
+ * property) - a FOURTH real Sootr dialog kept misplacing every dropdown
15
+ * after all three fixes above shipped, still using the exact same
16
+ * `translate-x-[-50%] translate-y-[-50%]` Tailwind centering classes the
17
+ * very first fix (above) was written for - but a current Tailwind/browser
18
+ * combination compiles those classes to the standalone `translate: -50%
19
+ * -50%` CSS property, not `transform: translate(-50%, -50%)`. Confirmed
20
+ * directly in real Chromium: an ancestor with only `translate` set (its
21
+ * own computed `transform` reads `none`) still fully establishes a
22
+ * `position: fixed` containing block, per the CSS Transforms Level 2
23
+ * spec - the original fix's own doc comment described the right *symptom*
24
+ * (a Radix/shadcn Dialog's centering translate) but checked the wrong
25
+ * *property* for it, because at the time it was written the transform
26
+ * ended up composited into `transform` rather than emitted as `translate`
27
+ * directly. Both are checked now, since either can appear depending on
28
+ * the host's exact Tailwind/browser version.
29
+ * - `contain: layout | paint | strict | content` (CSS Containment) - a
30
+ * common perf/isolation optimization on large scrollable containers, e.g.
31
+ * a second, different Sootr dialog reported still misplacing every
32
+ * toolbar dropdown ("More list tools", "More to insert", "Save a copy",
33
+ * "More text styles") despite already running the transform-only fix
34
+ * above. `layout` alone (without `paint`) was missed in that first pass -
35
+ * only checked for `paint`/`strict`/`content` - and confirmed directly in
36
+ * real Chromium to establish the containing block on its own regardless:
37
+ * a `position: fixed` descendant of a bare `contain: layout` ancestor (no
38
+ * transform, no paint) resolves its `top`/`left` against that ancestor's
39
+ * box, not the viewport. A THIRD Sootr dialog using exactly this
40
+ * (`contain: layout`, no `paint`) reported the same misplacement symptom
41
+ * again - not a clipping/invisibility symptom (see `getPositioningBounds`
42
+ * below for that distinct failure mode), just landing in the wrong place
43
+ * with no error, since the previous check silently treated this ancestor
44
+ * as if it weren't a containing block at all and fell through to
45
+ * `{ left: 0, top: 0 }` (viewport-relative) origin math instead.
46
+ *
47
+ * Deliberately NOT checking bare `container-type` (CSS Containers) -
48
+ * despite implying `contain: layout style` per spec, verified directly in
49
+ * real Chromium that `container-type: inline-size` alone does *not* make
50
+ * an ancestor a `position: fixed` containing block in practice (a
51
+ * position already correctly computed without any special-casing stayed
52
+ * correct; adding a check for it actively broke that same case). Only
53
+ * `contain` itself is checked, matching what's actually verified to
54
+ * matter, not everything that could theoretically imply it.
55
+ *
56
+ * When any of these apply, `left`/`top` set on a fixed-positioned menu/
57
+ * popover are resolved against that ancestor's border box instead of the
58
+ * viewport, silently producing coordinates that are internally consistent
59
+ * but land far from the trigger with no error.
60
+ *
61
+ * `getBoundingClientRect()` always returns viewport-relative coordinates
62
+ * regardless of any of the above, so every placement calculation in this
63
+ * codebase computes coordinates in viewport space first, then subtracts
64
+ * this origin - the real containing block's own viewport-relative top-left -
65
+ * so the applied `left`/`top` land correctly under any containing block.
66
+ */
67
+ export function findFixedPositioningContainer(el) {
68
+ let node = el.parentElement;
69
+ while (node) {
70
+ const style = window.getComputedStyle(node);
71
+ if ((style.transform && style.transform !== "none") ||
72
+ (style.translate && style.translate !== "none") ||
73
+ (style.rotate && style.rotate !== "none") ||
74
+ (style.scale && style.scale !== "none") ||
75
+ (style.perspective && style.perspective !== "none") ||
76
+ (style.filter && style.filter !== "none") ||
77
+ (style.backdropFilter && style.backdropFilter !== "none") ||
78
+ (style.willChange && /transform|perspective|filter|translate|rotate|scale/.test(style.willChange)) ||
79
+ (style.contain && /\b(layout|paint|strict|content)\b/.test(style.contain))) {
80
+ return node;
81
+ }
82
+ node = node.parentElement;
83
+ }
84
+ return null;
85
+ }
86
+ export function getFixedPositioningOrigin(el) {
87
+ const container = findFixedPositioningContainer(el);
88
+ if (!container)
89
+ return { left: 0, top: 0 };
90
+ const rect = container.getBoundingClientRect();
91
+ return { left: rect.left, top: rect.top };
92
+ }
93
+ /**
94
+ * Correcting the coordinate origin (above) is only half the fix - every
95
+ * overlay in this codebase also clamps its own placement so a menu never
96
+ * renders off-screen, and every one of those clamps was computed against
97
+ * `window.innerWidth`/`innerHeight`, the *full browser viewport*. That's
98
+ * wrong when the containing-block ancestor is one with `contain: paint`
99
+ * (or `strict`/`content`): CSS guarantees nothing painted by a descendant -
100
+ * including a `position: fixed` one whose containing block it is - can
101
+ * ever be visible outside that ancestor's own border box, no matter what
102
+ * `left`/`top` says. Reported live: a toolbar dropdown near the middle of
103
+ * a `contain: paint` dialog rendered hugging the dialog's own right edge
104
+ * (barely fit), and one further right ("More to insert") was positioned
105
+ * entirely outside the dialog's box and so never painted at all - both
106
+ * fully explained by clamping against the wide browser window instead of
107
+ * the actual, much narrower, clipping ancestor.
108
+ *
109
+ * Deliberately NOT applied for a containing block established only by
110
+ * `transform`/`translate`/`rotate`/`scale`/`perspective`/`filter`/
111
+ * `backdrop-filter`/`will-change`, or by `contain: layout` with no
112
+ * `paint`/`strict`/`content` alongside it - none
113
+ * of those clip overflow (`layout` containment only isolates layout
114
+ * calculations, it does not imply paint containment - see the spec note
115
+ * above `findFixedPositioningContainer`), so a menu is free to visually
116
+ * extend past that ancestor's box exactly as it could before any of this
117
+ * positioning logic existed. Verified directly: constraining
118
+ * to such an ancestor's bounds regardless made a mobile "More tools" menu
119
+ * genuinely too tall to fit inside a short transformed host overlap its
120
+ * own trigger instead of just extending past the host's edge (harmless,
121
+ * since nothing there clips it) - a real regression from being too
122
+ * conservative, not a fix.
123
+ */
124
+ export function getPositioningBounds(el) {
125
+ const viewport = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
126
+ const container = findFixedPositioningContainer(el);
127
+ if (!container)
128
+ return viewport;
129
+ const containerStyle = window.getComputedStyle(container);
130
+ if (!containerStyle.contain || !/\b(paint|strict|content)\b/.test(containerStyle.contain))
131
+ return viewport;
132
+ const rect = container.getBoundingClientRect();
133
+ return {
134
+ left: Math.max(viewport.left, rect.left),
135
+ top: Math.max(viewport.top, rect.top),
136
+ right: Math.min(viewport.right, rect.right),
137
+ bottom: Math.min(viewport.bottom, rect.bottom),
138
+ };
139
+ }