@beyondwork/docx-react-component 1.0.77 → 1.0.79
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 +51 -1
- package/src/api/v3/runtime/workflow.ts +21 -2
- package/src/core/commands/add-scope.ts +163 -36
- package/src/io/ooxml/parse-shapes.ts +32 -6
- package/src/model/canonical-document.ts +45 -8
- package/src/runtime/document-runtime.ts +77 -2
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +27 -1
- package/src/runtime/layout/public-facet.ts +35 -0
- package/src/runtime/workflow/coordinator.ts +63 -10
- package/src/runtime/workflow/scope-writer.ts +90 -2
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +12 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +20 -1
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +17 -2
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +13 -13
- package/src/ui-tailwind/page-stack/tw-active-band-ribbon.tsx +229 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +15 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +18 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +20 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +10 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +56 -6
|
@@ -569,6 +569,19 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
569
569
|
*/
|
|
570
570
|
getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
|
|
571
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Viewport-cull height resolver — returns total rendered height (twips)
|
|
574
|
+
* for every block in the current page graph, computed as the sum of each
|
|
575
|
+
* fragment's `heightTwips` grouped by `blockId`. Consumers (the render
|
|
576
|
+
* surface builder in particular) use this to size `placeholder-culled`
|
|
577
|
+
* opaque stubs so the scrollable canvas does not change height when a
|
|
578
|
+
* block realizes during scroll.
|
|
579
|
+
*
|
|
580
|
+
* Returns an empty map on the inert facet or before the first successful
|
|
581
|
+
* pagination pass. Cached per `graph.revision`.
|
|
582
|
+
*/
|
|
583
|
+
getBlockHeightsTwips(): ReadonlyMap<string, number>;
|
|
584
|
+
|
|
572
585
|
// Fields ---------------------------------------------------------------
|
|
573
586
|
getDirtyFieldFamilies(): readonly string[];
|
|
574
587
|
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
@@ -707,6 +720,13 @@ export function createLayoutFacet(
|
|
|
707
720
|
revision: number;
|
|
708
721
|
blocks: readonly PublicRegionBlock[] | null;
|
|
709
722
|
} = { revision: -1, blocks: null };
|
|
723
|
+
// Viewport-cull flicker fix — per-revision cache for getBlockHeightsTwips.
|
|
724
|
+
// One entry per blockId; value is the sum of that block's fragments'
|
|
725
|
+
// `heightTwips`. Busts on `graph.revision` change.
|
|
726
|
+
let blockHeightsCache: {
|
|
727
|
+
revision: number;
|
|
728
|
+
map: ReadonlyMap<string, number> | null;
|
|
729
|
+
} = { revision: -1, map: null };
|
|
710
730
|
|
|
711
731
|
function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
|
|
712
732
|
if (input.canonicalDocument) {
|
|
@@ -1234,6 +1254,21 @@ export function createLayoutFacet(
|
|
|
1234
1254
|
return null;
|
|
1235
1255
|
},
|
|
1236
1256
|
|
|
1257
|
+
getBlockHeightsTwips() {
|
|
1258
|
+
const graph = currentGraph();
|
|
1259
|
+
if (blockHeightsCache.revision === graph.revision && blockHeightsCache.map) {
|
|
1260
|
+
return blockHeightsCache.map;
|
|
1261
|
+
}
|
|
1262
|
+
const map = new Map<string, number>();
|
|
1263
|
+
for (const frag of graph.fragments) {
|
|
1264
|
+
const prev = map.get(frag.blockId) ?? 0;
|
|
1265
|
+
map.set(frag.blockId, prev + frag.heightTwips);
|
|
1266
|
+
}
|
|
1267
|
+
const frozen: ReadonlyMap<string, number> = map;
|
|
1268
|
+
blockHeightsCache = { revision: graph.revision, map: frozen };
|
|
1269
|
+
return frozen;
|
|
1270
|
+
},
|
|
1271
|
+
|
|
1237
1272
|
getDirtyFieldFamilies() {
|
|
1238
1273
|
return engine.getDirtyFieldFamilies();
|
|
1239
1274
|
},
|
|
@@ -791,19 +791,72 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
791
791
|
return { scopeId, anchor: params.anchor };
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
794
|
+
const plantResult = insertScopeMarkers(deps.getDocument(), {
|
|
795
|
+
scopeId,
|
|
796
|
+
from: anchor.from,
|
|
797
|
+
to: anchor.to,
|
|
798
|
+
});
|
|
798
799
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
800
|
+
// Plant failed — pre-2026-04-24 this returned silently with a dead
|
|
801
|
+
// scopeId; callers later saw `scope-not-resolvable`. Now surface
|
|
802
|
+
// the typed failure on the AddScopeResult so consumers can detect
|
|
803
|
+
// the plant-failed path without a round-trip through
|
|
804
|
+
// resolveReference.
|
|
805
|
+
if (plantResult.status !== "planted") {
|
|
806
|
+
const callerAssoc: { readonly start: -1 | 1; readonly end: -1 | 1 } =
|
|
807
|
+
params.anchor.kind === "range"
|
|
808
|
+
? params.anchor.assoc
|
|
809
|
+
: { start: -1, end: 1 };
|
|
810
|
+
// Return the caller's input range as an informational range
|
|
811
|
+
// anchor. The authoritative failure signal is `scopeId: ""` +
|
|
812
|
+
// `plantStatus.planted === false`. The detached-anchor shape has
|
|
813
|
+
// a fixed reason enum (`deleted|invalidatedByStructureChange|
|
|
814
|
+
// importAmbiguity`) that doesn't cover plant-refused, so we keep
|
|
815
|
+
// the range kind and let callers discriminate via plantStatus.
|
|
816
|
+
return {
|
|
817
|
+
scopeId: "",
|
|
818
|
+
anchor: {
|
|
819
|
+
kind: "range",
|
|
820
|
+
from: anchor.from,
|
|
821
|
+
to: anchor.to,
|
|
822
|
+
assoc: callerAssoc,
|
|
823
|
+
},
|
|
824
|
+
plantStatus: {
|
|
825
|
+
planted: false,
|
|
826
|
+
reason: plantResult.status,
|
|
827
|
+
...(plantResult.status === "cross-paragraph-range"
|
|
828
|
+
? {
|
|
829
|
+
fromBlockIndex: plantResult.fromBlockIndex,
|
|
830
|
+
toBlockIndex: plantResult.toBlockIndex,
|
|
831
|
+
}
|
|
832
|
+
: {}),
|
|
833
|
+
...(plantResult.status === "non-paragraph-target"
|
|
834
|
+
? {
|
|
835
|
+
blockIndex: plantResult.blockIndex,
|
|
836
|
+
blockKind: plantResult.blockKind,
|
|
837
|
+
}
|
|
838
|
+
: {}),
|
|
839
|
+
...(plantResult.status === "range-out-of-bounds"
|
|
840
|
+
? { storyLength: plantResult.storyLength }
|
|
841
|
+
: {}),
|
|
842
|
+
requestedFrom: plantResult.from,
|
|
843
|
+
requestedTo: plantResult.to,
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
// Intentionally NOT dispatching document.replace or workflow.set-overlay —
|
|
847
|
+
// a failed plant must not leave a half-registered scope. Prevents the
|
|
848
|
+
// pre-fix "overlay carries scopeId but canonical tree has no markers"
|
|
849
|
+
// state that produced `scope-not-resolvable` on every follow-up call.
|
|
805
850
|
}
|
|
806
851
|
|
|
852
|
+
const nextDocument = plantResult.document;
|
|
853
|
+
|
|
854
|
+
deps.dispatch({
|
|
855
|
+
type: "document.replace",
|
|
856
|
+
document: nextDocument,
|
|
857
|
+
origin: { source: "api", at: clock() },
|
|
858
|
+
});
|
|
859
|
+
|
|
807
860
|
// Coord-06 §13d — preserve the caller's assoc on the public anchor.
|
|
808
861
|
// resolveScope re-derives the range from the inserted markers but emits
|
|
809
862
|
// a hardcoded { start: -1, end: 1 }; without this override the caller's
|
|
@@ -276,10 +276,19 @@ export type CreateScopeFromAnchorResult =
|
|
|
276
276
|
readonly reason:
|
|
277
277
|
| "from-negative"
|
|
278
278
|
| "to-less-than-from"
|
|
279
|
-
| "range-exceeds-story-length"
|
|
279
|
+
| "range-exceeds-story-length"
|
|
280
|
+
| "cross-paragraph-range"
|
|
281
|
+
| "non-paragraph-target"
|
|
282
|
+
| "empty-document";
|
|
280
283
|
readonly from: number;
|
|
281
284
|
readonly to: number;
|
|
282
285
|
readonly storyLength: number;
|
|
286
|
+
/** Cross-paragraph only — the two block indices the range straddled. */
|
|
287
|
+
readonly fromBlockIndex?: number;
|
|
288
|
+
readonly toBlockIndex?: number;
|
|
289
|
+
/** Non-paragraph target only — the offending block's index and kind. */
|
|
290
|
+
readonly blockIndex?: number;
|
|
291
|
+
readonly blockKind?: string;
|
|
283
292
|
/**
|
|
284
293
|
* Single-sentence, agent-actionable explanation. Tells the caller
|
|
285
294
|
* what the failure was and the concrete next step — no guesswork
|
|
@@ -291,7 +300,9 @@ export type CreateScopeFromAnchorResult =
|
|
|
291
300
|
* Short machine-routable next-step hint for thin consumers that
|
|
292
301
|
* don't want to pattern-match on `reason`. Examples:
|
|
293
302
|
* "clamp-from-to-zero", "swap-from-and-to",
|
|
294
|
-
* "clamp-to-to-storyLength-or-pick-a-different-range"
|
|
303
|
+
* "clamp-to-to-storyLength-or-pick-a-different-range",
|
|
304
|
+
* "narrow-to-single-paragraph",
|
|
305
|
+
* "pick-a-paragraph-target".
|
|
295
306
|
*/
|
|
296
307
|
readonly nextStep: string;
|
|
297
308
|
};
|
|
@@ -420,5 +431,82 @@ export function createScopeFromAnchor(
|
|
|
420
431
|
...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
|
|
421
432
|
});
|
|
422
433
|
|
|
434
|
+
// Pre-2026-04-24 the coordinator silently returned a minted scopeId
|
|
435
|
+
// even when insertScopeMarkers refused to plant (cross-paragraph
|
|
436
|
+
// range, non-paragraph target, out-of-bounds after the story-length
|
|
437
|
+
// check passed). Now the coordinator surfaces `plantStatus.planted:
|
|
438
|
+
// false`; translate each reason into the same `range-invalid` shape
|
|
439
|
+
// used by the bounds checks above so the caller gets one uniform
|
|
440
|
+
// discriminator to branch on.
|
|
441
|
+
if (result.plantStatus && result.plantStatus.planted === false) {
|
|
442
|
+
const ps = result.plantStatus;
|
|
443
|
+
if (ps.reason === "cross-paragraph-range") {
|
|
444
|
+
return {
|
|
445
|
+
status: "range-invalid",
|
|
446
|
+
reason: "cross-paragraph-range",
|
|
447
|
+
from,
|
|
448
|
+
to,
|
|
449
|
+
storyLength,
|
|
450
|
+
fromBlockIndex: ps.fromBlockIndex ?? -1,
|
|
451
|
+
toBlockIndex: ps.toBlockIndex ?? -1,
|
|
452
|
+
message:
|
|
453
|
+
`createScopeFromAnchor refused: range [${from}, ${to}] straddles ` +
|
|
454
|
+
`paragraphs ${ps.fromBlockIndex} and ${ps.toBlockIndex}. Marker-backed ` +
|
|
455
|
+
`scopes only plant inside a single paragraph today. Narrow the range to ` +
|
|
456
|
+
`land inside one paragraph, or create two separate scopes.`,
|
|
457
|
+
nextStep: "narrow-to-single-paragraph",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (ps.reason === "non-paragraph-target") {
|
|
461
|
+
return {
|
|
462
|
+
status: "range-invalid",
|
|
463
|
+
reason: "non-paragraph-target",
|
|
464
|
+
from,
|
|
465
|
+
to,
|
|
466
|
+
storyLength,
|
|
467
|
+
blockIndex: ps.blockIndex ?? -1,
|
|
468
|
+
blockKind: ps.blockKind ?? "unknown",
|
|
469
|
+
message:
|
|
470
|
+
`createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
|
|
471
|
+
`${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
|
|
472
|
+
`Marker scopes only plant inside paragraphs today. Pick a paragraph ` +
|
|
473
|
+
`target, or use runtime.workflow.createScope({blockId}) for ` +
|
|
474
|
+
`whole-block scopes on the containing structure.`,
|
|
475
|
+
nextStep: "pick-a-paragraph-target",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (ps.reason === "range-out-of-bounds") {
|
|
479
|
+
// Shouldn't happen — storyLength was checked above — but surface
|
|
480
|
+
// it as a first-class failure in case the underlying length math
|
|
481
|
+
// drifts from our bounds check.
|
|
482
|
+
return {
|
|
483
|
+
status: "range-invalid",
|
|
484
|
+
reason: "range-exceeds-story-length",
|
|
485
|
+
from,
|
|
486
|
+
to,
|
|
487
|
+
storyLength: ps.storyLength ?? storyLength,
|
|
488
|
+
message:
|
|
489
|
+
`createScopeFromAnchor refused: coordinator reports range [${from}, ${to}] ` +
|
|
490
|
+
`is out of bounds (storyLength=${ps.storyLength}). This is usually a ` +
|
|
491
|
+
`stale-offset bug (KI-P9) — re-derive positions from the current ` +
|
|
492
|
+
`document and retry.`,
|
|
493
|
+
nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// empty-document — target has no canonical blocks.
|
|
497
|
+
return {
|
|
498
|
+
status: "range-invalid",
|
|
499
|
+
reason: "empty-document",
|
|
500
|
+
from,
|
|
501
|
+
to,
|
|
502
|
+
storyLength,
|
|
503
|
+
message:
|
|
504
|
+
`createScopeFromAnchor refused: the target document has no blocks; ` +
|
|
505
|
+
`cannot plant scope markers. Open or initialize a document before ` +
|
|
506
|
+
`creating sub-block scopes.`,
|
|
507
|
+
nextStep: "initialize-document-before-creating-scopes",
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
423
511
|
return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
|
|
424
512
|
}
|
|
@@ -186,6 +186,16 @@ export interface TwChromeOverlayProps {
|
|
|
186
186
|
/** Preview catalog threaded into the page-stack chrome so header /
|
|
187
187
|
* footer / footnote / endnote regions render real <img>s. */
|
|
188
188
|
mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot").MediaPreviewDescriptor>;
|
|
189
|
+
/**
|
|
190
|
+
* Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
|
|
191
|
+
* to whichever per-page header/footer band is the active story. Pass
|
|
192
|
+
* `null` (or omit) when no header/footer is active. The bundle's `kind`
|
|
193
|
+
* is set by the band itself; do not pre-pin it.
|
|
194
|
+
*/
|
|
195
|
+
activeBandRibbonProps?: Omit<
|
|
196
|
+
import("../page-stack/tw-active-band-ribbon").TwActiveBandRibbonProps,
|
|
197
|
+
"kind" | "data-testid"
|
|
198
|
+
> | null;
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
/**
|
|
@@ -230,6 +240,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
230
240
|
pmView,
|
|
231
241
|
visiblePageIndexRange,
|
|
232
242
|
mediaPreviews,
|
|
243
|
+
activeBandRibbonProps,
|
|
233
244
|
}) => {
|
|
234
245
|
return (
|
|
235
246
|
<div
|
|
@@ -248,6 +259,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
248
259
|
pmView={pmView}
|
|
249
260
|
visiblePageIndexRange={visiblePageIndexRange ?? null}
|
|
250
261
|
mediaPreviews={mediaPreviews}
|
|
262
|
+
activeBandRibbonProps={activeBandRibbonProps ?? null}
|
|
251
263
|
/>
|
|
252
264
|
) : null}
|
|
253
265
|
<TwScopeRailLayer
|
|
@@ -199,6 +199,17 @@ export const editorSchema = new Schema({
|
|
|
199
199
|
pageBreakBefore: { default: null },
|
|
200
200
|
hiddenTextOnly: { default: null },
|
|
201
201
|
placeholderCulled: { default: null },
|
|
202
|
+
/**
|
|
203
|
+
* Rendered height (in twips) of the block that this placeholder
|
|
204
|
+
* stands in for, supplied by `DocumentRuntime` from L04's page
|
|
205
|
+
* graph. When present on a `placeholderCulled` paragraph, `toDOM`
|
|
206
|
+
* emits a fixed-height `<div>` (`${twips/20}pt`) instead of the
|
|
207
|
+
* `min-height: 20px` fallback, eliminating the scroll-path
|
|
208
|
+
* "paragraphs jump around pagination gaps" flicker that occurred
|
|
209
|
+
* when blocks realized at real heights larger than one line.
|
|
210
|
+
* Null / undefined preserves the pre-existing 20 px minimum.
|
|
211
|
+
*/
|
|
212
|
+
placeholderHeightTwips: { default: null },
|
|
202
213
|
blockId: { default: null },
|
|
203
214
|
/**
|
|
204
215
|
* `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
|
|
@@ -214,6 +225,11 @@ export const editorSchema = new Schema({
|
|
|
214
225
|
toDOM(node) {
|
|
215
226
|
// Viewport-culled placeholder paragraph — cheap size-preserving leaf.
|
|
216
227
|
if (node.attrs.placeholderCulled) {
|
|
228
|
+
const heightTwips = node.attrs.placeholderHeightTwips as number | null;
|
|
229
|
+
const heightStyle =
|
|
230
|
+
typeof heightTwips === "number" && heightTwips > 0
|
|
231
|
+
? `height: ${heightTwips / 20}pt`
|
|
232
|
+
: "min-height: 20px";
|
|
217
233
|
return [
|
|
218
234
|
"div",
|
|
219
235
|
{
|
|
@@ -221,7 +237,10 @@ export const editorSchema = new Schema({
|
|
|
221
237
|
"data-placeholder-culled": "true",
|
|
222
238
|
"data-placeholder-size": String(node.nodeSize),
|
|
223
239
|
"data-placeholder-block-id": node.attrs.blockId ?? "",
|
|
224
|
-
|
|
240
|
+
...(typeof heightTwips === "number" && heightTwips > 0
|
|
241
|
+
? { "data-placeholder-height-twips": String(heightTwips) }
|
|
242
|
+
: {}),
|
|
243
|
+
style: `${heightStyle}; contain: strict;`,
|
|
225
244
|
"aria-hidden": "true",
|
|
226
245
|
},
|
|
227
246
|
0,
|
|
@@ -867,10 +867,25 @@ function buildOpaqueBlock(
|
|
|
867
867
|
const placeholderSize = block.placeholderSize ?? null;
|
|
868
868
|
if (placeholderSize !== null) {
|
|
869
869
|
const targetSize = placeholderSize as number;
|
|
870
|
+
// Flicker fix — when DocumentRuntime has enriched the placeholder with
|
|
871
|
+
// the block's known rendered height (from L04's page graph), thread it
|
|
872
|
+
// onto the paragraph node so `pm-schema.ts::toDOM` emits a fixed
|
|
873
|
+
// `height` style matching the real block. Without this, the placeholder
|
|
874
|
+
// renders at `min-height: 20px` and inflates to its real height when
|
|
875
|
+
// the block realizes on scroll, dragging content below the scroll
|
|
876
|
+
// pointer ("paragraphs jump around pagination gaps").
|
|
877
|
+
const placeholderHeightTwips = block.placeholderHeightTwips ?? null;
|
|
878
|
+
const placeholderAttrs: Record<string, unknown> = {
|
|
879
|
+
blockId: block.blockId,
|
|
880
|
+
placeholderCulled: true,
|
|
881
|
+
};
|
|
882
|
+
if (placeholderHeightTwips !== null) {
|
|
883
|
+
placeholderAttrs.placeholderHeightTwips = placeholderHeightTwips;
|
|
884
|
+
}
|
|
870
885
|
if (targetSize <= 2) {
|
|
871
886
|
// Edge case: bare empty paragraph claims exactly 2 positions.
|
|
872
887
|
return editorSchema.nodes.paragraph.create(
|
|
873
|
-
|
|
888
|
+
placeholderAttrs,
|
|
874
889
|
Fragment.empty,
|
|
875
890
|
);
|
|
876
891
|
}
|
|
@@ -878,7 +893,7 @@ function buildOpaqueBlock(
|
|
|
878
893
|
// total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
|
|
879
894
|
const filler = "\u200b".repeat(targetSize - 2);
|
|
880
895
|
return editorSchema.nodes.paragraph.create(
|
|
881
|
-
|
|
896
|
+
placeholderAttrs,
|
|
882
897
|
editorSchema.text(filler),
|
|
883
898
|
);
|
|
884
899
|
}
|
|
@@ -916,21 +916,21 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
916
916
|
viewRef.current = view;
|
|
917
917
|
recordPerfSample("pm.mount");
|
|
918
918
|
} else {
|
|
919
|
-
// Wave 1 Slice C ·
|
|
919
|
+
// Wave 1 Slice C · snapshot-replacement funnel.
|
|
920
920
|
//
|
|
921
|
-
// `replaceStatePreservingPosition`
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
// measurement on the hot path).
|
|
927
|
-
// 2. Echo-suppression ordering — `suppressSelectionEchoRef` is
|
|
928
|
-
// set to `true` BEFORE the state swap and released in a
|
|
929
|
-
// microtask AFTER, so PM's internal selection-change events
|
|
930
|
-
// during the swap are swallowed by the selection-sync
|
|
931
|
-
// plugin.
|
|
921
|
+
// `replaceStatePreservingPosition` owns the echo-suppression
|
|
922
|
+
// ordering around the state swap — suppressSelectionEchoRef is
|
|
923
|
+
// set to `true` BEFORE updateState and released in a microtask
|
|
924
|
+
// AFTER, so PM's internal selection-change events during the
|
|
925
|
+
// swap are swallowed by the selection-sync plugin.
|
|
932
926
|
//
|
|
933
|
-
//
|
|
927
|
+
// Scroll-anchor preservation (`preserveScrollAnchor: true`) is
|
|
928
|
+
// currently OFF by default after the 2026-04-24 jump-to-top
|
|
929
|
+
// regression report (see hotfix commit). Re-enable under a
|
|
930
|
+
// diagnosed-safe codepath only; the capture/restore helpers
|
|
931
|
+
// remain tested and ready.
|
|
932
|
+
//
|
|
933
|
+
// Ordering invariant is regression-guarded by
|
|
934
934
|
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
935
935
|
replaceStatePreservingPosition(
|
|
936
936
|
{
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
4
|
+
import { TwPageRuler } from "../chrome/tw-page-ruler";
|
|
5
|
+
import type {
|
|
6
|
+
EditorViewStateSnapshot,
|
|
7
|
+
HeaderFooterLinkPatch,
|
|
8
|
+
PageLayoutSnapshot,
|
|
9
|
+
SectionBreakType,
|
|
10
|
+
SectionLayoutPatch,
|
|
11
|
+
SectionPageNumberingPatch,
|
|
12
|
+
} from "../../api/public-types";
|
|
13
|
+
import type { ActiveParagraphLayout } from "../review-workspace/paragraph-layout";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// TwActiveBandRibbon — Slice B of the §6.20 page-layout reshape.
|
|
17
|
+
//
|
|
18
|
+
// A quiet on-demand surface that floats above (header) or below (footer)
|
|
19
|
+
// an active header/footer band. Hosts the section-properties controls
|
|
20
|
+
// previously parked in `TwLayoutPanel` + `TwReviewWorkspacePageToolbar`,
|
|
21
|
+
// scoped to the active story only — matching Word's "Header & Footer"
|
|
22
|
+
// ribbon-tab mental model. Dismisses with the active band.
|
|
23
|
+
//
|
|
24
|
+
// Position context: the parent band uses `position: absolute`; this
|
|
25
|
+
// ribbon uses `position: absolute` with `bottom: 100%` (header) or
|
|
26
|
+
// `top: 100%` (footer) so it overflows the band frame without
|
|
27
|
+
// repositioning the band itself.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface TwActiveBandRibbonProps {
|
|
31
|
+
kind: "header" | "footer";
|
|
32
|
+
pageLayout: PageLayoutSnapshot;
|
|
33
|
+
viewState: EditorViewStateSnapshot;
|
|
34
|
+
paragraphLayout: ActiveParagraphLayout | null;
|
|
35
|
+
readOnly: boolean;
|
|
36
|
+
onCloseStory?: () => void;
|
|
37
|
+
onInsertSectionBreak?: (type: SectionBreakType) => void;
|
|
38
|
+
onUpdateSectionLayout?: (sectionIndex: number, patch: SectionLayoutPatch) => void;
|
|
39
|
+
onSetSectionPageNumbering?: (
|
|
40
|
+
sectionIndex: number,
|
|
41
|
+
patch: SectionPageNumberingPatch | null,
|
|
42
|
+
) => void;
|
|
43
|
+
onSetHeaderFooterLink?: (sectionIndex: number, patch: HeaderFooterLinkPatch) => void;
|
|
44
|
+
onSetParagraphIndentation?: React.ComponentProps<typeof TwPageRuler>["onSetIndentation"];
|
|
45
|
+
onSetParagraphTabStops?: React.ComponentProps<typeof TwPageRuler>["onSetTabStops"];
|
|
46
|
+
"data-testid"?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const TwActiveBandRibbon: React.FC<TwActiveBandRibbonProps> = React.memo(({
|
|
50
|
+
kind,
|
|
51
|
+
pageLayout,
|
|
52
|
+
viewState,
|
|
53
|
+
paragraphLayout,
|
|
54
|
+
readOnly,
|
|
55
|
+
onCloseStory,
|
|
56
|
+
onInsertSectionBreak,
|
|
57
|
+
onUpdateSectionLayout,
|
|
58
|
+
onSetSectionPageNumbering,
|
|
59
|
+
onSetHeaderFooterLink,
|
|
60
|
+
onSetParagraphIndentation,
|
|
61
|
+
onSetParagraphTabStops,
|
|
62
|
+
"data-testid": testId,
|
|
63
|
+
}) => {
|
|
64
|
+
const sectionIndex = pageLayout.sectionIndex;
|
|
65
|
+
const nextOrientation =
|
|
66
|
+
pageLayout.orientation === "portrait" ? "landscape" : "portrait";
|
|
67
|
+
const titlePageEnabled = pageLayout.differentFirstPage;
|
|
68
|
+
const numberingFormat = pageLayout.pageNumbering?.format ?? "decimal";
|
|
69
|
+
const positionStyles: React.CSSProperties =
|
|
70
|
+
kind === "header"
|
|
71
|
+
? { left: 0, right: 0, bottom: "100%" }
|
|
72
|
+
: { left: 0, right: 0, top: "100%" };
|
|
73
|
+
const linkVariant: HeaderFooterLinkPatch["variant"] =
|
|
74
|
+
pageLayout.headerVariants[0]?.variant ?? "default";
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
data-active-band-ribbon={kind}
|
|
79
|
+
data-testid={testId}
|
|
80
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
81
|
+
style={{
|
|
82
|
+
position: "absolute",
|
|
83
|
+
...positionStyles,
|
|
84
|
+
pointerEvents: "auto",
|
|
85
|
+
zIndex: 2,
|
|
86
|
+
}}
|
|
87
|
+
className="flex flex-col gap-1 rounded-md border border-border/50 bg-canvas/95 px-2 py-1 shadow-sm backdrop-blur-sm"
|
|
88
|
+
>
|
|
89
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
90
|
+
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
91
|
+
{kind === "header" ? "Header" : "Footer"} · Section {sectionIndex + 1}
|
|
92
|
+
</span>
|
|
93
|
+
{onCloseStory ? (
|
|
94
|
+
<RibbonButton
|
|
95
|
+
ariaLabel="Return to document body"
|
|
96
|
+
onClick={onCloseStory}
|
|
97
|
+
data-testid="active-band-ribbon-close"
|
|
98
|
+
>
|
|
99
|
+
Done
|
|
100
|
+
</RibbonButton>
|
|
101
|
+
) : null}
|
|
102
|
+
<span aria-hidden="true" className="mx-1 h-3 w-px bg-border/60" />
|
|
103
|
+
<RibbonButton
|
|
104
|
+
ariaLabel={`Switch section to ${nextOrientation}`}
|
|
105
|
+
disabled={readOnly || !onUpdateSectionLayout}
|
|
106
|
+
onClick={() =>
|
|
107
|
+
onUpdateSectionLayout?.(sectionIndex, {
|
|
108
|
+
pageSize: {
|
|
109
|
+
orientation: nextOrientation,
|
|
110
|
+
width: pageLayout.pageHeight,
|
|
111
|
+
height: pageLayout.pageWidth,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
data-testid="active-band-ribbon-orientation"
|
|
116
|
+
>
|
|
117
|
+
{nextOrientation === "landscape" ? "Landscape" : "Portrait"}
|
|
118
|
+
</RibbonButton>
|
|
119
|
+
<RibbonButton
|
|
120
|
+
ariaLabel="Insert next-page section break"
|
|
121
|
+
disabled={readOnly || !onInsertSectionBreak}
|
|
122
|
+
onClick={() => onInsertSectionBreak?.("nextPage")}
|
|
123
|
+
data-testid="active-band-ribbon-section-break"
|
|
124
|
+
>
|
|
125
|
+
Section break
|
|
126
|
+
</RibbonButton>
|
|
127
|
+
<RibbonButton
|
|
128
|
+
ariaLabel="Restart page numbering at 1"
|
|
129
|
+
disabled={readOnly || !onSetSectionPageNumbering}
|
|
130
|
+
onClick={() =>
|
|
131
|
+
onSetSectionPageNumbering?.(sectionIndex, {
|
|
132
|
+
...(pageLayout.pageNumbering ?? {}),
|
|
133
|
+
start: 1,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
data-testid="active-band-ribbon-restart-numbering"
|
|
137
|
+
>
|
|
138
|
+
Restart numbering
|
|
139
|
+
</RibbonButton>
|
|
140
|
+
<RibbonButton
|
|
141
|
+
ariaLabel={
|
|
142
|
+
numberingFormat === "roman"
|
|
143
|
+
? "Switch numbering to decimal"
|
|
144
|
+
: "Switch numbering to roman"
|
|
145
|
+
}
|
|
146
|
+
disabled={readOnly || !onSetSectionPageNumbering}
|
|
147
|
+
onClick={() =>
|
|
148
|
+
onSetSectionPageNumbering?.(sectionIndex, {
|
|
149
|
+
...(pageLayout.pageNumbering ?? {}),
|
|
150
|
+
format: numberingFormat === "roman" ? "decimal" : "roman",
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
data-testid="active-band-ribbon-numbering-format"
|
|
154
|
+
>
|
|
155
|
+
{numberingFormat === "roman" ? "Decimal" : "Roman"}
|
|
156
|
+
</RibbonButton>
|
|
157
|
+
<RibbonButton
|
|
158
|
+
ariaLabel="Toggle different first page"
|
|
159
|
+
disabled={readOnly || !onUpdateSectionLayout}
|
|
160
|
+
onClick={() =>
|
|
161
|
+
onUpdateSectionLayout?.(sectionIndex, {
|
|
162
|
+
titlePage: !titlePageEnabled,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
data-testid="active-band-ribbon-title-page"
|
|
166
|
+
>
|
|
167
|
+
{titlePageEnabled ? "Same first page" : "Different first page"}
|
|
168
|
+
</RibbonButton>
|
|
169
|
+
{sectionIndex > 0 && onSetHeaderFooterLink ? (
|
|
170
|
+
<RibbonButton
|
|
171
|
+
ariaLabel={`Link ${kind} to previous section`}
|
|
172
|
+
disabled={readOnly}
|
|
173
|
+
onClick={() =>
|
|
174
|
+
onSetHeaderFooterLink(sectionIndex, {
|
|
175
|
+
kind,
|
|
176
|
+
variant: linkVariant,
|
|
177
|
+
linkToPrevious: true,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
data-testid="active-band-ribbon-link-previous"
|
|
181
|
+
>
|
|
182
|
+
Link to previous
|
|
183
|
+
</RibbonButton>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
<TwPageRuler
|
|
187
|
+
pageLayout={pageLayout}
|
|
188
|
+
viewState={viewState}
|
|
189
|
+
paragraphLayout={paragraphLayout}
|
|
190
|
+
readOnly={readOnly}
|
|
191
|
+
onReturnToBody={onCloseStory ?? (() => undefined)}
|
|
192
|
+
onSetIndentation={onSetParagraphIndentation}
|
|
193
|
+
onSetTabStops={onSetParagraphTabStops}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
interface RibbonButtonProps {
|
|
200
|
+
ariaLabel: string;
|
|
201
|
+
children: React.ReactNode;
|
|
202
|
+
disabled?: boolean;
|
|
203
|
+
onClick: () => void;
|
|
204
|
+
"data-testid"?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function RibbonButton({
|
|
208
|
+
ariaLabel,
|
|
209
|
+
children,
|
|
210
|
+
disabled,
|
|
211
|
+
onClick,
|
|
212
|
+
"data-testid": testId,
|
|
213
|
+
}: RibbonButtonProps): React.ReactElement {
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
aria-label={ariaLabel}
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
data-testid={testId}
|
|
220
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
221
|
+
onClick={onClick}
|
|
222
|
+
className="inline-flex h-6 items-center rounded px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
|
|
223
|
+
>
|
|
224
|
+
{children}
|
|
225
|
+
</button>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default TwActiveBandRibbon;
|