@beyondwork/docx-react-component 1.0.53 → 1.0.54
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/public-types.ts +35 -7
- package/src/io/docx-session.ts +30 -6
- package/src/runtime/collab/checkpoint-store.ts +1 -1
- package/src/runtime/collab/event-types.ts +4 -0
- package/src/runtime/collab/runtime-collab-sync.ts +1 -2
- package/src/runtime/document-runtime.ts +23 -9
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +58 -1
- package/src/runtime/layout/layout-invalidation.ts +150 -30
- package/src/runtime/layout/page-graph.ts +19 -0
- package/src/runtime/layout/paginated-layout-engine.ts +128 -19
- package/src/runtime/layout/project-block-fragments.ts +27 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/ui/WordReviewEditor.tsx +6 -3
- package/src/ui/headless/comment-decoration-model.ts +60 -5
- package/src/ui/headless/revision-decoration-model.ts +94 -6
- package/src/ui/shared/revision-filters.ts +16 -6
- package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
- package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
- package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
- package/src/ui-tailwind/chart/render/area.tsx +277 -0
- package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
- package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
- package/src/ui-tailwind/chart/render/combo.tsx +85 -0
- package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
- package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
- package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
- package/src/ui-tailwind/chart/render/line.tsx +363 -0
- package/src/ui-tailwind/chart/render/number-format.ts +120 -16
- package/src/ui-tailwind/chart/render/pie.tsx +275 -0
- package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
- package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
- package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
- package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
- package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
- package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
- package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
- package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
- package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
- package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
- package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
- package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
- package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
- package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
- package/src/ui-tailwind/theme/editor-theme.css +249 -22
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — Slice N2: display-mode selector (P11.10).
|
|
3
|
+
*
|
|
4
|
+
* Chrome-toolbar Popover exposing Word's 4 markup display modes:
|
|
5
|
+
* - All Markup — every tracked change + comment highlighted
|
|
6
|
+
* - Simple Markup — compact indicators in the gutter, subtle spans
|
|
7
|
+
* - No Markup — accepted-preview: insertions inline, deletions hidden
|
|
8
|
+
* - Original — rejected-preview: insertions hidden, deletions as plain text
|
|
9
|
+
*
|
|
10
|
+
* Values are emitted in Word's canonical grammar (`"all-markup"` etc.).
|
|
11
|
+
* Legacy `"clean" | "simple" | "all"` values are accepted as `value`
|
|
12
|
+
* via `normalizeMarkupDisplay`, so a host that already passes the old
|
|
13
|
+
* names sees the right option highlighted without code changes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState } from "react";
|
|
17
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
18
|
+
import { ChevronDown, Eye, EyeOff, Highlighter, Scroll } from "lucide-react";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
normalizeMarkupDisplay,
|
|
22
|
+
type MarkupDisplay,
|
|
23
|
+
} from "../../ui/headless/comment-decoration-model";
|
|
24
|
+
|
|
25
|
+
export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
|
|
26
|
+
|
|
27
|
+
export interface TwDisplayModeSelectorProps {
|
|
28
|
+
value: MarkupDisplay;
|
|
29
|
+
onChange: (value: DisplayMode) => void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
"data-testid"?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ModeEntry {
|
|
35
|
+
mode: DisplayMode;
|
|
36
|
+
label: string;
|
|
37
|
+
hint: string;
|
|
38
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MODES: readonly ModeEntry[] = [
|
|
42
|
+
{
|
|
43
|
+
mode: "all-markup",
|
|
44
|
+
label: "All Markup",
|
|
45
|
+
hint: "Every tracked change and comment visible",
|
|
46
|
+
icon: Highlighter,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
mode: "simple-markup",
|
|
50
|
+
label: "Simple Markup",
|
|
51
|
+
hint: "Compact indicators in the gutter",
|
|
52
|
+
icon: Scroll,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
mode: "no-markup",
|
|
56
|
+
label: "No Markup",
|
|
57
|
+
hint: "Preview as if all changes accepted",
|
|
58
|
+
icon: Eye,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
mode: "original",
|
|
62
|
+
label: "Original",
|
|
63
|
+
hint: "Preview as if all changes rejected",
|
|
64
|
+
icon: EyeOff,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
|
|
69
|
+
const [open, setOpen] = useState(false);
|
|
70
|
+
const canonical = normalizeMarkupDisplay(props.value);
|
|
71
|
+
const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
75
|
+
<Popover.Trigger asChild>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
disabled={props.disabled}
|
|
79
|
+
data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
|
|
80
|
+
aria-label={`Display mode: ${activeEntry.label}`}
|
|
81
|
+
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50"
|
|
82
|
+
>
|
|
83
|
+
<activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
|
|
84
|
+
<span>{activeEntry.label}</span>
|
|
85
|
+
<ChevronDown className="h-3.5 w-3.5 text-tertiary" />
|
|
86
|
+
</button>
|
|
87
|
+
</Popover.Trigger>
|
|
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
|
+
>
|
|
95
|
+
<div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
96
|
+
Display mode
|
|
97
|
+
</div>
|
|
98
|
+
{MODES.map((entry) => {
|
|
99
|
+
const isActive = entry.mode === canonical;
|
|
100
|
+
return (
|
|
101
|
+
<Popover.Close key={entry.mode} asChild>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
role="menuitemradio"
|
|
105
|
+
aria-checked={isActive}
|
|
106
|
+
onClick={() => {
|
|
107
|
+
props.onChange(entry.mode);
|
|
108
|
+
}}
|
|
109
|
+
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"
|
|
110
|
+
data-testid={`display-mode-option-${entry.mode}`}
|
|
111
|
+
data-mode={entry.mode}
|
|
112
|
+
data-active={isActive ? "true" : undefined}
|
|
113
|
+
>
|
|
114
|
+
<entry.icon
|
|
115
|
+
className={[
|
|
116
|
+
"mt-0.5 h-3.5 w-3.5 shrink-0",
|
|
117
|
+
isActive ? "text-accent" : "text-tertiary",
|
|
118
|
+
].join(" ")}
|
|
119
|
+
/>
|
|
120
|
+
<span className="flex flex-col">
|
|
121
|
+
<span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
|
|
122
|
+
{entry.label}
|
|
123
|
+
</span>
|
|
124
|
+
<span className="text-[10px] text-secondary">{entry.hint}</span>
|
|
125
|
+
</span>
|
|
126
|
+
</button>
|
|
127
|
+
</Popover.Close>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</Popover.Content>
|
|
131
|
+
</Popover.Portal>
|
|
132
|
+
</Popover.Root>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default TwDisplayModeSelector;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TwEmptyStateProps {
|
|
4
|
+
icon?: ReactNode;
|
|
5
|
+
title?: string;
|
|
6
|
+
body?: string;
|
|
7
|
+
action?: { label: string; onClick: () => void };
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const focusRingClass =
|
|
12
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
|
|
13
|
+
|
|
14
|
+
export function TwEmptyState(props: TwEmptyStateProps): React.JSX.Element {
|
|
15
|
+
const { icon, title, body, action, className } = props;
|
|
16
|
+
|
|
17
|
+
const containerClass = [
|
|
18
|
+
"rounded-[var(--radius-lg)]",
|
|
19
|
+
"bg-[var(--color-bg-muted)]",
|
|
20
|
+
"ring-1 ring-[var(--color-border-subtle)]/40",
|
|
21
|
+
"px-4 py-5",
|
|
22
|
+
"flex flex-col items-center text-center gap-1.5",
|
|
23
|
+
className,
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join(" ");
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={containerClass}
|
|
31
|
+
data-testid="tw-empty-state"
|
|
32
|
+
>
|
|
33
|
+
{icon !== undefined && (
|
|
34
|
+
<span
|
|
35
|
+
className="h-5 w-5 text-[var(--color-text-tertiary)]"
|
|
36
|
+
data-testid="tw-empty-state__icon"
|
|
37
|
+
aria-hidden="true"
|
|
38
|
+
>
|
|
39
|
+
{icon}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
{title !== undefined && (
|
|
43
|
+
<p
|
|
44
|
+
className="text-[13px] font-semibold text-[var(--color-text-primary)]"
|
|
45
|
+
data-testid="tw-empty-state__title"
|
|
46
|
+
>
|
|
47
|
+
{title}
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
50
|
+
{body !== undefined && (
|
|
51
|
+
<p
|
|
52
|
+
className="text-[12px] text-[var(--color-text-tertiary)] leading-snug"
|
|
53
|
+
data-testid="tw-empty-state__body"
|
|
54
|
+
>
|
|
55
|
+
{body}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
{action !== undefined && (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className={[
|
|
62
|
+
"mt-1 inline-flex items-center h-7 rounded-[var(--radius-sm)]",
|
|
63
|
+
"border border-[var(--color-border-default)] bg-[var(--color-bg-canvas)]",
|
|
64
|
+
"px-3 text-[12px] font-medium text-[var(--color-text-primary)]",
|
|
65
|
+
"hover:bg-[var(--color-bg-hover)]",
|
|
66
|
+
focusRingClass,
|
|
67
|
+
].join(" ")}
|
|
68
|
+
data-testid="tw-empty-state__action"
|
|
69
|
+
onClick={action.onClick}
|
|
70
|
+
>
|
|
71
|
+
{action.label}
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -30,7 +30,7 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
|
|
|
30
30
|
return (
|
|
31
31
|
<div
|
|
32
32
|
data-testid="image-context-toolbar"
|
|
33
|
-
className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-canvas px-2.5 py-1.5 shadow-
|
|
33
|
+
className="flex flex-wrap items-center gap-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2.5 py-1.5 shadow-[var(--shadow-float)]"
|
|
34
34
|
>
|
|
35
35
|
<span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
36
36
|
Image
|
|
@@ -38,20 +38,34 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
|
|
|
38
38
|
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
|
|
39
39
|
{activeImage.display}
|
|
40
40
|
</span>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
<div role="group" aria-label="Image size" className="inline-flex items-center rounded-[var(--radius-sm)] bg-[var(--color-bg-muted)] p-0.5">
|
|
42
|
+
{IMAGE_SIZE_PRESETS.map((preset) => {
|
|
43
|
+
const isActive =
|
|
44
|
+
activeImage.widthEmu === preset.widthEmu &&
|
|
45
|
+
activeImage.heightEmu === preset.heightEmu;
|
|
46
|
+
const shortLabel = preset.label.replace(" image", "");
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
key={preset.label}
|
|
50
|
+
type="button"
|
|
51
|
+
aria-pressed={isActive}
|
|
52
|
+
aria-label={preset.label}
|
|
53
|
+
disabled={props.disabled || !props.onSetImageLayout}
|
|
54
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
55
|
+
onClick={() =>
|
|
56
|
+
props.onSetImageLayout?.(activeImage.mediaId, {
|
|
57
|
+
widthEmu: preset.widthEmu,
|
|
58
|
+
heightEmu: preset.heightEmu,
|
|
59
|
+
})}
|
|
60
|
+
className={`inline-flex h-6 items-center px-2 text-[11px] font-medium rounded-[var(--radius-sm)] transition-colors disabled:cursor-not-allowed disabled:opacity-40
|
|
61
|
+
aria-pressed:bg-[var(--color-bg-selected)] aria-pressed:text-[var(--color-accent-primary)]
|
|
62
|
+
text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]`}
|
|
63
|
+
>
|
|
64
|
+
{shortLabel}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
55
69
|
{activeImage.display === "floating" ? (
|
|
56
70
|
<>
|
|
57
71
|
<ToolbarButton
|
|
@@ -113,7 +127,7 @@ function ToolbarButton(props: {
|
|
|
113
127
|
disabled={props.disabled}
|
|
114
128
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
115
129
|
onClick={props.onClick}
|
|
116
|
-
className="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-primary transition-colors hover:bg-
|
|
130
|
+
className="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-accent-soft)] disabled:cursor-not-allowed disabled:opacity-40"
|
|
117
131
|
>
|
|
118
132
|
{props.children}
|
|
119
133
|
</button>
|
|
@@ -5,13 +5,31 @@ export interface TwObjectContextToolbarProps {
|
|
|
5
5
|
activeObject: ActiveObjectContext;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function InfoIcon(props: { className?: string; "aria-hidden"?: boolean }) {
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
12
|
+
viewBox="0 0 20 20"
|
|
13
|
+
fill="currentColor"
|
|
14
|
+
className={props.className}
|
|
15
|
+
aria-hidden={props["aria-hidden"]}
|
|
16
|
+
>
|
|
17
|
+
<path
|
|
18
|
+
fillRule="evenodd"
|
|
19
|
+
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
|
|
20
|
+
clipRule="evenodd"
|
|
21
|
+
/>
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
export function TwObjectContextToolbar(props: TwObjectContextToolbarProps) {
|
|
9
27
|
const label = props.activeObject.kind === "textbox" ? "Text box" : "Shape";
|
|
10
28
|
|
|
11
29
|
return (
|
|
12
30
|
<div
|
|
13
31
|
data-testid="object-context-toolbar"
|
|
14
|
-
className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-canvas px-2.5 py-1.5 shadow-
|
|
32
|
+
className="flex flex-wrap items-center gap-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2.5 py-1.5 shadow-[var(--shadow-float)]"
|
|
15
33
|
>
|
|
16
34
|
<span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
17
35
|
Object
|
|
@@ -22,9 +40,10 @@ export function TwObjectContextToolbar(props: TwObjectContextToolbarProps) {
|
|
|
22
40
|
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
|
|
23
41
|
{props.activeObject.display}
|
|
24
42
|
</span>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
<div className="inline-flex items-center gap-1.5">
|
|
44
|
+
<InfoIcon className="h-3.5 w-3.5 text-[var(--color-semantic-info)]" aria-hidden={true} />
|
|
45
|
+
<span className="text-[11px] text-[var(--color-text-secondary)]">Shape preserved for export — opens in Word.</span>
|
|
46
|
+
</div>
|
|
28
47
|
</div>
|
|
29
48
|
);
|
|
30
49
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { AlertTriangle } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export interface TwPasteDropToastProps {
|
|
5
|
+
/**
|
|
6
|
+
* Current blocked-input payload. When it changes to a non-null value
|
|
7
|
+
* the toast renders and self-dismisses after `durationMs`. Pass a
|
|
8
|
+
* fresh object (not the same reference) for each new event — the
|
|
9
|
+
* identity change triggers the timer.
|
|
10
|
+
*/
|
|
11
|
+
event: {
|
|
12
|
+
command: "paste" | "drop";
|
|
13
|
+
message: string;
|
|
14
|
+
/**
|
|
15
|
+
* Optional unique id per event so repeated events with the same
|
|
16
|
+
* command / message still re-trigger the toast. Defaults to the
|
|
17
|
+
* current time when unset.
|
|
18
|
+
*/
|
|
19
|
+
eventId?: string;
|
|
20
|
+
} | null;
|
|
21
|
+
/** How long the toast stays visible, in ms (default 3000). */
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
/** Called when the toast fades out. */
|
|
24
|
+
onDismiss?: () => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* TwPasteDropToast — non-blocking bottom-right toast surfaced when the
|
|
30
|
+
* runtime refuses a paste / drop input (Lane 6b §6b.U7).
|
|
31
|
+
*
|
|
32
|
+
* Host wiring pattern (see `src/ui-tailwind/editor-surface/tw-prosemirror-
|
|
33
|
+
* surface.tsx`):
|
|
34
|
+
*
|
|
35
|
+
* const [blockedInput, setBlockedInput] = useState(null);
|
|
36
|
+
* // Wire the surface callback → state:
|
|
37
|
+
* onBlockedInput={(command, message) =>
|
|
38
|
+
* setBlockedInput({ command, message, eventId: String(Date.now()) })
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* // Mount the toast once in the shell zone:
|
|
42
|
+
* <TwPasteDropToast
|
|
43
|
+
* event={blockedInput}
|
|
44
|
+
* onDismiss={() => setBlockedInput(null)}
|
|
45
|
+
* />
|
|
46
|
+
*
|
|
47
|
+
* All colours / shadow / radius / motion bind Lane 6a tokens. Severity
|
|
48
|
+
* is treated as `warning` (semantic-warning-soft bg + semantic-warning
|
|
49
|
+
* glyph) — the input was refused, not dropped to the floor.
|
|
50
|
+
*/
|
|
51
|
+
export function TwPasteDropToast(
|
|
52
|
+
props: TwPasteDropToastProps,
|
|
53
|
+
): React.ReactElement | null {
|
|
54
|
+
// Start visible on any non-null event so SSR renders the toast. Auto-
|
|
55
|
+
// dismiss runs client-side via setTimeout after `durationMs`.
|
|
56
|
+
const [dismissed, setDismissed] = useState(false);
|
|
57
|
+
const duration = props.durationMs ?? 3000;
|
|
58
|
+
const eventKey = props.event
|
|
59
|
+
? `${props.event.command}:${props.event.eventId ?? props.event.message}`
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!props.event) {
|
|
64
|
+
setDismissed(false);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setDismissed(false);
|
|
68
|
+
const handle = setTimeout(() => {
|
|
69
|
+
setDismissed(true);
|
|
70
|
+
props.onDismiss?.();
|
|
71
|
+
}, duration);
|
|
72
|
+
return () => clearTimeout(handle);
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [eventKey, duration]);
|
|
75
|
+
|
|
76
|
+
if (!props.event || dismissed) return null;
|
|
77
|
+
|
|
78
|
+
const label =
|
|
79
|
+
props.event.command === "paste" ? "Paste blocked" : "Drop blocked";
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
role="status"
|
|
84
|
+
aria-live="polite"
|
|
85
|
+
data-testid="tw-paste-drop-toast"
|
|
86
|
+
data-command={props.event.command}
|
|
87
|
+
className={[
|
|
88
|
+
"fixed bottom-4 right-4 z-50",
|
|
89
|
+
"inline-flex items-start gap-3",
|
|
90
|
+
"max-w-sm px-3 py-2",
|
|
91
|
+
"rounded-[var(--radius-sm)]",
|
|
92
|
+
"bg-[var(--color-semantic-warning-soft)] text-[var(--color-semantic-warning)]",
|
|
93
|
+
"shadow-[var(--shadow-float)]",
|
|
94
|
+
"border border-[var(--color-border-subtle)]",
|
|
95
|
+
"transition-opacity duration-[var(--motion-default)]",
|
|
96
|
+
props.className,
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join(" ")}
|
|
100
|
+
>
|
|
101
|
+
<AlertTriangle
|
|
102
|
+
className="mt-0.5 h-4 w-4 shrink-0"
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
/>
|
|
105
|
+
<div className="min-w-0 flex-1">
|
|
106
|
+
<div className="text-xs font-semibold">{label}</div>
|
|
107
|
+
<div className="mt-0.5 text-[11px] text-[var(--color-text-secondary)]">
|
|
108
|
+
{props.event.message}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — Slice N4: revision hover preview (P11.12).
|
|
3
|
+
*
|
|
4
|
+
* Wrapper that renders its children unchanged, but attaches a
|
|
5
|
+
* 250 ms-delayed Radix Popover showing a compact summary card for a
|
|
6
|
+
* tracked change:
|
|
7
|
+
* - Author display name (optionally colored by N3's palette index)
|
|
8
|
+
* - Relative timestamp (e.g. "3 hours ago")
|
|
9
|
+
* - Kind label ("Inserted", "Deleted", "Formatting")
|
|
10
|
+
* - Short excerpt of the affected text
|
|
11
|
+
*
|
|
12
|
+
* Re-entering the Popover.Content keeps it open so the reviewer can
|
|
13
|
+
* click through to the change card. When `hidden` is true (e.g. the
|
|
14
|
+
* host is in `no-markup` display mode) the wrapper passes its children
|
|
15
|
+
* through without attaching the popover at all.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React, { useCallback, useRef, useState } from "react";
|
|
19
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
AUTHOR_PALETTE,
|
|
23
|
+
getAuthorColor,
|
|
24
|
+
} from "../../ui/headless/revision-decoration-model";
|
|
25
|
+
|
|
26
|
+
export const HOVER_DELAY_MS = 250;
|
|
27
|
+
|
|
28
|
+
export type RevisionKind = "insertion" | "deletion" | "formatting";
|
|
29
|
+
|
|
30
|
+
export interface TwRevisionHoverPreviewProps {
|
|
31
|
+
/** Stable id for the tracked change — used as the Popover's aria key. */
|
|
32
|
+
revisionId: string;
|
|
33
|
+
authorId?: string;
|
|
34
|
+
authorDisplayName?: string;
|
|
35
|
+
/** Relative-time string (e.g. "3 hours ago"); rendered verbatim. */
|
|
36
|
+
relativeTime?: string;
|
|
37
|
+
kind: RevisionKind;
|
|
38
|
+
excerpt?: string;
|
|
39
|
+
/**
|
|
40
|
+
* When true, pass children through without the popover wrapper.
|
|
41
|
+
* Hosts set this when `markupDisplay === "no-markup"` or when
|
|
42
|
+
* hovering previews would be noisy (selection active, print preview,
|
|
43
|
+
* etc).
|
|
44
|
+
*/
|
|
45
|
+
hidden?: boolean;
|
|
46
|
+
children: React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const KIND_LABEL: Record<RevisionKind, string> = {
|
|
50
|
+
insertion: "Inserted",
|
|
51
|
+
deletion: "Deleted",
|
|
52
|
+
formatting: "Formatting change",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function TwRevisionHoverPreview(props: TwRevisionHoverPreviewProps): React.ReactElement {
|
|
56
|
+
const { hidden, children } = props;
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
59
|
+
|
|
60
|
+
const cancelTimer = useCallback(() => {
|
|
61
|
+
if (openTimerRef.current !== null) {
|
|
62
|
+
clearTimeout(openTimerRef.current);
|
|
63
|
+
openTimerRef.current = null;
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const handleMouseEnter = useCallback(() => {
|
|
68
|
+
cancelTimer();
|
|
69
|
+
openTimerRef.current = setTimeout(() => {
|
|
70
|
+
setOpen(true);
|
|
71
|
+
openTimerRef.current = null;
|
|
72
|
+
}, HOVER_DELAY_MS);
|
|
73
|
+
}, [cancelTimer]);
|
|
74
|
+
|
|
75
|
+
const handleMouseLeave = useCallback(() => {
|
|
76
|
+
cancelTimer();
|
|
77
|
+
// Defer close so moving onto the Popover.Content keeps it open.
|
|
78
|
+
openTimerRef.current = setTimeout(() => {
|
|
79
|
+
setOpen(false);
|
|
80
|
+
openTimerRef.current = null;
|
|
81
|
+
}, 80);
|
|
82
|
+
}, [cancelTimer]);
|
|
83
|
+
|
|
84
|
+
if (hidden) {
|
|
85
|
+
return <>{children}</>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const authorColor = getAuthorColor(props.authorId);
|
|
89
|
+
const author = props.authorDisplayName ?? props.authorId ?? "Unknown author";
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
93
|
+
<Popover.Trigger asChild>
|
|
94
|
+
<span
|
|
95
|
+
data-revision-id={props.revisionId}
|
|
96
|
+
data-revision-hover-trigger=""
|
|
97
|
+
onMouseEnter={handleMouseEnter}
|
|
98
|
+
onMouseLeave={handleMouseLeave}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</span>
|
|
102
|
+
</Popover.Trigger>
|
|
103
|
+
<Popover.Portal>
|
|
104
|
+
<Popover.Content
|
|
105
|
+
role="dialog"
|
|
106
|
+
aria-label={`Revision by ${author}`}
|
|
107
|
+
className="wre-revision-hover-card z-50 w-[280px] rounded-[var(--radius-card,8px)] bg-[var(--color-card,var(--color-surface))] p-3 text-[11px] shadow-[var(--shadow-soft)] ring-1 ring-[var(--color-border-default)]"
|
|
108
|
+
sideOffset={6}
|
|
109
|
+
onMouseEnter={cancelTimer}
|
|
110
|
+
onMouseLeave={handleMouseLeave}
|
|
111
|
+
data-testid="revision-hover-preview-content"
|
|
112
|
+
>
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
{authorColor ? (
|
|
115
|
+
<span
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
data-kind="author-swatch"
|
|
118
|
+
style={{
|
|
119
|
+
display: "inline-block",
|
|
120
|
+
width: "10px",
|
|
121
|
+
height: "10px",
|
|
122
|
+
borderRadius: "var(--radius-pill)",
|
|
123
|
+
backgroundColor: authorColor,
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
) : null}
|
|
127
|
+
<span className="font-medium text-primary">{author}</span>
|
|
128
|
+
{props.relativeTime ? (
|
|
129
|
+
<span className="text-tertiary">· {props.relativeTime}</span>
|
|
130
|
+
) : null}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="mt-1 text-tertiary">{KIND_LABEL[props.kind]}</div>
|
|
133
|
+
{props.excerpt ? (
|
|
134
|
+
<div className="mt-2 rounded bg-[var(--color-surface)] p-2 text-secondary">
|
|
135
|
+
{props.excerpt}
|
|
136
|
+
</div>
|
|
137
|
+
) : null}
|
|
138
|
+
</Popover.Content>
|
|
139
|
+
</Popover.Portal>
|
|
140
|
+
</Popover.Root>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Exported for tests — guards against the palette drifting out of
|
|
146
|
+
* sync with the hover-preview swatch.
|
|
147
|
+
*/
|
|
148
|
+
export { AUTHOR_PALETTE };
|
|
149
|
+
|
|
150
|
+
export default TwRevisionHoverPreview;
|
|
@@ -14,6 +14,7 @@ export interface TwSelectionToolFormattingProps {
|
|
|
14
14
|
onSetTextColor?: (color: string) => void;
|
|
15
15
|
onSetHighlightColor?: (color: string | null) => void;
|
|
16
16
|
onAddComment?: () => void;
|
|
17
|
+
density?: "micro" | "full";
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export const TwSelectionToolFormatting = forwardRef<HTMLDivElement, TwSelectionToolFormattingProps>(
|
|
@@ -31,6 +32,7 @@ export const TwSelectionToolFormatting = forwardRef<HTMLDivElement, TwSelectionT
|
|
|
31
32
|
onSetTextColor={props.onSetTextColor}
|
|
32
33
|
onSetHighlightColor={props.onSetHighlightColor}
|
|
33
34
|
onAddComment={props.onAddComment}
|
|
35
|
+
density={props.density}
|
|
34
36
|
/>
|
|
35
37
|
);
|
|
36
38
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, type CSSProperties, type FocusEventHandler, type Ref } from "react";
|
|
1
|
+
import React, { useCallback, useEffect, useState, type CSSProperties, type FocusEventHandler, type Ref } from "react";
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
ChromePinsState,
|
|
@@ -73,7 +73,41 @@ export interface TwSelectionToolHostProps {
|
|
|
73
73
|
onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Dwell promotion: starts at "micro" on selection change, promotes to
|
|
78
|
+
* "full" after 150ms of selection stability. Keyed on the tool kind and
|
|
79
|
+
* previewText so that a new selection (which produces a new previewText)
|
|
80
|
+
* resets the timer. Pure re-renders with the same selection do not reset.
|
|
81
|
+
*/
|
|
82
|
+
function useDwellDensity(tool: ActiveSelectionToolModel | null): "micro" | "full" {
|
|
83
|
+
// Derive a stable selection key from the tool. For formatting-inline we
|
|
84
|
+
// use the previewText (reflects selected content). For other kinds we use
|
|
85
|
+
// a fixed sentinel so they never reset mid-render.
|
|
86
|
+
const selectionKey = tool
|
|
87
|
+
? `${tool.kind}:${tool.previewText ?? "none"}`
|
|
88
|
+
: null;
|
|
89
|
+
const [density, setDensity] = useState<"micro" | "full">("micro");
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (selectionKey === null) {
|
|
93
|
+
setDensity("micro");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
setDensity("micro");
|
|
97
|
+
const handle = window.setTimeout(() => {
|
|
98
|
+
setDensity("full");
|
|
99
|
+
}, 150);
|
|
100
|
+
return () => {
|
|
101
|
+
window.clearTimeout(handle);
|
|
102
|
+
};
|
|
103
|
+
}, [selectionKey]);
|
|
104
|
+
|
|
105
|
+
return density;
|
|
106
|
+
}
|
|
107
|
+
|
|
76
108
|
export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
109
|
+
const density = useDwellDensity(props.tool);
|
|
110
|
+
|
|
77
111
|
if (!props.tool) {
|
|
78
112
|
return null;
|
|
79
113
|
}
|
|
@@ -96,7 +130,7 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
|
96
130
|
);
|
|
97
131
|
|
|
98
132
|
const overlayTestId = getOverlayTestId(props.tool.kind, Boolean(props.placement));
|
|
99
|
-
const toolContent = renderTool(props, props.tool);
|
|
133
|
+
const toolContent = renderTool(props, props.tool, density);
|
|
100
134
|
const content = toolContent ? (
|
|
101
135
|
<div
|
|
102
136
|
ref={props.rootRef}
|
|
@@ -185,6 +219,7 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
|
185
219
|
function renderTool(
|
|
186
220
|
props: TwSelectionToolHostProps,
|
|
187
221
|
tool: ActiveSelectionToolModel,
|
|
222
|
+
density: "micro" | "full",
|
|
188
223
|
): React.ReactNode {
|
|
189
224
|
switch (tool.kind) {
|
|
190
225
|
case "formatting-inline":
|
|
@@ -197,6 +232,7 @@ function renderTool(
|
|
|
197
232
|
onSetTextColor={props.onSetTextColor}
|
|
198
233
|
onSetHighlightColor={props.onSetHighlightColor}
|
|
199
234
|
onAddComment={props.onAddComment}
|
|
235
|
+
density={density}
|
|
200
236
|
/>
|
|
201
237
|
);
|
|
202
238
|
case "suggestion-review":
|