@beyondwork/docx-react-component 1.0.72 → 1.0.73
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 +37 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/viewport.ts +1 -1
- package/src/core/state/editor-state.ts +49 -6
- package/src/io/export/serialize-footnotes.ts +6 -0
- package/src/io/export/serialize-headers-footers.ts +6 -0
- package/src/io/export/serialize-main-document.ts +7 -0
- package/src/io/export/serialize-paragraph-formatting.ts +1 -1
- package/src/io/normalize/normalize-text.ts +38 -2
- package/src/io/ooxml/parse-headers-footers.ts +31 -0
- package/src/io/ooxml/parse-main-document.ts +127 -2
- package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
- package/src/runtime/layout/layout-engine-version.ts +22 -1
- package/src/runtime/layout/paginated-layout-engine.ts +47 -0
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/replacement/apply.ts +1 -0
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/semantic-scope-types.ts +19 -0
- package/src/runtime/surface-projection.ts +55 -0
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
- package/src/ui-tailwind/theme/editor-theme.css +15 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +21 -14
|
@@ -85,6 +85,54 @@ export function resolveMarkerAlignCss(raw: string | undefined): React.CSSPropert
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Precompute per-tab-segment widths from the paragraph's tabStops.
|
|
90
|
+
*
|
|
91
|
+
* Returns a map `segmentId -> widthPt`. The N-th tab segment gets the
|
|
92
|
+
* width `(tabStops[N].pos - (tabStops[N-1]?.pos ?? 0)) / 20` points —
|
|
93
|
+
* the horizontal span from the previous stop (or the paragraph's
|
|
94
|
+
* content-left edge) to the current stop.
|
|
95
|
+
*
|
|
96
|
+
* This is a conservative approximation of Word's real tab algorithm
|
|
97
|
+
* (which advances to the next stop >= cumulative X considering prior
|
|
98
|
+
* content width). Without run-width measurement, the approximation
|
|
99
|
+
* holds best when the text in each zone is short relative to the zone
|
|
100
|
+
* width — which is exactly the three-zone-header / TOC case L11 needs
|
|
101
|
+
* to render correctly.
|
|
102
|
+
*
|
|
103
|
+
* Direct `block.tabStops` wins over `block.resolvedParagraphFormatting.tabStops`
|
|
104
|
+
* (consistent with other resolved fields).
|
|
105
|
+
*/
|
|
106
|
+
export function computeTabWidthsInPoints(
|
|
107
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
108
|
+
): Map<string, number> {
|
|
109
|
+
const widths = new Map<string, number>();
|
|
110
|
+
// Direct `block.tabStops` uses the public `{pos, val?, leader?}` shape;
|
|
111
|
+
// `block.resolvedParagraphFormatting.tabStops` uses the canonical
|
|
112
|
+
// `{position, align, leader?}` shape. Normalize to a single accessor.
|
|
113
|
+
const readPos = (stop: { pos?: number; position?: number }): number =>
|
|
114
|
+
stop.pos ?? stop.position ?? 0;
|
|
115
|
+
|
|
116
|
+
const rawStops: Array<{ pos?: number; position?: number }> | undefined =
|
|
117
|
+
block.tabStops ?? block.resolvedParagraphFormatting?.tabStops;
|
|
118
|
+
if (!rawStops || rawStops.length === 0) return widths;
|
|
119
|
+
|
|
120
|
+
let tabIndex = 0;
|
|
121
|
+
for (const seg of block.segments) {
|
|
122
|
+
if (seg.kind !== "tab") continue;
|
|
123
|
+
const stop = rawStops[tabIndex];
|
|
124
|
+
if (stop) {
|
|
125
|
+
const prevPos = tabIndex > 0 ? readPos(rawStops[tabIndex - 1]) : 0;
|
|
126
|
+
const widthTwips = readPos(stop) - prevPos;
|
|
127
|
+
if (widthTwips > 0) {
|
|
128
|
+
widths.set(seg.segmentId, widthTwips / 20);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
tabIndex += 1;
|
|
132
|
+
}
|
|
133
|
+
return widths;
|
|
134
|
+
}
|
|
135
|
+
|
|
88
136
|
/** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
|
|
89
137
|
export function buildParagraphStyle(
|
|
90
138
|
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
@@ -203,6 +251,35 @@ export function buildParagraphStyle(
|
|
|
203
251
|
style.marginTop = "16px";
|
|
204
252
|
}
|
|
205
253
|
|
|
254
|
+
// `<w:framePr>` out-of-flow frame (ECMA-376 §17.3.1.11). L04 returns 0
|
|
255
|
+
// from measureBlockHeight for these paragraphs (a298391e) so the inline
|
|
256
|
+
// flow doesn't double-count; L11 renders them absolutely positioned.
|
|
257
|
+
// Drop-cap (dropCap="drop"|"margin") is in-flow — only the initial
|
|
258
|
+
// letter is framed — so skip the absolute switch there.
|
|
259
|
+
const framePr = block.frameProperties;
|
|
260
|
+
if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
|
|
261
|
+
style.position = "absolute";
|
|
262
|
+
if (typeof framePr.xTwips === "number") {
|
|
263
|
+
style.left = `${framePr.xTwips / 20}pt`;
|
|
264
|
+
}
|
|
265
|
+
if (typeof framePr.yTwips === "number") {
|
|
266
|
+
style.top = `${framePr.yTwips / 20}pt`;
|
|
267
|
+
}
|
|
268
|
+
if (typeof framePr.widthTwips === "number") {
|
|
269
|
+
style.width = `${framePr.widthTwips / 20}pt`;
|
|
270
|
+
}
|
|
271
|
+
if (typeof framePr.heightTwips === "number") {
|
|
272
|
+
if (framePr.hRule === "exact") {
|
|
273
|
+
style.height = `${framePr.heightTwips / 20}pt`;
|
|
274
|
+
} else if (framePr.hRule === "atLeast") {
|
|
275
|
+
style.minHeight = `${framePr.heightTwips / 20}pt`;
|
|
276
|
+
}
|
|
277
|
+
// hRule === "auto" (or missing) leaves the frame's vertical size
|
|
278
|
+
// content-driven. The height field is ignored — OOXML treats the
|
|
279
|
+
// value as a hint that layout engines may override.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
206
283
|
return style;
|
|
207
284
|
}
|
|
208
285
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildMarkerStyle,
|
|
9
9
|
buildParagraphStyle,
|
|
10
10
|
buildSegmentStyle,
|
|
11
|
+
computeTabWidthsInPoints,
|
|
11
12
|
hasStyleEntries,
|
|
12
13
|
headingClassList,
|
|
13
14
|
resolveHeadingLevel,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
|
|
20
21
|
/** Render a single inline segment. */
|
|
21
|
-
function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
|
|
22
|
+
function renderSegment(seg: SurfaceInlineSegment, tabWidthsPt: Map<string, number>): React.ReactNode {
|
|
22
23
|
switch (seg.kind) {
|
|
23
24
|
case "text": {
|
|
24
25
|
const style = buildSegmentStyle(seg.marks, seg.markAttrs);
|
|
@@ -32,16 +33,22 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
|
|
|
32
33
|
</span>
|
|
33
34
|
);
|
|
34
35
|
}
|
|
35
|
-
case "tab":
|
|
36
|
+
case "tab": {
|
|
37
|
+
const widthPt = tabWidthsPt.get(seg.segmentId);
|
|
38
|
+
const tabStyle: React.CSSProperties =
|
|
39
|
+
typeof widthPt === "number"
|
|
40
|
+
? { display: "inline-block", width: `${widthPt}pt`, minWidth: "8px" }
|
|
41
|
+
: { display: "inline-block", width: "32px", minWidth: "8px" };
|
|
36
42
|
return (
|
|
37
43
|
<span
|
|
38
44
|
key={seg.segmentId}
|
|
39
45
|
data-node-type="tab"
|
|
40
|
-
style={
|
|
46
|
+
style={tabStyle}
|
|
41
47
|
>
|
|
42
48
|
{"\u00A0"}
|
|
43
49
|
</span>
|
|
44
50
|
);
|
|
51
|
+
}
|
|
45
52
|
case "hard_break":
|
|
46
53
|
return <br key={seg.segmentId} />;
|
|
47
54
|
case "image":
|
|
@@ -114,6 +121,7 @@ function ParagraphBlock({
|
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
const pStyle = buildParagraphStyle(block);
|
|
124
|
+
const tabWidthsPt = computeTabWidthsInPoints(block);
|
|
117
125
|
const attrs: React.HTMLAttributes<HTMLParagraphElement> & {
|
|
118
126
|
"data-heading-level"?: string;
|
|
119
127
|
"data-numbered"?: string;
|
|
@@ -177,7 +185,7 @@ function ParagraphBlock({
|
|
|
177
185
|
<p {...attrs}>
|
|
178
186
|
{prefixSpan}
|
|
179
187
|
<span className="pm-paragraph-content">
|
|
180
|
-
{block.segments.map((seg) => renderSegment(seg))}
|
|
188
|
+
{block.segments.map((seg) => renderSegment(seg, tabWidthsPt))}
|
|
181
189
|
</span>
|
|
182
190
|
</p>
|
|
183
191
|
);
|
|
@@ -101,40 +101,57 @@ export function collectFloatingImageOverlayItems(input: {
|
|
|
101
101
|
);
|
|
102
102
|
const items: FloatingImageOverlayItem[] = [];
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
const collectFromStory = (
|
|
105
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
106
|
+
storyTarget: EditorStoryTarget,
|
|
107
|
+
) => {
|
|
108
|
+
walkSurfaceBlocks(blocks, (segment) => {
|
|
109
|
+
if (
|
|
110
|
+
segment.kind !== "image" ||
|
|
111
|
+
!shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)
|
|
112
|
+
) {
|
|
113
|
+
return;
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
|
|
116
|
+
const pages = resolveTargetPages(facet, segment.from, storyTarget);
|
|
117
|
+
for (const page of pages) {
|
|
118
|
+
const pageRect = rectByPageIndex.get(page.pageIndex);
|
|
119
|
+
if (!pageRect) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const localRect = resolveFloatingImageLocalRect(page, storyTarget, segment, pxPerTwip);
|
|
123
|
+
if (!localRect) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const preview = input.mediaPreviews?.[segment.mediaId];
|
|
127
|
+
items.push({
|
|
128
|
+
key: `${segment.segmentId}:${page.pageId}`,
|
|
129
|
+
mediaId: segment.mediaId,
|
|
130
|
+
from: segment.from,
|
|
131
|
+
to: segment.to,
|
|
132
|
+
pageId: page.pageId,
|
|
133
|
+
pageIndex: page.pageIndex,
|
|
134
|
+
topPx: pageRect.topPx + localRect.topPx,
|
|
135
|
+
leftPx: localRect.leftPx,
|
|
136
|
+
widthPx: localRect.widthPx,
|
|
137
|
+
heightPx: localRect.heightPx,
|
|
138
|
+
behindDoc: Boolean(segment.anchor?.behindDoc),
|
|
139
|
+
src: preview?.src ?? null,
|
|
140
|
+
altText: segment.altText ?? null,
|
|
141
|
+
detail: segment.detail ?? null,
|
|
142
|
+
});
|
|
118
143
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
heightPx: localRect.heightPx,
|
|
131
|
-
behindDoc: Boolean(segment.anchor?.behindDoc),
|
|
132
|
-
src: preview?.src ?? null,
|
|
133
|
-
altText: segment.altText ?? null,
|
|
134
|
-
detail: segment.detail ?? null,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
});
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// coord-01 §9 / §5.1 — CCEP logos live in header stories; collect from
|
|
148
|
+
// the main story for the active-story case AND from every secondary
|
|
149
|
+
// story so header/footer images reach the overlay regardless of which
|
|
150
|
+
// story is active in the editor.
|
|
151
|
+
collectFromStory(surface.blocks, activeStory);
|
|
152
|
+
for (const secondary of surface.secondaryStories ?? []) {
|
|
153
|
+
collectFromStory(secondary.blocks, secondary.target);
|
|
154
|
+
}
|
|
138
155
|
|
|
139
156
|
return items;
|
|
140
157
|
}
|
|
@@ -80,7 +80,11 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
|
80
80
|
style={{ width: "100%", height: "100%" }}
|
|
81
81
|
/>
|
|
82
82
|
) : (
|
|
83
|
-
<TwRegionBlockRenderer
|
|
83
|
+
<TwRegionBlockRenderer
|
|
84
|
+
blocks={blocks}
|
|
85
|
+
mediaPreviews={mediaPreviews}
|
|
86
|
+
fallbackDisplay="hidden"
|
|
87
|
+
/>
|
|
84
88
|
)}
|
|
85
89
|
</div>
|
|
86
90
|
);
|
|
@@ -84,7 +84,11 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
|
84
84
|
style={{ width: "100%", height: "100%" }}
|
|
85
85
|
/>
|
|
86
86
|
) : (
|
|
87
|
-
<TwRegionBlockRenderer
|
|
87
|
+
<TwRegionBlockRenderer
|
|
88
|
+
blocks={blocks}
|
|
89
|
+
mediaPreviews={mediaPreviews}
|
|
90
|
+
fallbackDisplay="hidden"
|
|
91
|
+
/>
|
|
88
92
|
)}
|
|
89
93
|
</div>
|
|
90
94
|
);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildMarkerStyle,
|
|
10
10
|
buildParagraphStyle,
|
|
11
11
|
buildSegmentStyle,
|
|
12
|
+
computeTabWidthsInPoints,
|
|
12
13
|
hasStyleEntries,
|
|
13
14
|
headingClassList,
|
|
14
15
|
resolveHeadingLevel,
|
|
@@ -40,9 +41,26 @@ const EMU_PER_PX = 9525;
|
|
|
40
41
|
// Inline segment renderer — mirrors `tw-page-block-view`'s `renderSegment`.
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Fallback visual for image segments that cannot resolve real bytes
|
|
46
|
+
* (no preview in `mediaPreviews`, or `seg.state === "missing"`).
|
|
47
|
+
*
|
|
48
|
+
* - `"chip"` (default) — 48×32 gray placeholder. Valuable dev-mode signal;
|
|
49
|
+
* correct for the body renderer.
|
|
50
|
+
* - `"hidden"` — zero-size, `aria-hidden` span. Correct inside header/
|
|
51
|
+
* footer bands, where the chip geometry visibly disrupts layout.
|
|
52
|
+
*
|
|
53
|
+
* The DOM node (with `data-node-type="image"` + `data-state`) is still
|
|
54
|
+
* emitted under `"hidden"` so diagnostics + tests can still see that an
|
|
55
|
+
* image segment was present.
|
|
56
|
+
*/
|
|
57
|
+
export type RegionImageFallbackDisplay = "chip" | "hidden";
|
|
58
|
+
|
|
43
59
|
function renderSegment(
|
|
44
60
|
seg: SurfaceInlineSegment,
|
|
45
61
|
mediaPreviews: Record<string, MediaPreviewDescriptor>,
|
|
62
|
+
fallbackDisplay: RegionImageFallbackDisplay,
|
|
63
|
+
tabWidthsPt: Map<string, number>,
|
|
46
64
|
): React.ReactNode {
|
|
47
65
|
switch (seg.kind) {
|
|
48
66
|
case "text": {
|
|
@@ -57,19 +75,32 @@ function renderSegment(
|
|
|
57
75
|
</span>
|
|
58
76
|
);
|
|
59
77
|
}
|
|
60
|
-
case "tab":
|
|
78
|
+
case "tab": {
|
|
79
|
+
const widthPt = tabWidthsPt.get(seg.segmentId);
|
|
80
|
+
const tabStyle: React.CSSProperties =
|
|
81
|
+
typeof widthPt === "number"
|
|
82
|
+
? { display: "inline-block", width: `${widthPt}pt`, minWidth: "8px" }
|
|
83
|
+
: { display: "inline-block", width: "32px", minWidth: "8px" };
|
|
61
84
|
return (
|
|
62
85
|
<span
|
|
63
86
|
key={seg.segmentId}
|
|
64
87
|
data-node-type="tab"
|
|
65
|
-
style={
|
|
88
|
+
style={tabStyle}
|
|
66
89
|
>
|
|
67
90
|
{"\u00A0"}
|
|
68
91
|
</span>
|
|
69
92
|
);
|
|
93
|
+
}
|
|
70
94
|
case "hard_break":
|
|
71
95
|
return <br key={seg.segmentId} />;
|
|
72
96
|
case "image": {
|
|
97
|
+
// §5.1 gap 3 — floating-anchor images are owned by the absolute
|
|
98
|
+
// floating-image overlay (`TwFloatingImageLayer`). Emitting them
|
|
99
|
+
// inline here would double-paint the CCEP header logo on every
|
|
100
|
+
// page. Skip entirely so only the overlay renders them.
|
|
101
|
+
if (seg.anchor?.display === "floating") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
73
104
|
// Mirror body-renderer behavior (`pm-state-from-snapshot.ts` :500+):
|
|
74
105
|
// look up the preview via `seg.mediaId` and render a real <img> when
|
|
75
106
|
// available. Without a preview (or `state === "missing"`), fall back
|
|
@@ -103,6 +134,18 @@ function renderSegment(
|
|
|
103
134
|
/>
|
|
104
135
|
);
|
|
105
136
|
}
|
|
137
|
+
if (fallbackDisplay === "hidden") {
|
|
138
|
+
return (
|
|
139
|
+
<span
|
|
140
|
+
key={seg.segmentId}
|
|
141
|
+
data-node-type="image"
|
|
142
|
+
data-media-id={seg.mediaId}
|
|
143
|
+
data-state={seg.state}
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
style={{ display: "inline-block", width: 0, height: 0 }}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
106
149
|
return (
|
|
107
150
|
<span
|
|
108
151
|
key={seg.segmentId}
|
|
@@ -164,9 +207,11 @@ function renderSegment(
|
|
|
164
207
|
function RegionParagraph({
|
|
165
208
|
block,
|
|
166
209
|
mediaPreviews,
|
|
210
|
+
fallbackDisplay,
|
|
167
211
|
}: {
|
|
168
212
|
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
|
|
169
213
|
mediaPreviews: Record<string, MediaPreviewDescriptor>;
|
|
214
|
+
fallbackDisplay: RegionImageFallbackDisplay;
|
|
170
215
|
}): React.ReactElement {
|
|
171
216
|
const headingLevel = resolveHeadingLevel(block.styleId, block.outlineLevel);
|
|
172
217
|
const classes: string[] = ["leading-relaxed"];
|
|
@@ -175,6 +220,7 @@ function RegionParagraph({
|
|
|
175
220
|
}
|
|
176
221
|
|
|
177
222
|
const pStyle = buildParagraphStyle(block);
|
|
223
|
+
const tabWidthsPt = computeTabWidthsInPoints(block);
|
|
178
224
|
|
|
179
225
|
// Numbering prefix span — matches tw-page-block-view so region content that
|
|
180
226
|
// happens to carry numbering (e.g. footnote bodies authored as lists) shows
|
|
@@ -239,7 +285,7 @@ function RegionParagraph({
|
|
|
239
285
|
<div {...attrs}>
|
|
240
286
|
{prefixSpan}
|
|
241
287
|
<span className="pm-paragraph-content">
|
|
242
|
-
{block.segments.map((seg) => renderSegment(seg, mediaPreviews))}
|
|
288
|
+
{block.segments.map((seg) => renderSegment(seg, mediaPreviews, fallbackDisplay, tabWidthsPt))}
|
|
243
289
|
</span>
|
|
244
290
|
</div>
|
|
245
291
|
);
|
|
@@ -248,9 +294,11 @@ function RegionParagraph({
|
|
|
248
294
|
function RegionTable({
|
|
249
295
|
block,
|
|
250
296
|
mediaPreviews,
|
|
297
|
+
fallbackDisplay,
|
|
251
298
|
}: {
|
|
252
299
|
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
|
|
253
300
|
mediaPreviews: Record<string, MediaPreviewDescriptor>;
|
|
301
|
+
fallbackDisplay: RegionImageFallbackDisplay;
|
|
254
302
|
}): React.ReactElement {
|
|
255
303
|
const tableStyle: React.CSSProperties = {
|
|
256
304
|
borderCollapse: "collapse",
|
|
@@ -323,6 +371,7 @@ function RegionTable({
|
|
|
323
371
|
key={childBlock.blockId}
|
|
324
372
|
block={childBlock}
|
|
325
373
|
mediaPreviews={mediaPreviews}
|
|
374
|
+
fallbackDisplay={fallbackDisplay}
|
|
326
375
|
/>
|
|
327
376
|
))}
|
|
328
377
|
</td>
|
|
@@ -362,15 +411,17 @@ function RegionOpaque({
|
|
|
362
411
|
function RegionBlockItem({
|
|
363
412
|
block,
|
|
364
413
|
mediaPreviews,
|
|
414
|
+
fallbackDisplay,
|
|
365
415
|
}: {
|
|
366
416
|
block: SurfaceBlockSnapshot;
|
|
367
417
|
mediaPreviews: Record<string, MediaPreviewDescriptor>;
|
|
418
|
+
fallbackDisplay: RegionImageFallbackDisplay;
|
|
368
419
|
}): React.ReactElement | null {
|
|
369
420
|
switch (block.kind) {
|
|
370
421
|
case "paragraph":
|
|
371
|
-
return <RegionParagraph block={block} mediaPreviews={mediaPreviews} />;
|
|
422
|
+
return <RegionParagraph block={block} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />;
|
|
372
423
|
case "table":
|
|
373
|
-
return <RegionTable block={block} mediaPreviews={mediaPreviews} />;
|
|
424
|
+
return <RegionTable block={block} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />;
|
|
374
425
|
case "sdt_block":
|
|
375
426
|
return (
|
|
376
427
|
<section
|
|
@@ -380,7 +431,7 @@ function RegionBlockItem({
|
|
|
380
431
|
style={{ margin: "8px 0" }}
|
|
381
432
|
>
|
|
382
433
|
{block.children.map((child) => (
|
|
383
|
-
<RegionBlockItem key={child.blockId} block={child} mediaPreviews={mediaPreviews} />
|
|
434
|
+
<RegionBlockItem key={child.blockId} block={child} mediaPreviews={mediaPreviews} fallbackDisplay={fallbackDisplay} />
|
|
384
435
|
))}
|
|
385
436
|
</section>
|
|
386
437
|
);
|
|
@@ -409,6 +460,13 @@ export interface TwRegionBlockRendererProps {
|
|
|
409
460
|
* real image bytes.
|
|
410
461
|
*/
|
|
411
462
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
463
|
+
/**
|
|
464
|
+
* Behavior for image segments that can't resolve real bytes.
|
|
465
|
+
* Defaults to `"chip"` for back-compat with body-renderer parity;
|
|
466
|
+
* pass `"hidden"` inside header/footer bands where the 48×32 chip
|
|
467
|
+
* disrupts band geometry (§5.5 tuning-phase handover).
|
|
468
|
+
*/
|
|
469
|
+
fallbackDisplay?: RegionImageFallbackDisplay;
|
|
412
470
|
}
|
|
413
471
|
|
|
414
472
|
const EMPTY_MEDIA_PREVIEWS: Record<string, MediaPreviewDescriptor> = {};
|
|
@@ -427,6 +485,7 @@ export function TwRegionBlockRenderer({
|
|
|
427
485
|
blocks,
|
|
428
486
|
className,
|
|
429
487
|
mediaPreviews,
|
|
488
|
+
fallbackDisplay = "chip",
|
|
430
489
|
}: TwRegionBlockRendererProps): React.ReactElement {
|
|
431
490
|
const rootClasses = ["ProseMirror"];
|
|
432
491
|
if (className) rootClasses.push(className);
|
|
@@ -438,7 +497,12 @@ export function TwRegionBlockRenderer({
|
|
|
438
497
|
data-region-block-renderer=""
|
|
439
498
|
>
|
|
440
499
|
{blocks.map((block) => (
|
|
441
|
-
<RegionBlockItem
|
|
500
|
+
<RegionBlockItem
|
|
501
|
+
key={block.blockId}
|
|
502
|
+
block={block}
|
|
503
|
+
mediaPreviews={previews}
|
|
504
|
+
fallbackDisplay={fallbackDisplay}
|
|
505
|
+
/>
|
|
442
506
|
))}
|
|
443
507
|
</div>
|
|
444
508
|
);
|
|
@@ -1058,30 +1058,29 @@
|
|
|
1058
1058
|
* `data-story-active="header|footer"` and the body PM surface dims to
|
|
1059
1059
|
* 0.65 so the active band reads as the focal surface.
|
|
1060
1060
|
*/
|
|
1061
|
+
/*
|
|
1062
|
+
* Designsystem §6.20 — inactive = low-contrast tint (color.bg.muted),
|
|
1063
|
+
* active = tint color.accent.soft + border color.border.accent.
|
|
1064
|
+
* The 3px top accent stripe was the prior signal; §6.20 rejects it in
|
|
1065
|
+
* favor of a border/tint-led active state that reads as "a focal frame"
|
|
1066
|
+
* rather than "a colored line draped across the top".
|
|
1067
|
+
*/
|
|
1061
1068
|
.wre-page-band {
|
|
1062
|
-
|
|
1063
|
-
|
|
1069
|
+
background-color: var(--color-bg-muted);
|
|
1070
|
+
border: 1px solid transparent;
|
|
1071
|
+
transition:
|
|
1072
|
+
background-color var(--motion-fast, 120ms) ease-out,
|
|
1073
|
+
border-color var(--motion-fast, 120ms) ease-out;
|
|
1064
1074
|
pointer-events: auto;
|
|
1065
1075
|
}
|
|
1066
1076
|
|
|
1067
1077
|
.wre-page-band:hover {
|
|
1068
|
-
|
|
1078
|
+
background-color: color-mix(in srgb, var(--color-bg-muted) 80%, var(--color-surface));
|
|
1069
1079
|
}
|
|
1070
1080
|
|
|
1071
1081
|
.wre-page-band[data-active="true"] {
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
.wre-page-band[data-active="true"]::before {
|
|
1076
|
-
content: "";
|
|
1077
|
-
position: absolute;
|
|
1078
|
-
top: 0;
|
|
1079
|
-
left: 0;
|
|
1080
|
-
right: 0;
|
|
1081
|
-
height: 3px;
|
|
1082
|
-
background: var(--color-accent);
|
|
1083
|
-
border-radius: 0 0 var(--radius-pill) var(--radius-pill);
|
|
1084
|
-
pointer-events: none;
|
|
1082
|
+
background-color: var(--color-accent-soft);
|
|
1083
|
+
border: 1px solid var(--color-border-accent);
|
|
1085
1084
|
}
|
|
1086
1085
|
|
|
1087
1086
|
.wre-page-band__label {
|
|
@@ -351,21 +351,28 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
351
351
|
const defaultShellActiveMode: ShellHeaderMode = editorRoleToShellMode(
|
|
352
352
|
viewState.editorRole,
|
|
353
353
|
);
|
|
354
|
+
// coord-11 §21 — host-supplied shellHeader wins; otherwise the default
|
|
355
|
+
// TwShellHeader mounts only when `chromeVisibility.shellHeader === true`
|
|
356
|
+
// (default for every preset except `selection`). The `selection` preset
|
|
357
|
+
// is intended for minimal embeds (incl. visual-fidelity `chrome=none`)
|
|
358
|
+
// and must not paint a workspace mode-tab header.
|
|
354
359
|
const renderedShell =
|
|
355
|
-
props.shellHeader !== undefined
|
|
356
|
-
props.shellHeader
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
360
|
+
props.shellHeader !== undefined
|
|
361
|
+
? props.shellHeader
|
|
362
|
+
: chromeVisibility.shellHeader
|
|
363
|
+
? (
|
|
364
|
+
<TwShellHeader
|
|
365
|
+
modes={defaultShellModes}
|
|
366
|
+
activeMode={defaultShellActiveMode}
|
|
367
|
+
onModeChange={(mode) => {
|
|
368
|
+
const nextRole = shellModeToEditorRole(mode);
|
|
369
|
+
if (nextRole !== null && nextRole !== viewState.editorRole) {
|
|
370
|
+
props.onEditorRoleChange?.(nextRole);
|
|
371
|
+
}
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
)
|
|
375
|
+
: null;
|
|
369
376
|
|
|
370
377
|
// Audit §2.5 — context band mounts as a composition-level sibling of
|
|
371
378
|
// the toolbar so the workspace row becomes
|