@beyondwork/docx-react-component 1.0.87 → 1.0.88
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.88",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
@@ -124,6 +124,7 @@ import {
|
|
|
124
124
|
MAIN_STORY_TARGET,
|
|
125
125
|
storyTargetsEqual,
|
|
126
126
|
type EditorAnchorProjection as InternalEditorAnchorProjection,
|
|
127
|
+
type TransactionMapping,
|
|
127
128
|
} from "../core/selection/mapping.ts";
|
|
128
129
|
import {
|
|
129
130
|
toInternalAnchorProjection,
|
|
@@ -1174,6 +1175,8 @@ export function createDocumentRuntime(
|
|
|
1174
1175
|
// to a single microtask-deferred emit. Declared early because emit() (which
|
|
1175
1176
|
// checks this flag) runs during construction.
|
|
1176
1177
|
let analyticsEmitScheduled = false;
|
|
1178
|
+
let analyticsEmitScheduleMode: "none" | "microtask" | "idle" = "none";
|
|
1179
|
+
let deferNextContextAnalyticsEmit = false;
|
|
1177
1180
|
|
|
1178
1181
|
// V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
|
|
1179
1182
|
// a mismatch schedules a microtask refresh of TOC fields.
|
|
@@ -1380,6 +1383,8 @@ export function createDocumentRuntime(
|
|
|
1380
1383
|
let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
|
|
1381
1384
|
/** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
|
|
1382
1385
|
let viewportRangesKey: string = serializeViewportRanges(null);
|
|
1386
|
+
const EDITING_CORRIDOR_BLOCK_RADIUS = 8;
|
|
1387
|
+
const EDITING_CORRIDOR_MIN_BLOCKS = 24;
|
|
1383
1388
|
|
|
1384
1389
|
function applyViewportRanges(
|
|
1385
1390
|
incoming: readonly { start: number; end: number }[] | null,
|
|
@@ -1567,19 +1572,35 @@ export function createDocumentRuntime(
|
|
|
1567
1572
|
function getCachedSurface(
|
|
1568
1573
|
document: CanonicalDocumentEnvelope,
|
|
1569
1574
|
nextActiveStory: EditorStoryTarget,
|
|
1575
|
+
options: {
|
|
1576
|
+
viewportBlockRangesOverride?: readonly { start: number; end: number }[] | null;
|
|
1577
|
+
enrichCulledPlaceholders?: boolean;
|
|
1578
|
+
} = {},
|
|
1570
1579
|
): RuntimeRenderSnapshot["surface"] {
|
|
1571
1580
|
const activeStoryKey = storyTargetKey(nextActiveStory);
|
|
1581
|
+
const surfaceViewportRanges =
|
|
1582
|
+
"viewportBlockRangesOverride" in options
|
|
1583
|
+
? options.viewportBlockRangesOverride ?? null
|
|
1584
|
+
: viewportBlockRanges;
|
|
1585
|
+
const surfaceViewportRangesKey =
|
|
1586
|
+
"viewportBlockRangesOverride" in options
|
|
1587
|
+
? serializeViewportRanges(surfaceViewportRanges)
|
|
1588
|
+
: viewportRangesKey;
|
|
1589
|
+
const surfaceCacheKey =
|
|
1590
|
+
options.enrichCulledPlaceholders === false
|
|
1591
|
+
? `${surfaceViewportRangesKey}|raw-placeholders`
|
|
1592
|
+
: surfaceViewportRangesKey;
|
|
1572
1593
|
if (
|
|
1573
1594
|
cachedSurface &&
|
|
1574
1595
|
cachedSurface.revisionToken === state.revisionToken &&
|
|
1575
1596
|
cachedSurface.activeStoryKey === activeStoryKey &&
|
|
1576
|
-
cachedSurface.viewportRangesKey ===
|
|
1597
|
+
cachedSurface.viewportRangesKey === surfaceCacheKey
|
|
1577
1598
|
) {
|
|
1578
1599
|
return cachedSurface.snapshot;
|
|
1579
1600
|
}
|
|
1580
1601
|
|
|
1581
1602
|
const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
|
|
1582
|
-
viewportBlockRanges,
|
|
1603
|
+
viewportBlockRanges: surfaceViewportRanges,
|
|
1583
1604
|
...(effectiveMarkupModeProvider
|
|
1584
1605
|
? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
|
|
1585
1606
|
: {}),
|
|
@@ -1605,20 +1626,57 @@ export function createDocumentRuntime(
|
|
|
1605
1626
|
//
|
|
1606
1627
|
// No-op on pre-pagination calls (engine not yet ready → empty map)
|
|
1607
1628
|
// and on the inert facet.
|
|
1608
|
-
const enrichedSnapshot =
|
|
1629
|
+
const enrichedSnapshot =
|
|
1630
|
+
options.enrichCulledPlaceholders === false
|
|
1631
|
+
? snapshot
|
|
1632
|
+
: enrichCulledPlaceholdersWithHeights(snapshot);
|
|
1609
1633
|
cachedSurface = {
|
|
1610
1634
|
revisionToken: state.revisionToken,
|
|
1611
1635
|
activeStoryKey,
|
|
1612
|
-
viewportRangesKey,
|
|
1636
|
+
viewportRangesKey: surfaceCacheKey,
|
|
1613
1637
|
snapshot: enrichedSnapshot,
|
|
1614
1638
|
};
|
|
1615
1639
|
// Keep the scroll-path fingerprint in lockstep so a subsequent
|
|
1616
1640
|
// `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
|
|
1617
1641
|
// and short-circuits instead of paying a redundant projection.
|
|
1618
|
-
cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${
|
|
1642
|
+
cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${surfaceCacheKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
|
|
1619
1643
|
return enrichedSnapshot;
|
|
1620
1644
|
}
|
|
1621
1645
|
|
|
1646
|
+
function getLocalTextCommitViewportRanges(
|
|
1647
|
+
previousSurface: RuntimeRenderSnapshot["surface"] | undefined,
|
|
1648
|
+
): readonly { start: number; end: number }[] | null {
|
|
1649
|
+
if (!previousSurface || previousSurface.blocks.length < EDITING_CORRIDOR_MIN_BLOCKS) {
|
|
1650
|
+
return viewportBlockRanges;
|
|
1651
|
+
}
|
|
1652
|
+
const caretBlockIndex = findSurfaceBlockIndexForSelection(previousSurface, state.selection);
|
|
1653
|
+
if (caretBlockIndex === -1) {
|
|
1654
|
+
return viewportBlockRanges;
|
|
1655
|
+
}
|
|
1656
|
+
const corridor = {
|
|
1657
|
+
start: Math.max(0, caretBlockIndex - EDITING_CORRIDOR_BLOCK_RADIUS),
|
|
1658
|
+
end: Math.min(previousSurface.blocks.length, caretBlockIndex + EDITING_CORRIDOR_BLOCK_RADIUS + 1),
|
|
1659
|
+
};
|
|
1660
|
+
return normalizeViewportRanges([...(viewportBlockRanges ?? []), corridor]);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function findSurfaceBlockIndexForSelection(
|
|
1664
|
+
surface: RuntimeRenderSnapshot["surface"],
|
|
1665
|
+
selection: EditorState["selection"],
|
|
1666
|
+
): number {
|
|
1667
|
+
if (!surface) return -1;
|
|
1668
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
1669
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
1670
|
+
for (let index = 0; index < surface.blocks.length; index += 1) {
|
|
1671
|
+
const block = surface.blocks[index];
|
|
1672
|
+
if (!block) continue;
|
|
1673
|
+
if (to >= block.from && from <= block.to) {
|
|
1674
|
+
return index;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return -1;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1622
1680
|
function enrichCulledPlaceholdersWithHeights(
|
|
1623
1681
|
snapshot: EditorSurfaceSnapshot,
|
|
1624
1682
|
): EditorSurfaceSnapshot {
|
|
@@ -2469,6 +2527,57 @@ export function createDocumentRuntime(
|
|
|
2469
2527
|
return snapshot;
|
|
2470
2528
|
}
|
|
2471
2529
|
|
|
2530
|
+
function refreshLocalTextCommitSnapshot(
|
|
2531
|
+
surface: RuntimeRenderSnapshot["surface"],
|
|
2532
|
+
mapping: TransactionMapping,
|
|
2533
|
+
): RuntimeRenderSnapshot {
|
|
2534
|
+
perfCounters.increment("refresh.localTextEquivalent");
|
|
2535
|
+
return {
|
|
2536
|
+
...cachedRenderSnapshot,
|
|
2537
|
+
documentId: state.documentId,
|
|
2538
|
+
sessionId: state.sessionId,
|
|
2539
|
+
sourceLabel: state.sourceLabel,
|
|
2540
|
+
revisionToken: state.revisionToken,
|
|
2541
|
+
isReady: state.phase === "ready",
|
|
2542
|
+
isDirty: state.isDirty,
|
|
2543
|
+
readOnly: state.readOnly,
|
|
2544
|
+
documentMode: viewState.documentMode,
|
|
2545
|
+
selection: toPublicSelectionSnapshot(state.selection, activeStory),
|
|
2546
|
+
activeStory,
|
|
2547
|
+
documentStats: updateDocumentStatsForLocalTextCommit(
|
|
2548
|
+
cachedRenderSnapshot.documentStats,
|
|
2549
|
+
mapping,
|
|
2550
|
+
),
|
|
2551
|
+
warnings: cachedRenderSnapshot.warnings,
|
|
2552
|
+
fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
|
|
2553
|
+
commandState: {
|
|
2554
|
+
canUndo: history.past.length > 0,
|
|
2555
|
+
canRedo: history.future.length > 0,
|
|
2556
|
+
readOnly: state.readOnly,
|
|
2557
|
+
},
|
|
2558
|
+
surface,
|
|
2559
|
+
protectionSnapshot,
|
|
2560
|
+
grabbedObjectId: grabState.objectId,
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function updateDocumentStatsForLocalTextCommit(
|
|
2565
|
+
previousStats: RuntimeRenderSnapshot["documentStats"],
|
|
2566
|
+
mapping: TransactionMapping,
|
|
2567
|
+
): RuntimeRenderSnapshot["documentStats"] {
|
|
2568
|
+
if (!previousStats) {
|
|
2569
|
+
return toPublicDocumentStats(state);
|
|
2570
|
+
}
|
|
2571
|
+
let delta = 0;
|
|
2572
|
+
for (const step of mapping.steps) {
|
|
2573
|
+
delta += step.insertSize - Math.max(0, step.to - step.from);
|
|
2574
|
+
}
|
|
2575
|
+
return {
|
|
2576
|
+
...previousStats,
|
|
2577
|
+
storyLength: Math.max(0, previousStats.storyLength + delta),
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2472
2581
|
/**
|
|
2473
2582
|
* Fingerprint over the inputs that the viewport-refresh fast-path knows
|
|
2474
2583
|
* about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
|
|
@@ -4892,14 +5001,52 @@ export function createDocumentRuntime(
|
|
|
4892
5001
|
history.future = [];
|
|
4893
5002
|
}
|
|
4894
5003
|
|
|
4895
|
-
applyTransactionToState(transaction);
|
|
5004
|
+
applyTransactionToState(transaction, { allowLocalTextFastPath: true });
|
|
4896
5005
|
}
|
|
4897
5006
|
|
|
4898
5007
|
function commitRemote(transaction: EditorTransaction): void {
|
|
4899
5008
|
applyTransactionToState(transaction);
|
|
4900
5009
|
}
|
|
4901
5010
|
|
|
4902
|
-
function
|
|
5011
|
+
function shouldUseLocalTextCommitSnapshot(
|
|
5012
|
+
previous: EditorState,
|
|
5013
|
+
next: EditorState,
|
|
5014
|
+
transaction: EditorTransaction,
|
|
5015
|
+
effects: EditorTransaction["effects"],
|
|
5016
|
+
): boolean {
|
|
5017
|
+
if (activeStory.kind !== "main") return false;
|
|
5018
|
+
if (!transaction.markDirty || transaction.mapping.steps.length === 0) {
|
|
5019
|
+
return false;
|
|
5020
|
+
}
|
|
5021
|
+
if (transaction.mapping.metadata?.invalidatesStructures) {
|
|
5022
|
+
return false;
|
|
5023
|
+
}
|
|
5024
|
+
if (previous.document.subParts !== next.document.subParts) {
|
|
5025
|
+
return false;
|
|
5026
|
+
}
|
|
5027
|
+
if (effects.warningsAdded.length > 0 || effects.warningsCleared.length > 0) {
|
|
5028
|
+
return false;
|
|
5029
|
+
}
|
|
5030
|
+
if ((effects.transientWarnings?.length ?? 0) > 0) {
|
|
5031
|
+
return false;
|
|
5032
|
+
}
|
|
5033
|
+
return (
|
|
5034
|
+
!effects.commentAdded &&
|
|
5035
|
+
!effects.commentResolved &&
|
|
5036
|
+
!effects.commentReopened &&
|
|
5037
|
+
!effects.commentReplyAdded &&
|
|
5038
|
+
!effects.commentBodyEdited &&
|
|
5039
|
+
!effects.changeAccepted &&
|
|
5040
|
+
!effects.changeRejected &&
|
|
5041
|
+
!effects.revisionAuthored &&
|
|
5042
|
+
!effects.commandBlocked
|
|
5043
|
+
);
|
|
5044
|
+
}
|
|
5045
|
+
|
|
5046
|
+
function applyTransactionToState(
|
|
5047
|
+
transaction: EditorTransaction,
|
|
5048
|
+
options: { allowLocalTextFastPath?: boolean } = {},
|
|
5049
|
+
): void {
|
|
4903
5050
|
// Pure-no-op short-circuit: when a reducer skipped without emitting any
|
|
4904
5051
|
// observable effect AND the selection is identical, skip finalizeState
|
|
4905
5052
|
// / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
|
|
@@ -4941,7 +5088,9 @@ export function createDocumentRuntime(
|
|
|
4941
5088
|
const previous = state;
|
|
4942
5089
|
|
|
4943
5090
|
const tApply0 = performance.now();
|
|
5091
|
+
const tProtection0 = performance.now();
|
|
4944
5092
|
protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
|
|
5093
|
+
perfCounters.increment("commit.protectionRemap.us", Math.round((performance.now() - tProtection0) * 1000));
|
|
4945
5094
|
const tFinalize0 = performance.now();
|
|
4946
5095
|
state = finalizeState(transaction.nextState, transaction.markDirty, clock());
|
|
4947
5096
|
perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
|
|
@@ -4949,8 +5098,10 @@ export function createDocumentRuntime(
|
|
|
4949
5098
|
// Re-sync marker-backed scope IDs against the new document; the
|
|
4950
5099
|
// store handles set computation + telemetry. `replaceOverlay(current, doc)`
|
|
4951
5100
|
// is idempotent on state and re-derives the marker-backed set.
|
|
5101
|
+
const tOverlay0 = performance.now();
|
|
4952
5102
|
overlayStore.replaceOverlay(overlayStore.getOverlay(), state.document);
|
|
4953
5103
|
const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
|
|
5104
|
+
perfCounters.increment("commit.overlaySync.us", Math.round((performance.now() - tOverlay0) * 1000));
|
|
4954
5105
|
|
|
4955
5106
|
const tInvalidate0 = performance.now();
|
|
4956
5107
|
if (transaction.markDirty && transaction.mapping.steps.length > 0) {
|
|
@@ -4974,12 +5125,37 @@ export function createDocumentRuntime(
|
|
|
4974
5125
|
}
|
|
4975
5126
|
perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
|
|
4976
5127
|
|
|
5128
|
+
const notifyEffects: EditorTransaction["effects"] = {
|
|
5129
|
+
...transaction.effects,
|
|
5130
|
+
warningsAdded: [
|
|
5131
|
+
...transaction.effects.warningsAdded,
|
|
5132
|
+
...detachedWorkflowScopeWarnings.added,
|
|
5133
|
+
],
|
|
5134
|
+
warningsCleared: [
|
|
5135
|
+
...transaction.effects.warningsCleared,
|
|
5136
|
+
...detachedWorkflowScopeWarnings.cleared,
|
|
5137
|
+
],
|
|
5138
|
+
};
|
|
5139
|
+
|
|
5140
|
+
const tClassify0 = performance.now();
|
|
5141
|
+
const useLocalTextCommitSnapshot =
|
|
5142
|
+
options.allowLocalTextFastPath === true &&
|
|
5143
|
+
shouldUseLocalTextCommitSnapshot(
|
|
5144
|
+
previous,
|
|
5145
|
+
state,
|
|
5146
|
+
transaction,
|
|
5147
|
+
notifyEffects,
|
|
5148
|
+
);
|
|
5149
|
+
perfCounters.increment("commit.refreshClassify.us", Math.round((performance.now() - tClassify0) * 1000));
|
|
5150
|
+
|
|
4977
5151
|
// I5: validate post-mutation selection against the new document bound.
|
|
4978
5152
|
// First call to getCachedSurface() here computes the post-mutation surface
|
|
4979
5153
|
// snapshot (cache miss — revisionToken just changed in finalizeState).
|
|
4980
|
-
//
|
|
4981
|
-
//
|
|
4982
|
-
//
|
|
5154
|
+
// For local text commits, this is an editing-corridor surface (current
|
|
5155
|
+
// block plus a small halo) so the immediate typing path keeps visible
|
|
5156
|
+
// truth hot without rebuilding offscreen table/comment markup. Structural,
|
|
5157
|
+
// review, and warning commits still use the full current viewport surface
|
|
5158
|
+
// and fall back to refreshRenderSnapshot().
|
|
4983
5159
|
//
|
|
4984
5160
|
// Wired ONLY at this chokepoint. The other 12 `cachedRenderSnapshot =
|
|
4985
5161
|
// refreshRenderSnapshot()` sites in this file are intentionally skipped:
|
|
@@ -4999,7 +5175,13 @@ export function createDocumentRuntime(
|
|
|
4999
5175
|
// The return type of getCachedSurface is `RuntimeRenderSnapshot["surface"]`
|
|
5000
5176
|
// which is optional in the public API for shape-only reasons — the helper
|
|
5001
5177
|
// itself always returns a defined snapshot.
|
|
5002
|
-
const
|
|
5178
|
+
const tValidation0 = performance.now();
|
|
5179
|
+
const surfaceForValidation = useLocalTextCommitSnapshot
|
|
5180
|
+
? getCachedSurface(state.document, activeStory, {
|
|
5181
|
+
viewportBlockRangesOverride: getLocalTextCommitViewportRanges(cachedRenderSnapshot.surface),
|
|
5182
|
+
enrichCulledPlaceholders: false,
|
|
5183
|
+
})!
|
|
5184
|
+
: getCachedSurface(state.document, activeStory)!;
|
|
5003
5185
|
const validationOptions = state.selection.activeRange.kind === "node"
|
|
5004
5186
|
? {
|
|
5005
5187
|
isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
|
|
@@ -5015,26 +5197,24 @@ export function createDocumentRuntime(
|
|
|
5015
5197
|
state = { ...state, selection: validatedSelection };
|
|
5016
5198
|
storySelections.set(storyTargetKey(activeStory), state.selection);
|
|
5017
5199
|
}
|
|
5200
|
+
perfCounters.increment("commit.selectionValidation.us", Math.round((performance.now() - tValidation0) * 1000));
|
|
5018
5201
|
|
|
5019
5202
|
const tRefresh0 = performance.now();
|
|
5020
|
-
cachedRenderSnapshot =
|
|
5203
|
+
cachedRenderSnapshot = useLocalTextCommitSnapshot
|
|
5204
|
+
? refreshLocalTextCommitSnapshot(surfaceForValidation, transaction.mapping)
|
|
5205
|
+
: refreshRenderSnapshot();
|
|
5021
5206
|
perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
|
|
5022
5207
|
|
|
5023
5208
|
const tNotify0 = performance.now();
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
...transaction
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
...transaction.effects.warningsCleared,
|
|
5034
|
-
...detachedWorkflowScopeWarnings.cleared,
|
|
5035
|
-
],
|
|
5036
|
-
},
|
|
5037
|
-
});
|
|
5209
|
+
deferNextContextAnalyticsEmit = useLocalTextCommitSnapshot;
|
|
5210
|
+
try {
|
|
5211
|
+
notify(previous, state, {
|
|
5212
|
+
...transaction,
|
|
5213
|
+
effects: notifyEffects,
|
|
5214
|
+
});
|
|
5215
|
+
} finally {
|
|
5216
|
+
deferNextContextAnalyticsEmit = false;
|
|
5217
|
+
}
|
|
5038
5218
|
perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
|
|
5039
5219
|
perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
|
|
5040
5220
|
emitStageToken(telemetryBus, "commit", "commit.apply.complete", {
|
|
@@ -5608,23 +5788,58 @@ export function createDocumentRuntime(
|
|
|
5608
5788
|
// synchronous call stack — one per commit. (Flag declared above near
|
|
5609
5789
|
// perfCounters so it is initialized before construction-time emits run.)
|
|
5610
5790
|
function scheduleContextAnalyticsEmit(): void {
|
|
5791
|
+
const shouldDefer = deferNextContextAnalyticsEmit;
|
|
5792
|
+
deferNextContextAnalyticsEmit = false;
|
|
5611
5793
|
if (analyticsEmitScheduled) {
|
|
5794
|
+
if (shouldDefer && analyticsEmitScheduleMode === "microtask") {
|
|
5795
|
+
analyticsEmitScheduleMode = "idle";
|
|
5796
|
+
perfCounters.increment("emit.contextAnalytics.deferred");
|
|
5797
|
+
return;
|
|
5798
|
+
}
|
|
5612
5799
|
perfCounters.increment("emit.contextAnalytics.coalesced");
|
|
5613
5800
|
return;
|
|
5614
5801
|
}
|
|
5615
5802
|
analyticsEmitScheduled = true;
|
|
5616
|
-
|
|
5803
|
+
analyticsEmitScheduleMode = shouldDefer ? "idle" : "microtask";
|
|
5804
|
+
const run = () => {
|
|
5617
5805
|
// Reset BEFORE the emit so any synchronous re-entrant emits triggered
|
|
5618
5806
|
// by listener callbacks schedule a fresh second microtask (no lost
|
|
5619
5807
|
// updates). Reversing this order would drop bursts originating in
|
|
5620
5808
|
// listeners.
|
|
5621
5809
|
analyticsEmitScheduled = false;
|
|
5810
|
+
analyticsEmitScheduleMode = "none";
|
|
5622
5811
|
const t = performance.now();
|
|
5623
5812
|
emitContextAnalyticsChanged();
|
|
5624
5813
|
perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
|
|
5814
|
+
};
|
|
5815
|
+
if (shouldDefer) {
|
|
5816
|
+
perfCounters.increment("emit.contextAnalytics.deferred");
|
|
5817
|
+
scheduleIdleContextAnalytics(run);
|
|
5818
|
+
return;
|
|
5819
|
+
}
|
|
5820
|
+
queueMicrotask(() => {
|
|
5821
|
+
if (analyticsEmitScheduleMode === "idle") {
|
|
5822
|
+
scheduleIdleContextAnalytics(run);
|
|
5823
|
+
return;
|
|
5824
|
+
}
|
|
5825
|
+
run();
|
|
5625
5826
|
});
|
|
5626
5827
|
}
|
|
5627
5828
|
|
|
5829
|
+
function scheduleIdleContextAnalytics(callback: () => void): void {
|
|
5830
|
+
const requestIdle = (globalThis as {
|
|
5831
|
+
requestIdleCallback?: (
|
|
5832
|
+
cb: () => void,
|
|
5833
|
+
options?: { timeout?: number },
|
|
5834
|
+
) => number;
|
|
5835
|
+
}).requestIdleCallback;
|
|
5836
|
+
if (typeof requestIdle === "function") {
|
|
5837
|
+
requestIdle(callback, { timeout: 250 });
|
|
5838
|
+
return;
|
|
5839
|
+
}
|
|
5840
|
+
setTimeout(callback, 0);
|
|
5841
|
+
}
|
|
5842
|
+
|
|
5628
5843
|
// V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
|
|
5629
5844
|
// microtask-coalesce shape. Bursts of heading edits within one synchronous
|
|
5630
5845
|
// call stack collapse to a single rebuild + a single toc_auto_refreshed
|
|
@@ -29,7 +29,7 @@ import type { EditorView } from "prosemirror-view";
|
|
|
29
29
|
import type { GeometryFacet } from "../../api/public-types.ts";
|
|
30
30
|
import {
|
|
31
31
|
findScrollAnchor,
|
|
32
|
-
|
|
32
|
+
resolveScrollTopForAnchor,
|
|
33
33
|
type ScrollAnchor,
|
|
34
34
|
} from "./scroll-anchor.ts";
|
|
35
35
|
|
|
@@ -56,6 +56,13 @@ export interface PreservePositionOptions {
|
|
|
56
56
|
* element directly to avoid the DOM lookup.
|
|
57
57
|
*/
|
|
58
58
|
scrollRoot?: HTMLElement | null;
|
|
59
|
+
/**
|
|
60
|
+
* Optional safety bound for edit-path restores. When set, restores whose
|
|
61
|
+
* target differs from the captured `scrollTop` by more than this many CSS
|
|
62
|
+
* pixels are refused. This keeps scroll preservation narrow for typing
|
|
63
|
+
* rebuilds while still allowing small same-story geometry shifts.
|
|
64
|
+
*/
|
|
65
|
+
maxScrollDeltaPx?: number;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export interface PreservedPosition {
|
|
@@ -102,11 +109,29 @@ export function capturePosition(
|
|
|
102
109
|
export function restorePosition(
|
|
103
110
|
captured: PreservedPosition,
|
|
104
111
|
options: PreservePositionOptions,
|
|
105
|
-
):
|
|
106
|
-
if (!captured.scrollRoot || !captured.anchor) return;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
): boolean {
|
|
113
|
+
if (!captured.scrollRoot || !captured.anchor) return false;
|
|
114
|
+
const targetScrollTop = resolveScrollTopForAnchor(
|
|
115
|
+
captured.scrollRoot,
|
|
116
|
+
captured.anchor,
|
|
117
|
+
{
|
|
118
|
+
geometryFacet: options.geometryFacet,
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
if (targetScrollTop === null || !Number.isFinite(targetScrollTop)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (targetScrollTop < 0) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
options.maxScrollDeltaPx !== undefined &&
|
|
129
|
+
Math.abs(targetScrollTop - captured.scrollTop) > options.maxScrollDeltaPx
|
|
130
|
+
) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
captured.scrollRoot.scrollTop = targetScrollTop;
|
|
134
|
+
return true;
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
/**
|
|
@@ -137,7 +137,23 @@ export function restoreScrollAnchor(
|
|
|
137
137
|
anchor: ScrollAnchor | null,
|
|
138
138
|
options?: FindScrollAnchorOptions,
|
|
139
139
|
): void {
|
|
140
|
-
|
|
140
|
+
const targetScrollTop = resolveScrollTopForAnchor(root, anchor, options);
|
|
141
|
+
if (root && targetScrollTop !== null) {
|
|
142
|
+
root.scrollTop = targetScrollTop;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the `scrollTop` that would restore `anchor` without mutating the
|
|
148
|
+
* scroll container. Used by the edit-path preservation guard so it can reject
|
|
149
|
+
* unsafe restores before writing scroll state.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveScrollTopForAnchor(
|
|
152
|
+
root: HTMLElement | null,
|
|
153
|
+
anchor: ScrollAnchor | null,
|
|
154
|
+
options?: FindScrollAnchorOptions,
|
|
155
|
+
): number | null {
|
|
156
|
+
if (!root || !anchor) return null;
|
|
141
157
|
|
|
142
158
|
if (options?.geometryFacet && !options.prepaintFallback) {
|
|
143
159
|
const geometry = options.geometryFacet.getBlock(anchor.blockId);
|
|
@@ -150,15 +166,14 @@ export function restoreScrollAnchor(
|
|
|
150
166
|
// newScrollTop = newBlockTop + offsetWithinBlock.
|
|
151
167
|
// Matches the DOM-path formula below (round-trip verified by
|
|
152
168
|
// `test/ui/mode-toggle-scroll-anchor.test.ts`).
|
|
153
|
-
|
|
154
|
-
return;
|
|
169
|
+
return rect.topPx + anchor.offsetWithinBlock;
|
|
155
170
|
}
|
|
156
171
|
// No block match through facet; fall through to DOM path.
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
|
|
160
175
|
const block = root.querySelector<HTMLElement>(selector);
|
|
161
|
-
if (!block) return;
|
|
176
|
+
if (!block) return null;
|
|
162
177
|
// Cold-open / pre-paint DOM fallback — same rationale as
|
|
163
178
|
// findScrollAnchor's fallback above.
|
|
164
179
|
// geometry:allow-dom-fallback
|
|
@@ -171,7 +186,7 @@ export function restoreScrollAnchor(
|
|
|
171
186
|
// capture time). Scrolling by `delta` shifts rects by `-delta`, so
|
|
172
187
|
// solve for delta: delta = blockRect.top - rootRect.top + offsetWithinBlock.
|
|
173
188
|
const delta = blockRect.top - rootRect.top + anchor.offsetWithinBlock;
|
|
174
|
-
|
|
189
|
+
return root.scrollTop + delta;
|
|
175
190
|
}
|
|
176
191
|
|
|
177
192
|
/**
|
|
@@ -72,6 +72,7 @@ import { createPredictedTxGate } from "./predicted-tx-gate";
|
|
|
72
72
|
import { replaceStatePreservingPosition } from "./preserve-position";
|
|
73
73
|
import {
|
|
74
74
|
createScopeTagRegistry,
|
|
75
|
+
storyTargetsEqual,
|
|
75
76
|
type ScopeTagRegistry,
|
|
76
77
|
} from "../../api/public-types";
|
|
77
78
|
import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
|
|
@@ -92,6 +93,24 @@ import { chartNodeViews } from "./chart-node-view.tsx";
|
|
|
92
93
|
import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
|
|
93
94
|
import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
|
|
94
95
|
|
|
96
|
+
type RebuildScrollAnchorPolicy = "bounded-same-story";
|
|
97
|
+
|
|
98
|
+
const BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX = 256;
|
|
99
|
+
|
|
100
|
+
export function shouldPreserveScrollAnchorForRebuild(options: {
|
|
101
|
+
policy: RebuildScrollAnchorPolicy | null;
|
|
102
|
+
view: Pick<EditorView, "hasFocus"> | null;
|
|
103
|
+
geometryFacet?: import("../../runtime/geometry/index.ts").GeometryFacet;
|
|
104
|
+
previousStory: EditorStoryTarget | null;
|
|
105
|
+
nextStory: EditorStoryTarget;
|
|
106
|
+
}): boolean {
|
|
107
|
+
if (options.policy !== "bounded-same-story") return false;
|
|
108
|
+
if (!options.view?.hasFocus()) return false;
|
|
109
|
+
if (!options.geometryFacet) return false;
|
|
110
|
+
if (!options.previousStory) return false;
|
|
111
|
+
return storyTargetsEqual(options.previousStory, options.nextStory);
|
|
112
|
+
}
|
|
113
|
+
|
|
95
114
|
/**
|
|
96
115
|
* Build page-break widget decorations from the layout facet's current page
|
|
97
116
|
* graph. Returns `[]` when the facet is unavailable, the active story is
|
|
@@ -435,6 +454,8 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
435
454
|
const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
|
|
436
455
|
const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
|
|
437
456
|
const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
|
|
457
|
+
const pendingRebuildScrollAnchorRef = useRef<RebuildScrollAnchorPolicy | null>(null);
|
|
458
|
+
const lastBuiltStoryRef = useRef<EditorStoryTarget | null>(null);
|
|
438
459
|
const selectionToolbarFrameRef = useRef<number | null>(null);
|
|
439
460
|
const lastSelectionToolbarMeasurementRef = useRef<{
|
|
440
461
|
key: string | null;
|
|
@@ -766,6 +787,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
766
787
|
if (!props.dispatchRuntimeCommand || !sessionRef.current) {
|
|
767
788
|
laneRef.current = null;
|
|
768
789
|
equivalentAckLedgerRef.current.clear();
|
|
790
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
769
791
|
return;
|
|
770
792
|
}
|
|
771
793
|
// Wave 1 Slice E1/E2 — lane observability.
|
|
@@ -811,6 +833,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
811
833
|
);
|
|
812
834
|
},
|
|
813
835
|
onEquivalentAck: (ack) => {
|
|
836
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
814
837
|
if (
|
|
815
838
|
ack.opId &&
|
|
816
839
|
ack.newRevisionToken &&
|
|
@@ -821,16 +844,22 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
821
844
|
}
|
|
822
845
|
equivalentAckLedgerRef.current.clear();
|
|
823
846
|
},
|
|
824
|
-
onAdjustedAck: () => {
|
|
847
|
+
onAdjustedAck: (ack) => {
|
|
825
848
|
// Adjusted path: allow the rebuild effect to run (it will call
|
|
826
849
|
// view.updateState with the canonical snapshot).
|
|
827
850
|
equivalentAckLedgerRef.current.clear();
|
|
851
|
+
pendingRebuildScrollAnchorRef.current =
|
|
852
|
+
getTextCommandRefreshClass(ack) === "surface-only"
|
|
853
|
+
? "bounded-same-story"
|
|
854
|
+
: null;
|
|
828
855
|
},
|
|
829
856
|
onRejectedAck: () => {
|
|
830
857
|
equivalentAckLedgerRef.current.clear();
|
|
858
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
831
859
|
},
|
|
832
860
|
onStructuralDivergence: () => {
|
|
833
861
|
equivalentAckLedgerRef.current.clear();
|
|
862
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
834
863
|
},
|
|
835
864
|
});
|
|
836
865
|
}, [props.dispatchRuntimeCommand, scopeTagRegistry]);
|
|
@@ -839,6 +868,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
839
868
|
if (!mountRef.current || !surface) return;
|
|
840
869
|
|
|
841
870
|
if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
|
|
871
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
842
872
|
return;
|
|
843
873
|
}
|
|
844
874
|
|
|
@@ -866,6 +896,8 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
866
896
|
documentBuildKeyRef.current = documentBuildKey;
|
|
867
897
|
applyDecorationProps(viewRef.current, positionMapRef.current);
|
|
868
898
|
equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
|
|
899
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
900
|
+
lastBuiltStoryRef.current = snapshot.activeStory;
|
|
869
901
|
if (pendingTypingProbeRef.current) {
|
|
870
902
|
finishPerfProbe(pendingTypingProbeRef.current);
|
|
871
903
|
pendingTypingProbeRef.current = null;
|
|
@@ -921,24 +953,37 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
921
953
|
// AFTER, so PM's internal selection-change events during the
|
|
922
954
|
// swap are swallowed by the selection-sync plugin.
|
|
923
955
|
//
|
|
924
|
-
// Scroll-anchor preservation
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
956
|
+
// Scroll-anchor preservation is narrowly re-enabled only when a
|
|
957
|
+
// predicted edit receives a `surface-only` adjusted ack and the
|
|
958
|
+
// replacement stays in the same focused story with a live geometry
|
|
959
|
+
// facet. `maxScrollDeltaPx` is the guardrail: if the anchor target
|
|
960
|
+
// would move by more than the small local-edit budget, the helper
|
|
961
|
+
// refuses the restore and leaves the browser/runtime position alone.
|
|
929
962
|
//
|
|
930
963
|
// Ordering invariant is regression-guarded by
|
|
931
964
|
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
965
|
+
const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
|
|
966
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
967
|
+
const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
|
|
968
|
+
policy: scrollAnchorPolicy,
|
|
969
|
+
view: viewRef.current,
|
|
970
|
+
geometryFacet: props.geometryFacet,
|
|
971
|
+
previousStory: lastBuiltStoryRef.current,
|
|
972
|
+
nextStory: snapshot.activeStory,
|
|
973
|
+
});
|
|
932
974
|
replaceStatePreservingPosition(
|
|
933
975
|
{
|
|
934
976
|
view: viewRef.current,
|
|
935
977
|
geometryFacet: props.geometryFacet,
|
|
936
978
|
suppressionRef: suppressSelectionEchoRef,
|
|
979
|
+
preserveScrollAnchor,
|
|
980
|
+
maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
|
|
937
981
|
},
|
|
938
982
|
state,
|
|
939
983
|
);
|
|
940
984
|
}
|
|
941
985
|
documentBuildKeyRef.current = documentBuildKey;
|
|
986
|
+
lastBuiltStoryRef.current = snapshot.activeStory;
|
|
942
987
|
applyDecorationProps(viewRef.current, positionMap);
|
|
943
988
|
|
|
944
989
|
if (activeSearchRef.current) {
|