@beyondwork/docx-react-component 1.0.53 → 1.0.55
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 +125 -7
- package/src/index.ts +5 -0
- package/src/io/docx-session.ts +27 -3
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/parse-field-switches.ts +134 -0
- package/src/io/ooxml/parse-fields.ts +28 -2
- package/src/model/canonical-document.ts +13 -2
- package/src/runtime/chart/chart-model-store.ts +88 -0
- package/src/runtime/chart/chart-snapshot.ts +239 -0
- 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 +115 -13
- 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/page-number-format.ts +207 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/surface-projection.ts +32 -3
- package/src/ui/WordReviewEditor.tsx +57 -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/chart-node-view.tsx +90 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- 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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smooth-curve helper — Catmull-Rom → cubic Bézier (Stage 4 Slice 4B, B4).
|
|
3
|
+
*
|
|
4
|
+
* Emits an SVG path string for a smoothed polyline through the given
|
|
5
|
+
* points. Word's `c:smooth` switch produces a Bezier/Catmull-Rom family
|
|
6
|
+
* curve (per blog.splitwise.com / walice/beziersplines reverse-
|
|
7
|
+
* engineering); LibreOffice uses a cubic natural spline. We use
|
|
8
|
+
* Catmull-Rom with tension=0.5 (the canonical choice that matches
|
|
9
|
+
* Word's output within ~1-2 pixels at 100% zoom for typical 4-20 knot
|
|
10
|
+
* datasets).
|
|
11
|
+
*
|
|
12
|
+
* The conversion Catmull-Rom → cubic Bézier emits a sequence of `C`
|
|
13
|
+
* commands between every pair of adjacent knots, computing control
|
|
14
|
+
* points from the 4-point sliding window (p[i-1], p[i], p[i+1], p[i+2])
|
|
15
|
+
* with endpoint reflection for the first/last segments.
|
|
16
|
+
*
|
|
17
|
+
* Falls back to a simple polyline (`M … L …`) for n < 3.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Public API
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type Point = readonly [number, number];
|
|
25
|
+
|
|
26
|
+
export interface SmoothCurveOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Catmull-Rom tension. 0.5 = centripetal-ish; 0 = uniform; 1 = chordal.
|
|
29
|
+
* Word's observed behavior matches tension ≈ 0.5.
|
|
30
|
+
*/
|
|
31
|
+
tension?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return an SVG path `d` string for a smoothed curve through `points`.
|
|
36
|
+
*
|
|
37
|
+
* n=0 → "" (no path)
|
|
38
|
+
* n=1 → "M x y" (single-point marker)
|
|
39
|
+
* n=2 → "M x1 y1 L x2 y2" (straight line)
|
|
40
|
+
* n≥3 → "M x0 y0 C cp1x cp1y cp2x cp2y x1 y1 C …"
|
|
41
|
+
*
|
|
42
|
+
* The first and last segments reflect their neighbors (no virtual
|
|
43
|
+
* overshoot), matching Word's endpoint behavior.
|
|
44
|
+
*/
|
|
45
|
+
export function smoothPath(points: ReadonlyArray<Point>, options: SmoothCurveOptions = {}): string {
|
|
46
|
+
if (points.length === 0) return "";
|
|
47
|
+
if (points.length === 1) return `M ${fmt(points[0]![0])} ${fmt(points[0]![1])}`;
|
|
48
|
+
if (points.length === 2) {
|
|
49
|
+
return `M ${fmt(points[0]![0])} ${fmt(points[0]![1])} L ${fmt(points[1]![0])} ${fmt(points[1]![1])}`;
|
|
50
|
+
}
|
|
51
|
+
const tension = options.tension ?? 0.5;
|
|
52
|
+
const parts: string[] = [`M ${fmt(points[0]![0])} ${fmt(points[0]![1])}`];
|
|
53
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
54
|
+
const p0 = i === 0 ? reflect(points[1]!, points[0]!) : points[i - 1]!;
|
|
55
|
+
const p1 = points[i]!;
|
|
56
|
+
const p2 = points[i + 1]!;
|
|
57
|
+
const p3 = i === points.length - 2 ? reflect(points[i]!, points[i + 1]!) : points[i + 2]!;
|
|
58
|
+
const cp1 = controlPoint(p0, p1, p2, tension);
|
|
59
|
+
const cp2 = controlPoint(p3, p2, p1, tension);
|
|
60
|
+
parts.push(
|
|
61
|
+
`C ${fmt(cp1[0])} ${fmt(cp1[1])} ${fmt(cp2[0])} ${fmt(cp2[1])} ${fmt(p2[0])} ${fmt(p2[1])}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return parts.join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Implementation
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute a Catmull-Rom control point for the segment from p1 to p2.
|
|
73
|
+
* The control point lives at `p1 + (p2 - p0) * tension / 3`, which is
|
|
74
|
+
* the classic conversion of a Catmull-Rom tangent into a cubic-Bézier
|
|
75
|
+
* handle.
|
|
76
|
+
*/
|
|
77
|
+
function controlPoint(p0: Point, p1: Point, p2: Point, tension: number): Point {
|
|
78
|
+
return [
|
|
79
|
+
p1[0] + ((p2[0] - p0[0]) * tension) / 3,
|
|
80
|
+
p1[1] + ((p2[1] - p0[1]) * tension) / 3,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reflect `ref` across `anchor` to produce the "virtual" point used at
|
|
86
|
+
* polyline endpoints (so the curve's tangent at the endpoint matches
|
|
87
|
+
* the chord, preventing overshoot).
|
|
88
|
+
*/
|
|
89
|
+
function reflect(ref: Point, anchor: Point): Point {
|
|
90
|
+
return [2 * anchor[0] - ref[0], 2 * anchor[1] - ref[1]];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format a number to at most 3 decimal places without trailing zeros.
|
|
95
|
+
* Keeps SVG path strings compact and deterministic.
|
|
96
|
+
*/
|
|
97
|
+
function fmt(n: number): string {
|
|
98
|
+
if (!Number.isFinite(n)) return "0";
|
|
99
|
+
const rounded = Math.round(n * 1000) / 1000;
|
|
100
|
+
return rounded === 0 ? "0" : String(rounded);
|
|
101
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG element helpers for chart rendering (Stage 3B).
|
|
3
|
+
*
|
|
4
|
+
* Each helper returns a plain object (`React.createElement` props) rather
|
|
5
|
+
* than JSX so this file stays free of the `.tsx` extension and its build
|
|
6
|
+
* dependency. Chart renderer components assemble the returned props into
|
|
7
|
+
* JSX:
|
|
8
|
+
*
|
|
9
|
+
* const r = svgRect({ x, y, w, h, fill, defs });
|
|
10
|
+
* // → <rect {...r} />
|
|
11
|
+
*
|
|
12
|
+
* Gradient fills auto-register into the `DefsRegistry` passed via `defs`
|
|
13
|
+
* and emit `fill="url(#<id>)"`. Callers must render `defsRegistry.toElement()`
|
|
14
|
+
* inside a `<defs>` block at the root of the SVG.
|
|
15
|
+
*
|
|
16
|
+
* Color resolution uses `resolveColor(ref, theme)` from Stage 2 so renderers
|
|
17
|
+
* never need to import resolve-color directly.
|
|
18
|
+
*
|
|
19
|
+
* Stroke widths are stored in EMU (English Metric Units) on the model but
|
|
20
|
+
* converted to pt for SVG: 1 pt = 12700 EMU.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { resolveColor } from "../../../io/ooxml/chart/resolve-color.ts";
|
|
24
|
+
import type {
|
|
25
|
+
FillSpec,
|
|
26
|
+
StrokeSpec,
|
|
27
|
+
ColorRef,
|
|
28
|
+
} from "../../../io/ooxml/chart/types.ts";
|
|
29
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// DefsRegistry — collects gradient / pattern defs for the SVG <defs> block
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const EMU_PER_PT = 12700;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Deterministic ID generator using FNV-1a (32-bit). Ensures gradient IDs
|
|
39
|
+
* are stable across renders for Stage 7 pixel-diff (no Math.random / counter
|
|
40
|
+
* state that drifts between runs).
|
|
41
|
+
*/
|
|
42
|
+
function fnv1a32(str: string): string {
|
|
43
|
+
let hash = 0x811c9dc5;
|
|
44
|
+
for (let i = 0; i < str.length; i++) {
|
|
45
|
+
hash ^= str.charCodeAt(i);
|
|
46
|
+
hash = (Math.imul(hash, 0x01000193) >>> 0);
|
|
47
|
+
}
|
|
48
|
+
return hash.toString(16).padStart(8, "0");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GradientStop {
|
|
52
|
+
offset: number; // 0–1
|
|
53
|
+
color: string; // resolved "#RRGGBB"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DefsEntry {
|
|
57
|
+
kind: "linearGradient";
|
|
58
|
+
id: string;
|
|
59
|
+
angle: number;
|
|
60
|
+
stops: GradientStop[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class DefsRegistry {
|
|
64
|
+
private readonly entries = new Map<string, DefsEntry>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Register a linear gradient and return its `id`. If an identical
|
|
68
|
+
* gradient was already registered, the existing `id` is returned
|
|
69
|
+
* without creating a duplicate.
|
|
70
|
+
*/
|
|
71
|
+
registerGradient(stops: GradientStop[], angleDeg: number): string {
|
|
72
|
+
const key = stops.map((s) => `${s.offset}:${s.color}`).join("|") + `@${angleDeg}`;
|
|
73
|
+
const id = `grad-${fnv1a32(key)}`;
|
|
74
|
+
if (!this.entries.has(id)) {
|
|
75
|
+
this.entries.set(id, { kind: "linearGradient", id, angle: angleDeg, stops });
|
|
76
|
+
}
|
|
77
|
+
return id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Serialise all registered defs to an SVG attribute-object map array. */
|
|
81
|
+
toDefsEntries(): ReadonlyArray<DefsEntry> {
|
|
82
|
+
return Array.from(this.entries.values());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True when no defs have been registered. */
|
|
86
|
+
isEmpty(): boolean {
|
|
87
|
+
return this.entries.size === 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Fill / stroke resolution helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a `FillSpec` → SVG fill attribute string + optional gradient
|
|
97
|
+
* registration.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveFill(
|
|
100
|
+
fill: FillSpec | undefined,
|
|
101
|
+
theme: ResolvedTheme | undefined,
|
|
102
|
+
defs: DefsRegistry,
|
|
103
|
+
): string {
|
|
104
|
+
if (!fill || fill.kind === "auto") return "#808080";
|
|
105
|
+
if (fill.kind === "none") return "none";
|
|
106
|
+
if (fill.kind === "solid") {
|
|
107
|
+
return resolveColorSafe(fill.color, theme);
|
|
108
|
+
}
|
|
109
|
+
if (fill.kind === "gradient") {
|
|
110
|
+
const stops: GradientStop[] = fill.stops.map((s) => ({
|
|
111
|
+
offset: Math.max(0, Math.min(1, s.pos / 100)),
|
|
112
|
+
color: resolveColorSafe(s.color, theme),
|
|
113
|
+
}));
|
|
114
|
+
const angle = fill.angle ?? 0;
|
|
115
|
+
return `url(#${defs.registerGradient(stops, angle)})`;
|
|
116
|
+
}
|
|
117
|
+
return "#808080";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Resolve a `StrokeSpec` → SVG stroke attribute string. */
|
|
121
|
+
export function resolveStroke(
|
|
122
|
+
stroke: StrokeSpec | undefined,
|
|
123
|
+
theme: ResolvedTheme | undefined,
|
|
124
|
+
): string {
|
|
125
|
+
if (!stroke || stroke.noFill) return "none";
|
|
126
|
+
if (!stroke.color) return "none";
|
|
127
|
+
return resolveColorSafe(stroke.color, theme);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Resolve stroke width EMU → pt string for SVG `stroke-width`. */
|
|
131
|
+
export function resolveStrokeWidthPt(
|
|
132
|
+
stroke: StrokeSpec | undefined,
|
|
133
|
+
): number {
|
|
134
|
+
if (!stroke || stroke.noFill) return 0;
|
|
135
|
+
if (!stroke.widthEmu) return 0.75; // Word default thin stroke
|
|
136
|
+
return stroke.widthEmu / EMU_PER_PT;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveColorSafe(ref: ColorRef, theme: ResolvedTheme | undefined): string {
|
|
140
|
+
return resolveColor(ref, theme ?? { colors: {} });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// SVG element attribute helpers
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export interface PrimitiveProps {
|
|
148
|
+
theme: ResolvedTheme | undefined;
|
|
149
|
+
defs: DefsRegistry;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface RectAttrs {
|
|
153
|
+
x: number;
|
|
154
|
+
y: number;
|
|
155
|
+
width: number;
|
|
156
|
+
height: number;
|
|
157
|
+
fill: string;
|
|
158
|
+
stroke: string;
|
|
159
|
+
strokeWidth: number;
|
|
160
|
+
"data-role"?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function svgRect(opts: {
|
|
164
|
+
x: number;
|
|
165
|
+
y: number;
|
|
166
|
+
w: number;
|
|
167
|
+
h: number;
|
|
168
|
+
fill?: FillSpec;
|
|
169
|
+
stroke?: StrokeSpec;
|
|
170
|
+
role?: string;
|
|
171
|
+
} & PrimitiveProps): RectAttrs {
|
|
172
|
+
const fillStr = resolveFill(opts.fill, opts.theme, opts.defs);
|
|
173
|
+
const strokeStr = resolveStroke(opts.stroke, opts.theme);
|
|
174
|
+
const sw = resolveStrokeWidthPt(opts.stroke);
|
|
175
|
+
const attrs: RectAttrs = {
|
|
176
|
+
x: opts.x,
|
|
177
|
+
y: opts.y,
|
|
178
|
+
width: opts.w,
|
|
179
|
+
height: opts.h,
|
|
180
|
+
fill: fillStr,
|
|
181
|
+
stroke: strokeStr,
|
|
182
|
+
strokeWidth: sw,
|
|
183
|
+
};
|
|
184
|
+
if (opts.role) attrs["data-role"] = opts.role;
|
|
185
|
+
return attrs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface LineAttrs {
|
|
189
|
+
x1: number;
|
|
190
|
+
y1: number;
|
|
191
|
+
x2: number;
|
|
192
|
+
y2: number;
|
|
193
|
+
stroke: string;
|
|
194
|
+
strokeWidth: number;
|
|
195
|
+
strokeDasharray?: string;
|
|
196
|
+
"data-role"?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function svgLine(opts: {
|
|
200
|
+
x1: number;
|
|
201
|
+
y1: number;
|
|
202
|
+
x2: number;
|
|
203
|
+
y2: number;
|
|
204
|
+
stroke?: StrokeSpec;
|
|
205
|
+
role?: string;
|
|
206
|
+
} & PrimitiveProps): LineAttrs {
|
|
207
|
+
const strokeStr = resolveStroke(opts.stroke, opts.theme);
|
|
208
|
+
const sw = resolveStrokeWidthPt(opts.stroke);
|
|
209
|
+
const dash = opts.stroke?.dash ? dashArray(opts.stroke.dash, sw) : undefined;
|
|
210
|
+
const attrs: LineAttrs = {
|
|
211
|
+
x1: opts.x1,
|
|
212
|
+
y1: opts.y1,
|
|
213
|
+
x2: opts.x2,
|
|
214
|
+
y2: opts.y2,
|
|
215
|
+
stroke: strokeStr,
|
|
216
|
+
strokeWidth: sw,
|
|
217
|
+
};
|
|
218
|
+
if (dash) attrs.strokeDasharray = dash;
|
|
219
|
+
if (opts.role) attrs["data-role"] = opts.role;
|
|
220
|
+
return attrs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface CircleAttrs {
|
|
224
|
+
cx: number;
|
|
225
|
+
cy: number;
|
|
226
|
+
r: number;
|
|
227
|
+
fill: string;
|
|
228
|
+
stroke: string;
|
|
229
|
+
strokeWidth: number;
|
|
230
|
+
"data-role"?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function svgCircle(opts: {
|
|
234
|
+
cx: number;
|
|
235
|
+
cy: number;
|
|
236
|
+
r: number;
|
|
237
|
+
fill?: FillSpec;
|
|
238
|
+
stroke?: StrokeSpec;
|
|
239
|
+
role?: string;
|
|
240
|
+
} & PrimitiveProps): CircleAttrs {
|
|
241
|
+
const attrs: CircleAttrs = {
|
|
242
|
+
cx: opts.cx,
|
|
243
|
+
cy: opts.cy,
|
|
244
|
+
r: opts.r,
|
|
245
|
+
fill: resolveFill(opts.fill, opts.theme, opts.defs),
|
|
246
|
+
stroke: resolveStroke(opts.stroke, opts.theme),
|
|
247
|
+
strokeWidth: resolveStrokeWidthPt(opts.stroke),
|
|
248
|
+
};
|
|
249
|
+
if (opts.role) attrs["data-role"] = opts.role;
|
|
250
|
+
return attrs;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface PathAttrs {
|
|
254
|
+
d: string;
|
|
255
|
+
fill: string;
|
|
256
|
+
stroke: string;
|
|
257
|
+
strokeWidth: number;
|
|
258
|
+
strokeDasharray?: string;
|
|
259
|
+
fillRule?: "nonzero" | "evenodd";
|
|
260
|
+
"data-role"?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function svgPath(opts: {
|
|
264
|
+
d: string;
|
|
265
|
+
fill?: FillSpec;
|
|
266
|
+
stroke?: StrokeSpec;
|
|
267
|
+
fillRule?: "nonzero" | "evenodd";
|
|
268
|
+
role?: string;
|
|
269
|
+
} & PrimitiveProps): PathAttrs {
|
|
270
|
+
const sw = resolveStrokeWidthPt(opts.stroke);
|
|
271
|
+
const dash = opts.stroke?.dash ? dashArray(opts.stroke.dash, sw) : undefined;
|
|
272
|
+
const attrs: PathAttrs = {
|
|
273
|
+
d: opts.d,
|
|
274
|
+
fill: resolveFill(opts.fill, opts.theme, opts.defs),
|
|
275
|
+
stroke: resolveStroke(opts.stroke, opts.theme),
|
|
276
|
+
strokeWidth: sw,
|
|
277
|
+
};
|
|
278
|
+
if (dash) attrs.strokeDasharray = dash;
|
|
279
|
+
if (opts.fillRule) attrs.fillRule = opts.fillRule;
|
|
280
|
+
if (opts.role) attrs["data-role"] = opts.role;
|
|
281
|
+
return attrs;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface TextAttrs {
|
|
285
|
+
x: number;
|
|
286
|
+
y: number;
|
|
287
|
+
fill: string;
|
|
288
|
+
fontFamily: string;
|
|
289
|
+
fontSize: number;
|
|
290
|
+
fontWeight: string;
|
|
291
|
+
fontStyle: string;
|
|
292
|
+
textAnchor: "start" | "middle" | "end";
|
|
293
|
+
dominantBaseline: "auto" | "middle" | "hanging";
|
|
294
|
+
transform?: string;
|
|
295
|
+
"data-role"?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function svgText(opts: {
|
|
299
|
+
x: number;
|
|
300
|
+
y: number;
|
|
301
|
+
fontFamily?: string;
|
|
302
|
+
fontSizePt?: number;
|
|
303
|
+
bold?: boolean;
|
|
304
|
+
italic?: boolean;
|
|
305
|
+
color?: ColorRef;
|
|
306
|
+
anchor?: "start" | "middle" | "end";
|
|
307
|
+
baseline?: "auto" | "middle" | "hanging";
|
|
308
|
+
rotate?: number;
|
|
309
|
+
role?: string;
|
|
310
|
+
} & PrimitiveProps): TextAttrs {
|
|
311
|
+
const PT_TO_PX = 96 / 72;
|
|
312
|
+
const attrs: TextAttrs = {
|
|
313
|
+
x: opts.x,
|
|
314
|
+
y: opts.y,
|
|
315
|
+
fill: opts.color ? resolveColorSafe(opts.color, opts.theme) : "currentColor",
|
|
316
|
+
fontFamily: opts.fontFamily ?? "Calibri, Carlito, 'Segoe UI', Arial, sans-serif",
|
|
317
|
+
fontSize: (opts.fontSizePt ?? 10) * PT_TO_PX,
|
|
318
|
+
fontWeight: opts.bold ? "bold" : "normal",
|
|
319
|
+
fontStyle: opts.italic ? "italic" : "normal",
|
|
320
|
+
textAnchor: opts.anchor ?? "start",
|
|
321
|
+
dominantBaseline: opts.baseline ?? "auto",
|
|
322
|
+
};
|
|
323
|
+
if (opts.rotate !== undefined && opts.rotate !== 0) {
|
|
324
|
+
attrs.transform = `rotate(${opts.rotate},${opts.x},${opts.y})`;
|
|
325
|
+
}
|
|
326
|
+
if (opts.role) attrs["data-role"] = opts.role;
|
|
327
|
+
return attrs;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface PolygonAttrs {
|
|
331
|
+
points: string;
|
|
332
|
+
fill: string;
|
|
333
|
+
stroke: string;
|
|
334
|
+
strokeWidth: number;
|
|
335
|
+
"data-role"?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function svgPolygon(opts: {
|
|
339
|
+
points: Array<[number, number]>;
|
|
340
|
+
fill?: FillSpec;
|
|
341
|
+
stroke?: StrokeSpec;
|
|
342
|
+
role?: string;
|
|
343
|
+
} & PrimitiveProps): PolygonAttrs {
|
|
344
|
+
const pointsStr = opts.points.map(([x, y]) => `${x},${y}`).join(" ");
|
|
345
|
+
const attrs: PolygonAttrs = {
|
|
346
|
+
points: pointsStr,
|
|
347
|
+
fill: resolveFill(opts.fill, opts.theme, opts.defs),
|
|
348
|
+
stroke: resolveStroke(opts.stroke, opts.theme),
|
|
349
|
+
strokeWidth: resolveStrokeWidthPt(opts.stroke),
|
|
350
|
+
};
|
|
351
|
+
if (opts.role) attrs["data-role"] = opts.role;
|
|
352
|
+
return attrs;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Dash-array helper
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
type DashPattern = NonNullable<StrokeSpec["dash"]>;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Convert an OOXML dash name to an SVG `stroke-dasharray` value scaled
|
|
363
|
+
* to the given stroke width. Patterns are approximate matches to Word's
|
|
364
|
+
* rendering.
|
|
365
|
+
*/
|
|
366
|
+
function dashArray(dash: DashPattern, strokeWidth: number): string {
|
|
367
|
+
const sw = Math.max(0.5, strokeWidth);
|
|
368
|
+
const patterns: Record<DashPattern, string> = {
|
|
369
|
+
solid: "",
|
|
370
|
+
dash: `${4 * sw} ${2 * sw}`,
|
|
371
|
+
dashDot: `${4 * sw} ${1.5 * sw} ${sw} ${1.5 * sw}`,
|
|
372
|
+
lgDash: `${8 * sw} ${3 * sw}`,
|
|
373
|
+
lgDashDot: `${8 * sw} ${3 * sw} ${sw} ${3 * sw}`,
|
|
374
|
+
sysDash: `${3 * sw} ${sw}`,
|
|
375
|
+
sysDashDot: `${3 * sw} ${sw} ${sw} ${sw}`,
|
|
376
|
+
};
|
|
377
|
+
return patterns[dash] ?? "";
|
|
378
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unsupported-chart fallback renderer (Stage 4 Slice 4H).
|
|
3
|
+
*
|
|
4
|
+
* Two paths:
|
|
5
|
+
* 1. `previewMediaId` present — render the fallback bitmap Word cached
|
|
6
|
+
* in `mc:Fallback`. The host passes a `mediaUrl` resolver via the
|
|
7
|
+
* surrounding runtime; when none is provided, we fall through to
|
|
8
|
+
* the typed badge.
|
|
9
|
+
* 2. No preview available — render a grey rounded-rect typed badge
|
|
10
|
+
* sized to the reserved chart rectangle. The badge shows the chart
|
|
11
|
+
* type (`"Unsupported chart"` plus the parser's `reason` detail).
|
|
12
|
+
*
|
|
13
|
+
* This is the Stage 4 floor: every `ChartModel` with `kind:
|
|
14
|
+
* "unsupported"` gets a predictable, sized placeholder so chart slots
|
|
15
|
+
* in the document never collapse to zero-height.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React from "react";
|
|
19
|
+
import type { UnsupportedChartModel } from "../../../io/ooxml/chart/types.ts";
|
|
20
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
21
|
+
import type { PlotAreaLayout } from "../layout/plot-area.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public API
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface UnsupportedChartProps {
|
|
28
|
+
model: UnsupportedChartModel;
|
|
29
|
+
layout: PlotAreaLayout;
|
|
30
|
+
theme: ResolvedTheme | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Optional resolver — caller provides URL for `previewMediaId`.
|
|
33
|
+
* When absent, the typed-badge fallback renders.
|
|
34
|
+
*/
|
|
35
|
+
resolveMediaUrl?: (mediaId: string) => string | undefined;
|
|
36
|
+
/** Optional preview-media ID threaded by the surface layer. */
|
|
37
|
+
previewMediaId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function UnsupportedChartImpl({
|
|
41
|
+
model,
|
|
42
|
+
layout,
|
|
43
|
+
resolveMediaUrl,
|
|
44
|
+
previewMediaId,
|
|
45
|
+
theme,
|
|
46
|
+
}: UnsupportedChartProps): React.ReactElement {
|
|
47
|
+
void theme;
|
|
48
|
+
const rect = layout.plotRect;
|
|
49
|
+
const url = previewMediaId && resolveMediaUrl ? resolveMediaUrl(previewMediaId) : undefined;
|
|
50
|
+
if (url) {
|
|
51
|
+
return (
|
|
52
|
+
<image
|
|
53
|
+
x={rect.x}
|
|
54
|
+
y={rect.y}
|
|
55
|
+
width={rect.w}
|
|
56
|
+
height={rect.h}
|
|
57
|
+
href={url}
|
|
58
|
+
preserveAspectRatio="xMidYMid meet"
|
|
59
|
+
data-role="chart-fallback-image"
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return (
|
|
64
|
+
<g data-role="chart-unsupported" data-reason={model.reason}>
|
|
65
|
+
<rect
|
|
66
|
+
x={rect.x}
|
|
67
|
+
y={rect.y}
|
|
68
|
+
width={rect.w}
|
|
69
|
+
height={rect.h}
|
|
70
|
+
rx={4}
|
|
71
|
+
ry={4}
|
|
72
|
+
fill="#F2F2F2"
|
|
73
|
+
stroke="#BFBFBF"
|
|
74
|
+
strokeWidth={1}
|
|
75
|
+
strokeDasharray="4 2"
|
|
76
|
+
/>
|
|
77
|
+
<text
|
|
78
|
+
x={rect.x + rect.w / 2}
|
|
79
|
+
y={rect.y + rect.h / 2 - 4}
|
|
80
|
+
textAnchor="middle"
|
|
81
|
+
fontSize={13}
|
|
82
|
+
fill="#595959"
|
|
83
|
+
data-role="chart-unsupported-title"
|
|
84
|
+
>
|
|
85
|
+
Unsupported chart
|
|
86
|
+
</text>
|
|
87
|
+
<text
|
|
88
|
+
x={rect.x + rect.w / 2}
|
|
89
|
+
y={rect.y + rect.h / 2 + 14}
|
|
90
|
+
textAnchor="middle"
|
|
91
|
+
fontSize={10}
|
|
92
|
+
fill="#808080"
|
|
93
|
+
data-role="chart-unsupported-detail"
|
|
94
|
+
>
|
|
95
|
+
{describeReason(model)}
|
|
96
|
+
</text>
|
|
97
|
+
</g>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const UnsupportedChart = React.memo(
|
|
102
|
+
UnsupportedChartImpl,
|
|
103
|
+
(prev, next) =>
|
|
104
|
+
prev.model.rawXml === next.model.rawXml &&
|
|
105
|
+
prev.layout.plotRect.w === next.layout.plotRect.w &&
|
|
106
|
+
prev.layout.plotRect.h === next.layout.plotRect.h &&
|
|
107
|
+
prev.previewMediaId === next.previewMediaId,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
function describeReason(model: UnsupportedChartModel): string {
|
|
111
|
+
const map: Record<UnsupportedChartModel["reason"], string> = {
|
|
112
|
+
"not-yet-implemented": "Not yet implemented",
|
|
113
|
+
pivot: "Pivot chart",
|
|
114
|
+
stock: "Stock chart",
|
|
115
|
+
surface: "Surface chart",
|
|
116
|
+
treemap: "Treemap",
|
|
117
|
+
sunburst: "Sunburst",
|
|
118
|
+
histogram: "Histogram",
|
|
119
|
+
waterfall: "Waterfall",
|
|
120
|
+
funnel: "Funnel",
|
|
121
|
+
map: "Map",
|
|
122
|
+
"no-plot-area": "No plot area",
|
|
123
|
+
"parse-error": "Chart could not be parsed",
|
|
124
|
+
};
|
|
125
|
+
return map[model.reason] ?? "Unsupported chart type";
|
|
126
|
+
}
|
|
@@ -41,6 +41,17 @@ export function CollabAudienceChip({
|
|
|
41
41
|
"tw-collab-audience-chip",
|
|
42
42
|
audience ? `tw-collab-audience-chip--${audience}` : "tw-collab-audience-chip--empty",
|
|
43
43
|
disabled ? "tw-collab-audience-chip--disabled" : null,
|
|
44
|
+
// Lane 6b §6b.S6 — calm chip; disabled = dim tertiary text. BEM
|
|
45
|
+
// classes above remain as hooks for host CSS to tint by audience.
|
|
46
|
+
"inline-flex items-center px-2 py-0.5",
|
|
47
|
+
"rounded-[var(--radius-pill)]",
|
|
48
|
+
"bg-[var(--color-bg-muted)] text-[var(--color-text-secondary)]",
|
|
49
|
+
"text-[11px] font-medium capitalize",
|
|
50
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
51
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
|
|
52
|
+
disabled
|
|
53
|
+
? "opacity-60 text-[var(--color-text-tertiary)] cursor-not-allowed"
|
|
54
|
+
: "hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] cursor-pointer",
|
|
44
55
|
className ?? null,
|
|
45
56
|
]
|
|
46
57
|
.filter((v): v is string => v !== null)
|
|
@@ -58,6 +58,11 @@ export function CollabNegotiationActionBar({
|
|
|
58
58
|
"tw-collab-negotiation-action-bar",
|
|
59
59
|
`tw-collab-negotiation-action-bar--${state}`,
|
|
60
60
|
blocked ? "tw-collab-negotiation-action-bar--blocked" : null,
|
|
61
|
+
// Lane 6b §6b.S6 — calm toolbar row; blocked state dims.
|
|
62
|
+
"inline-flex items-center gap-1 px-2 py-1",
|
|
63
|
+
"text-[11px] text-[var(--color-text-secondary)]",
|
|
64
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
65
|
+
blocked ? "opacity-60" : null,
|
|
61
66
|
className ?? null,
|
|
62
67
|
]
|
|
63
68
|
.filter((v): v is string => v !== null)
|
|
@@ -85,24 +90,45 @@ export function CollabNegotiationActionBar({
|
|
|
85
90
|
No active comment
|
|
86
91
|
</span>
|
|
87
92
|
) : (
|
|
88
|
-
buttons.map((btn) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
buttons.map((btn) => {
|
|
94
|
+
// Lane 6b §6b.S6 — semantic tone per action class:
|
|
95
|
+
// accept / vote-approve → accent primary (the "go" action)
|
|
96
|
+
// reject / vote-reject → semantic error soft tint
|
|
97
|
+
// lock / reopen / propose / counter → calm secondary
|
|
98
|
+
const toneClass =
|
|
99
|
+
btn.id === "accept" || btn.id === "vote-approve"
|
|
100
|
+
? "bg-[var(--color-accent-primary)] text-[var(--color-text-on-accent)] hover:bg-[var(--color-accent-primary-hover)]"
|
|
101
|
+
: btn.id === "reject" || btn.id === "vote-reject"
|
|
102
|
+
? "bg-[var(--color-semantic-error-soft)] text-[var(--color-semantic-error)] hover:bg-[var(--color-semantic-error-soft)]/80"
|
|
103
|
+
: "bg-[var(--color-bg-muted)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]";
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
key={btn.id}
|
|
107
|
+
type="button"
|
|
108
|
+
className={[
|
|
109
|
+
"tw-collab-negotiation-action-bar__button",
|
|
110
|
+
`tw-collab-negotiation-action-bar__button--${btn.id}`,
|
|
111
|
+
"inline-flex items-center rounded-[var(--radius-sm)]",
|
|
112
|
+
"px-2 py-1 text-[11px] font-medium",
|
|
113
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
114
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
|
|
115
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
116
|
+
toneClass,
|
|
117
|
+
].join(" ")}
|
|
118
|
+
data-testid={`collab-negotiation-action-${btn.id}`}
|
|
119
|
+
data-action-id={btn.id}
|
|
120
|
+
disabled={blocked || btn.disabled}
|
|
121
|
+
aria-disabled={blocked || btn.disabled ? "true" : undefined}
|
|
122
|
+
title={btn.title}
|
|
123
|
+
onClick={() => {
|
|
124
|
+
if (blocked || btn.disabled) return;
|
|
125
|
+
onDispatch(btn.build());
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{btn.label}
|
|
129
|
+
</button>
|
|
130
|
+
);
|
|
131
|
+
})
|
|
106
132
|
)}
|
|
107
133
|
</div>
|
|
108
134
|
);
|