smartrte-react 1.0.0-beta.9 → 1.1.1
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 +50 -0
- package/LICENSE +21 -0
- package/README.md +239 -745
- package/dist/components/BlockquoteBorderPopover.d.ts +30 -0
- package/dist/components/BlockquoteBorderPopover.js +115 -0
- package/dist/components/CanonicalAuthorityEditor.js +289 -41
- package/dist/components/ColorPickerPopover.js +6 -7
- package/dist/components/MediaOverlay.d.ts +3 -1
- package/dist/components/MediaOverlay.js +2 -2
- package/dist/components/TableBorderPopover.js +6 -7
- package/dist/components/TableResizeHandles.js +22 -0
- package/dist/components/ToolbarPrimitives.d.ts +14 -1
- package/dist/components/ToolbarPrimitives.js +19 -17
- package/dist/components/fixedPositioning.d.ts +99 -16
- package/dist/components/fixedPositioning.js +114 -17
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +88 -0
- package/dist/toolbarTools.d.ts +6 -0
- package/dist/toolbarTools.js +3 -2
- package/package.json +2 -2
|
@@ -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]);
|
|
@@ -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,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]);
|
|
@@ -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 }: {
|
|
@@ -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
|
-
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";
|
|
4
|
-
import { getFixedPositioningOrigin } from "./fixedPositioning.js";
|
|
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";
|
|
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,
|
|
@@ -75,10 +75,13 @@ export const toolbarIcons = {
|
|
|
75
75
|
undo: _jsx(Undo2, { ...stroke }),
|
|
76
76
|
redo: _jsx(Redo2, { ...stroke }),
|
|
77
77
|
more: _jsx(MoreHorizontal, { ...stroke }),
|
|
78
|
+
divider: _jsx(SeparatorHorizontal, { ...stroke }),
|
|
79
|
+
pageBreak: _jsx(ScissorsLineDashed, { ...stroke }),
|
|
80
|
+
lineHeight: _jsx(AlignVerticalSpaceAround, { ...stroke }),
|
|
78
81
|
};
|
|
79
82
|
/** A single always-visible toolbar control: icon + plain-language label together, per the redesign's core principle. */
|
|
80
|
-
export function ToolbarButton({ icon, label, ariaLabel, pressed, disabled, title, onClick, iconOnly, widePromote }) {
|
|
81
|
-
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 })] });
|
|
82
85
|
}
|
|
83
86
|
/** One row inside an open dropdown menu - closes the dropdown after acting, matching ContextMenu.tsx's own dismiss-on-action convention. */
|
|
84
87
|
export function ToolbarMenuItem({ icon, label, ariaLabel, pressed, disabled, title, onClick, widePromote }) {
|
|
@@ -154,11 +157,10 @@ export function ToolbarDropdown({ icon, label, priority, children }) {
|
|
|
154
157
|
const menu = details.querySelector(":scope > .srte-menu");
|
|
155
158
|
const menuWidth = menu?.offsetWidth ?? 210;
|
|
156
159
|
const menuHeight = menu?.offsetHeight ?? 0;
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
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;
|
|
162
164
|
const origin = getFixedPositioningOrigin(details);
|
|
163
165
|
setPlacement({ left: left - origin.left, top: top - origin.top });
|
|
164
166
|
};
|
|
@@ -212,17 +214,17 @@ export function MobileMoreMenu({ children }) {
|
|
|
212
214
|
const margin = 8;
|
|
213
215
|
const triggerRect = details.getBoundingClientRect();
|
|
214
216
|
const menu = details.querySelector(":scope > .srte-menu");
|
|
215
|
-
const
|
|
217
|
+
const bounds = getPositioningBounds(details);
|
|
218
|
+
const menuWidth = menu?.offsetWidth ?? Math.min(280, bounds.right - bounds.left - 16);
|
|
216
219
|
const menuHeight = menu?.offsetHeight ?? 0;
|
|
217
|
-
const viewportWidth = window.innerWidth;
|
|
218
|
-
const viewportHeight = window.innerHeight;
|
|
219
220
|
// Right-aligned to the trigger, matching this menu's original
|
|
220
|
-
// CSS-only `right: 0` intent - clamped into the
|
|
221
|
-
// 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.
|
|
222
224
|
const preferredLeft = triggerRect.right - menuWidth;
|
|
223
|
-
const left = Math.min(Math.max(margin, preferredLeft), Math.max(margin,
|
|
224
|
-
const overflowsBottom = triggerRect.bottom + 6 + menuHeight >
|
|
225
|
-
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;
|
|
226
228
|
const origin = getFixedPositioningOrigin(details);
|
|
227
229
|
setPlacement({ left: left - origin.left, top: top - origin.top });
|
|
228
230
|
};
|
|
@@ -1,25 +1,108 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
60
|
*
|
|
15
61
|
* `getBoundingClientRect()` always returns viewport-relative coordinates
|
|
16
|
-
* regardless of
|
|
17
|
-
* computes coordinates in viewport space first, then subtracts
|
|
18
|
-
* origin - the real containing block's own viewport-relative top-left -
|
|
19
|
-
* the applied `left`/`top` land correctly under
|
|
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.
|
|
20
66
|
*/
|
|
21
67
|
export declare function findFixedPositioningContainer(el: HTMLElement): HTMLElement | null;
|
|
22
68
|
export declare function getFixedPositioningOrigin(el: HTMLElement): {
|
|
23
69
|
left: number;
|
|
24
70
|
top: number;
|
|
25
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
|
+
};
|
|
@@ -1,32 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
60
|
*
|
|
15
61
|
* `getBoundingClientRect()` always returns viewport-relative coordinates
|
|
16
|
-
* regardless of
|
|
17
|
-
* computes coordinates in viewport space first, then subtracts
|
|
18
|
-
* origin - the real containing block's own viewport-relative top-left -
|
|
19
|
-
* the applied `left`/`top` land correctly under
|
|
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.
|
|
20
66
|
*/
|
|
21
67
|
export function findFixedPositioningContainer(el) {
|
|
22
68
|
let node = el.parentElement;
|
|
23
69
|
while (node) {
|
|
24
70
|
const style = window.getComputedStyle(node);
|
|
25
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") ||
|
|
26
75
|
(style.perspective && style.perspective !== "none") ||
|
|
27
76
|
(style.filter && style.filter !== "none") ||
|
|
28
77
|
(style.backdropFilter && style.backdropFilter !== "none") ||
|
|
29
|
-
(style.willChange && /transform|perspective|filter/.test(style.willChange))
|
|
78
|
+
(style.willChange && /transform|perspective|filter|translate|rotate|scale/.test(style.willChange)) ||
|
|
79
|
+
(style.contain && /\b(layout|paint|strict|content)\b/.test(style.contain))) {
|
|
30
80
|
return node;
|
|
31
81
|
}
|
|
32
82
|
node = node.parentElement;
|
|
@@ -40,3 +90,50 @@ export function getFixedPositioningOrigin(el) {
|
|
|
40
90
|
const rect = container.getBoundingClientRect();
|
|
41
91
|
return { left: rect.left, top: rect.top };
|
|
42
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
|
+
}
|