@beyondwork/docx-react-component 1.0.92 → 1.0.94
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/package.json +1 -1
- package/src/api/v3/ui/_types.ts +26 -2
- package/src/api/v3/ui/index.ts +2 -0
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +109 -42
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +29 -12
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +78 -16
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +389 -109
- package/src/ui-tailwind/tw-review-workspace.tsx +5 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.94",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
package/src/api/v3/ui/_types.ts
CHANGED
|
@@ -25,8 +25,12 @@
|
|
|
25
25
|
|
|
26
26
|
import type {
|
|
27
27
|
OverlayKind,
|
|
28
|
+
ScopeCardModel,
|
|
28
29
|
ScopeBundle,
|
|
30
|
+
ScopeRailSnapshot,
|
|
29
31
|
SelectionSnapshot,
|
|
32
|
+
SemanticScope,
|
|
33
|
+
SemanticScopeKind,
|
|
30
34
|
WorkflowMarkupMode,
|
|
31
35
|
} from "../../public-types.ts";
|
|
32
36
|
import type { GeometryRect } from "../../../runtime/geometry/geometry-types.ts";
|
|
@@ -549,11 +553,31 @@ export interface ApiV3UiDebug {
|
|
|
549
553
|
detach(): void;
|
|
550
554
|
}
|
|
551
555
|
|
|
556
|
+
export interface UiScopeListFilter {
|
|
557
|
+
readonly kind?: SemanticScopeKind;
|
|
558
|
+
readonly limit?: number;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export interface UiScopeRailOptions {
|
|
562
|
+
/**
|
|
563
|
+
* Narrow the snapshot to a single page index. Omit to span every page
|
|
564
|
+
* the runtime's page graph knows about.
|
|
565
|
+
*/
|
|
566
|
+
readonly pageIndex?: number;
|
|
567
|
+
}
|
|
568
|
+
|
|
552
569
|
/**
|
|
553
|
-
* Scope seam — mounted-path scope
|
|
554
|
-
*
|
|
570
|
+
* Scope seam — mounted-path scope reads. Uses handle-backed compiled
|
|
571
|
+
* projections so chrome surfaces do not reach directly into workflow
|
|
572
|
+
* facets for list, card, rail, or bundle data.
|
|
555
573
|
*/
|
|
556
574
|
export interface ApiV3UiScope {
|
|
575
|
+
/** Enumerate compiled semantic scopes, optionally narrowed by kind/limit. */
|
|
576
|
+
list(filter?: UiScopeListFilter): readonly SemanticScope[];
|
|
577
|
+
/** Read the scope-card projection for a single scope id. */
|
|
578
|
+
card(scopeId: string): ScopeCardModel | null;
|
|
579
|
+
/** Read scope-rail segments across the document or a single page. */
|
|
580
|
+
rail(options?: UiScopeRailOptions): ScopeRailSnapshot;
|
|
557
581
|
/**
|
|
558
582
|
* Read the full `ScopeBundle` for a scope by id. Returns `null` when
|
|
559
583
|
* the scope does not enumerate today (handle drift, synthetic scope,
|
package/src/api/v3/ui/index.ts
CHANGED
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
* names sees the right option highlighted without code changes.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import React, { useState } from "react";
|
|
17
|
-
import
|
|
16
|
+
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
17
|
+
import { createPortal } from "react-dom";
|
|
18
18
|
import { ChevronDown, Eye, EyeOff, Highlighter, Scroll } from "lucide-react";
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
21
|
normalizeMarkupDisplay,
|
|
22
22
|
type MarkupDisplay,
|
|
23
23
|
} from "../../ui/headless/comment-decoration-model";
|
|
24
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
24
25
|
|
|
25
26
|
export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
|
|
26
27
|
|
|
@@ -67,70 +68,136 @@ const MODES: readonly ModeEntry[] = [
|
|
|
67
68
|
|
|
68
69
|
export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
|
|
69
70
|
const [open, setOpen] = useState(false);
|
|
71
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
70
72
|
const canonical = normalizeMarkupDisplay(props.value);
|
|
71
73
|
const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
|
|
72
74
|
|
|
73
75
|
return (
|
|
74
|
-
|
|
75
|
-
<Popover.Trigger asChild>
|
|
76
|
+
<>
|
|
76
77
|
<button
|
|
78
|
+
ref={triggerRef}
|
|
77
79
|
type="button"
|
|
78
80
|
disabled={props.disabled}
|
|
79
81
|
data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
|
|
80
82
|
aria-label={`Display mode: ${activeEntry.label}`}
|
|
81
|
-
|
|
83
|
+
aria-expanded={open}
|
|
84
|
+
aria-haspopup="menu"
|
|
85
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
86
|
+
onClick={(event) => {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
setOpen((value) => !value);
|
|
89
|
+
}}
|
|
90
|
+
className={[
|
|
91
|
+
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary",
|
|
92
|
+
"hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50",
|
|
93
|
+
open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : "",
|
|
94
|
+
].join(" ")}
|
|
82
95
|
>
|
|
83
96
|
<activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
|
|
84
97
|
<span>{activeEntry.label}</span>
|
|
85
98
|
<ChevronDown className="h-3.5 w-3.5 text-tertiary" />
|
|
86
99
|
</button>
|
|
87
|
-
|
|
88
|
-
<Popover.Portal>
|
|
89
|
-
<Popover.Content
|
|
90
|
-
className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
|
|
91
|
-
sideOffset={8}
|
|
92
|
-
align="end"
|
|
93
|
-
data-testid="display-mode-selector-content"
|
|
94
|
-
>
|
|
100
|
+
<DisplayModePortalMenu anchorRef={triggerRef} open={open}>
|
|
95
101
|
<div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
96
102
|
Display mode
|
|
97
103
|
</div>
|
|
98
104
|
{MODES.map((entry) => {
|
|
99
105
|
const isActive = entry.mode === canonical;
|
|
100
106
|
return (
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<span className="text-[10px] text-secondary">{entry.hint}</span>
|
|
107
|
+
<button
|
|
108
|
+
key={entry.mode}
|
|
109
|
+
type="button"
|
|
110
|
+
role="menuitemradio"
|
|
111
|
+
aria-checked={isActive}
|
|
112
|
+
onClick={() => {
|
|
113
|
+
props.onChange(entry.mode);
|
|
114
|
+
setOpen(false);
|
|
115
|
+
}}
|
|
116
|
+
className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
|
|
117
|
+
data-testid={`display-mode-option-${entry.mode}`}
|
|
118
|
+
data-mode={entry.mode}
|
|
119
|
+
data-active={isActive ? "true" : undefined}
|
|
120
|
+
>
|
|
121
|
+
<entry.icon
|
|
122
|
+
className={[
|
|
123
|
+
"mt-0.5 h-3.5 w-3.5 shrink-0",
|
|
124
|
+
isActive ? "text-accent" : "text-tertiary",
|
|
125
|
+
].join(" ")}
|
|
126
|
+
/>
|
|
127
|
+
<span className="flex flex-col">
|
|
128
|
+
<span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
|
|
129
|
+
{entry.label}
|
|
125
130
|
</span>
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
<span className="text-[10px] text-secondary">{entry.hint}</span>
|
|
132
|
+
</span>
|
|
133
|
+
</button>
|
|
128
134
|
);
|
|
129
135
|
})}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
</DisplayModePortalMenu>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function DisplayModePortalMenu(props: {
|
|
142
|
+
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
|
143
|
+
children: React.ReactNode;
|
|
144
|
+
open: boolean;
|
|
145
|
+
}): React.ReactPortal | null {
|
|
146
|
+
const style = useDisplayModePortalPosition(props.anchorRef, props.open);
|
|
147
|
+
const body = props.anchorRef.current?.ownerDocument?.body;
|
|
148
|
+
if (!props.open || !body) return null;
|
|
149
|
+
return createPortal(
|
|
150
|
+
<div
|
|
151
|
+
className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
|
|
152
|
+
data-testid="display-mode-selector-content"
|
|
153
|
+
style={style}
|
|
154
|
+
>
|
|
155
|
+
{props.children}
|
|
156
|
+
</div>,
|
|
157
|
+
body,
|
|
133
158
|
);
|
|
134
159
|
}
|
|
135
160
|
|
|
161
|
+
function useDisplayModePortalPosition(
|
|
162
|
+
anchorRef: React.RefObject<HTMLButtonElement | null>,
|
|
163
|
+
open: boolean,
|
|
164
|
+
): CSSProperties {
|
|
165
|
+
const [style, setStyle] = useState<CSSProperties>({
|
|
166
|
+
left: 8,
|
|
167
|
+
position: "fixed",
|
|
168
|
+
top: 8,
|
|
169
|
+
zIndex: 50,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
if (!open) return;
|
|
174
|
+
const anchor = anchorRef.current;
|
|
175
|
+
const ownerWindow = anchor?.ownerDocument?.defaultView;
|
|
176
|
+
if (!anchor || !ownerWindow) return;
|
|
177
|
+
const update = () => {
|
|
178
|
+
const rect = anchor.getBoundingClientRect();
|
|
179
|
+
const width = 260;
|
|
180
|
+
const left = Math.min(
|
|
181
|
+
Math.max(8, rect.right - width),
|
|
182
|
+
Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
|
|
183
|
+
);
|
|
184
|
+
setStyle({
|
|
185
|
+
left,
|
|
186
|
+
position: "fixed",
|
|
187
|
+
top: Math.max(8, rect.bottom + 8),
|
|
188
|
+
zIndex: 50,
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
update();
|
|
192
|
+
ownerWindow.addEventListener("resize", update);
|
|
193
|
+
ownerWindow.addEventListener("scroll", update, true);
|
|
194
|
+
return () => {
|
|
195
|
+
ownerWindow.removeEventListener("resize", update);
|
|
196
|
+
ownerWindow.removeEventListener("scroll", update, true);
|
|
197
|
+
};
|
|
198
|
+
}, [anchorRef, open]);
|
|
199
|
+
|
|
200
|
+
return style;
|
|
201
|
+
}
|
|
202
|
+
|
|
136
203
|
export default TwDisplayModeSelector;
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "./chrome-overlay-projector";
|
|
26
26
|
import { TwScopeCard } from "./tw-scope-card";
|
|
27
27
|
import { useLocalSurfaceRequest } from "../chrome/local-surface-arbiter";
|
|
28
|
+
import { useUiApi } from "../ui-api-context";
|
|
28
29
|
import type {
|
|
29
30
|
EditorRole,
|
|
30
31
|
ScopeCardModel,
|
|
@@ -46,12 +47,14 @@ export interface TwScopeCardLayerProps {
|
|
|
46
47
|
*/
|
|
47
48
|
facet: WordReviewEditorLayoutFacet;
|
|
48
49
|
/**
|
|
49
|
-
* Workflow facet —
|
|
50
|
-
*
|
|
51
|
-
* "no cards".
|
|
50
|
+
* Workflow facet — fallback source for no-provider paths. Mounted
|
|
51
|
+
* editor surfaces read scope cards through `api.ui.scope.card(...)`.
|
|
52
|
+
* `null` is treated as "no cards".
|
|
52
53
|
*/
|
|
53
54
|
workflowFacet: WorkflowFacet | null;
|
|
54
55
|
activeScopeId: string | null;
|
|
56
|
+
/** Scope ids currently visible under the Workflow rail layer filters. */
|
|
57
|
+
visibleScopeIds?: ReadonlySet<string>;
|
|
55
58
|
onClose: () => void;
|
|
56
59
|
onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
|
|
57
60
|
onIssueAction: (
|
|
@@ -91,6 +94,7 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
91
94
|
facet,
|
|
92
95
|
workflowFacet,
|
|
93
96
|
activeScopeId,
|
|
97
|
+
visibleScopeIds,
|
|
94
98
|
onClose,
|
|
95
99
|
onModeChange,
|
|
96
100
|
onIssueAction,
|
|
@@ -104,6 +108,7 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
104
108
|
"data-testid": testId,
|
|
105
109
|
}) => {
|
|
106
110
|
const [pinnedScopeId, setPinnedScopeId] = React.useState<string | null>(null);
|
|
111
|
+
const ui = useUiApi();
|
|
107
112
|
|
|
108
113
|
// If the layer's close handler fires, also clear any pin so the
|
|
109
114
|
// next stripe click starts from a clean state.
|
|
@@ -116,14 +121,27 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
116
121
|
setPinnedScopeId((current) => (current === scopeId ? null : scopeId));
|
|
117
122
|
}, []);
|
|
118
123
|
|
|
124
|
+
const getWorkflowScopeCardModel = React.useCallback(
|
|
125
|
+
(scopeId: string): ScopeCardModel | null =>
|
|
126
|
+
workflowFacet?.getAllScopeCardModels().find((m) => m.scopeId === scopeId) ??
|
|
127
|
+
null,
|
|
128
|
+
[workflowFacet],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const getVisibleScopeCardModel = React.useCallback(
|
|
132
|
+
(scopeId: string | null): ScopeCardModel | null => {
|
|
133
|
+
if (!scopeId) return null;
|
|
134
|
+
if (visibleScopeIds && !visibleScopeIds.has(scopeId)) return null;
|
|
135
|
+
if (ui) return ui.scope.card(scopeId);
|
|
136
|
+
return getWorkflowScopeCardModel(scopeId);
|
|
137
|
+
},
|
|
138
|
+
[getWorkflowScopeCardModel, ui, visibleScopeIds],
|
|
139
|
+
);
|
|
140
|
+
|
|
119
141
|
// The effective scope is the pinned one if it still resolves to a
|
|
120
142
|
// model, else the active one. When a pinned scope disappears
|
|
121
143
|
// (e.g. the host cleared the overlay), drop the pin.
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
const pinnedModel = pinnedScopeId
|
|
125
|
-
? models.find((m) => m.scopeId === pinnedScopeId) ?? null
|
|
126
|
-
: null;
|
|
144
|
+
const pinnedModel = getVisibleScopeCardModel(pinnedScopeId);
|
|
127
145
|
|
|
128
146
|
// R2.b — when a pinned scope disappears from the model list (e.g. the
|
|
129
147
|
// host cleared the overlay), drop the pin. Must run in an effect, not
|
|
@@ -132,10 +150,10 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
132
150
|
// exceeded" in React 18 concurrent mode where renders are retried
|
|
133
151
|
// before commit.
|
|
134
152
|
React.useEffect(() => {
|
|
135
|
-
if (pinnedScopeId && !
|
|
153
|
+
if (pinnedScopeId && !pinnedModel) {
|
|
136
154
|
setPinnedScopeId(null);
|
|
137
155
|
}
|
|
138
|
-
}, [pinnedScopeId,
|
|
156
|
+
}, [pinnedScopeId, pinnedModel]);
|
|
139
157
|
|
|
140
158
|
const effectiveScopeId = pinnedModel ? pinnedScopeId : activeScopeId;
|
|
141
159
|
const isPinnedEffective = pinnedScopeId !== null && pinnedScopeId === effectiveScopeId;
|
|
@@ -157,8 +175,7 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
157
175
|
);
|
|
158
176
|
|
|
159
177
|
if (!effectiveScopeId) return null;
|
|
160
|
-
const model
|
|
161
|
-
pinnedModel ?? models.find((m) => m.scopeId === effectiveScopeId);
|
|
178
|
+
const model = pinnedModel ?? getVisibleScopeCardModel(effectiveScopeId);
|
|
162
179
|
if (!model) return null;
|
|
163
180
|
if (!isArbiterActive) return null;
|
|
164
181
|
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* traversal + claim/skip/complete.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import React, { useState } from "react";
|
|
17
|
-
import
|
|
16
|
+
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
17
|
+
import { createPortal } from "react-dom";
|
|
18
18
|
import * as Toggle from "@radix-ui/react-toggle";
|
|
19
19
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
20
20
|
import {
|
|
@@ -433,38 +433,100 @@ function RoleActionOverflow({
|
|
|
433
433
|
props,
|
|
434
434
|
}: RoleActionOverflowProps): React.JSX.Element {
|
|
435
435
|
const [open, setOpen] = useState(false);
|
|
436
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
436
437
|
|
|
437
438
|
return (
|
|
438
|
-
|
|
439
|
-
<Popover.Trigger asChild>
|
|
439
|
+
<>
|
|
440
440
|
<button
|
|
441
|
+
ref={triggerRef}
|
|
441
442
|
type="button"
|
|
442
443
|
aria-label="More role actions"
|
|
443
444
|
aria-expanded={open}
|
|
445
|
+
aria-haspopup="menu"
|
|
444
446
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
447
|
+
onClick={(event) => {
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
setOpen((value) => !value);
|
|
450
|
+
}}
|
|
445
451
|
title="More role actions"
|
|
446
|
-
className=
|
|
452
|
+
className={`inline-flex h-6 w-6 items-center justify-center rounded-md border border-border bg-canvas text-primary transition-colors hover:bg-surface outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
|
|
453
|
+
open ? "text-accent ring-1 ring-accent/30 shadow-sm" : ""
|
|
454
|
+
}`}
|
|
447
455
|
data-testid="role-action-overflow-trigger"
|
|
448
456
|
>
|
|
449
457
|
<MoreHorizontal className="h-3.5 w-3.5 text-tertiary" aria-hidden="true" />
|
|
450
458
|
</button>
|
|
451
|
-
|
|
452
|
-
<Popover.Portal>
|
|
453
|
-
<Popover.Content
|
|
454
|
-
className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
|
|
455
|
-
sideOffset={8}
|
|
456
|
-
align="start"
|
|
457
|
-
data-testid="role-action-overflow-content"
|
|
458
|
-
>
|
|
459
|
+
<RoleActionPortalMenu anchorRef={triggerRef} open={open}>
|
|
459
460
|
{ids.map((id) => (
|
|
460
461
|
<OverflowAction key={id} id={id} props={props} onClose={() => setOpen(false)} />
|
|
461
462
|
))}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
463
|
+
</RoleActionPortalMenu>
|
|
464
|
+
</>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function RoleActionPortalMenu(props: {
|
|
469
|
+
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
|
470
|
+
children: React.ReactNode;
|
|
471
|
+
open: boolean;
|
|
472
|
+
}): React.ReactPortal | null {
|
|
473
|
+
const style = useRoleActionPortalPosition(props.anchorRef, props.open);
|
|
474
|
+
const body = props.anchorRef.current?.ownerDocument?.body;
|
|
475
|
+
if (!props.open || !body) return null;
|
|
476
|
+
return createPortal(
|
|
477
|
+
<div
|
|
478
|
+
className="z-50 w-[220px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
|
|
479
|
+
data-testid="role-action-overflow-content"
|
|
480
|
+
style={style}
|
|
481
|
+
>
|
|
482
|
+
{props.children}
|
|
483
|
+
</div>,
|
|
484
|
+
body,
|
|
465
485
|
);
|
|
466
486
|
}
|
|
467
487
|
|
|
488
|
+
function useRoleActionPortalPosition(
|
|
489
|
+
anchorRef: React.RefObject<HTMLButtonElement | null>,
|
|
490
|
+
open: boolean,
|
|
491
|
+
): CSSProperties {
|
|
492
|
+
const [style, setStyle] = useState<CSSProperties>({
|
|
493
|
+
left: 8,
|
|
494
|
+
position: "fixed",
|
|
495
|
+
top: 8,
|
|
496
|
+
zIndex: 50,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
useLayoutEffect(() => {
|
|
500
|
+
if (!open) return;
|
|
501
|
+
const anchor = anchorRef.current;
|
|
502
|
+
const ownerWindow = anchor?.ownerDocument?.defaultView;
|
|
503
|
+
if (!anchor || !ownerWindow) return;
|
|
504
|
+
const update = () => {
|
|
505
|
+
const rect = anchor.getBoundingClientRect();
|
|
506
|
+
const width = 220;
|
|
507
|
+
const left = Math.min(
|
|
508
|
+
Math.max(8, rect.left),
|
|
509
|
+
Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
|
|
510
|
+
);
|
|
511
|
+
setStyle({
|
|
512
|
+
left,
|
|
513
|
+
position: "fixed",
|
|
514
|
+
top: Math.max(8, rect.bottom + 8),
|
|
515
|
+
zIndex: 50,
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
update();
|
|
519
|
+
ownerWindow.addEventListener("resize", update);
|
|
520
|
+
ownerWindow.addEventListener("scroll", update, true);
|
|
521
|
+
return () => {
|
|
522
|
+
ownerWindow.removeEventListener("resize", update);
|
|
523
|
+
ownerWindow.removeEventListener("scroll", update, true);
|
|
524
|
+
};
|
|
525
|
+
}, [anchorRef, open]);
|
|
526
|
+
|
|
527
|
+
return style;
|
|
528
|
+
}
|
|
529
|
+
|
|
468
530
|
function OverflowAction(arg: {
|
|
469
531
|
id: ToolbarChromeItemId;
|
|
470
532
|
props: TwRoleActionRegionProps;
|
|
@@ -27,6 +27,8 @@ export interface TwToolbarIconButtonProps {
|
|
|
27
27
|
* vs. Windows however they like.
|
|
28
28
|
*/
|
|
29
29
|
shortcut?: string;
|
|
30
|
+
/** Explanation surfaced when the button is disabled by selection/mode state. */
|
|
31
|
+
disabledReason?: string;
|
|
30
32
|
onClick?: () => void;
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -42,7 +44,9 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
|
|
|
42
44
|
aria-label={props.label}
|
|
43
45
|
aria-pressed={props.active ?? undefined}
|
|
44
46
|
data-active={props.active ? "true" : undefined}
|
|
47
|
+
data-disabled-reason={props.disabled && props.disabledReason ? props.disabledReason : undefined}
|
|
45
48
|
disabled={props.disabled}
|
|
49
|
+
title={props.disabled && props.disabledReason ? `Not available: ${props.disabledReason}` : undefined}
|
|
46
50
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
47
51
|
onClick={props.onClick}
|
|
48
52
|
className={[
|