@beyondwork/docx-react-component 1.0.41 → 1.0.43
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 +38 -37
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table row-boundary split math.
|
|
3
|
+
*
|
|
4
|
+
* Pre-P6 the paginated-layout-engine placed every table atomically — a
|
|
5
|
+
* table taller than the remaining page space pushed to the next page as
|
|
6
|
+
* a single block, regardless of how many rows could have fit. That was
|
|
7
|
+
* correctness-first (the `collectTableRowSlices` walk after pagination
|
|
8
|
+
* produces empty slice maps) but visually wrong for CCEP corpus tables
|
|
9
|
+
* like the SOW Milestones table that routinely exceed a page.
|
|
10
|
+
*
|
|
11
|
+
* P6.b is the **pure math** side of the real row-boundary split:
|
|
12
|
+
*
|
|
13
|
+
* - `measureTableRowHeights(...)` computes a per-row twip height
|
|
14
|
+
* vector via the same measurement path the existing
|
|
15
|
+
* `measureTableHeight` walk uses. Each row height already honors
|
|
16
|
+
* `heightRule`, `w:gridBefore` / `w:gridAfter` padding, and real
|
|
17
|
+
* per-cell widths via `resolveCellWidth`.
|
|
18
|
+
*
|
|
19
|
+
* - `findTableRowSplit(...)` takes the per-row heights plus the
|
|
20
|
+
* remaining page space and walks rows greedily until the next row
|
|
21
|
+
* would overflow. It honors two invariants:
|
|
22
|
+
* 1. `cantSplit` rows never straddle a page boundary. If row K
|
|
23
|
+
* is `cantSplit` and overflows, the split moves BEFORE row K
|
|
24
|
+
* (row K becomes the first row of the next page).
|
|
25
|
+
* 2. The header rows (`isHeader: true`) must repeat on every
|
|
26
|
+
* continuation page. The caller passes a
|
|
27
|
+
* `repeatedHeaderHeightTwips` reservation so `findTableRowSplit`
|
|
28
|
+
* accounts for the repeat on subsequent pages.
|
|
29
|
+
*
|
|
30
|
+
* P6.c will wire these helpers into `paginateSectionBlocksWithSplits`
|
|
31
|
+
* so a table overflowing a page emits a `TableRowSlice` pair
|
|
32
|
+
* (rows-on-prev-page, rows-on-next-page) and the node view prepends
|
|
33
|
+
* the header rows on continuation pages. The pure helpers here ship
|
|
34
|
+
* ahead of the wiring so the math is testable in isolation; the
|
|
35
|
+
* pagination engine stays byte-for-byte compatible until P6.c lands.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
|
|
39
|
+
import type { SurfaceBlockSnapshot } from "../../api/public-types";
|
|
40
|
+
|
|
41
|
+
// Re-export the resolveCellWidth helper from paginated-layout-engine so the
|
|
42
|
+
// math stays single-sourced. The engine module exposes it via the
|
|
43
|
+
// `__resolveCellWidth` test alias already.
|
|
44
|
+
import { __resolveCellWidth } from "./paginated-layout-engine.ts";
|
|
45
|
+
|
|
46
|
+
const MIN_ROW_HEIGHT_TWIPS = 240;
|
|
47
|
+
const TABLE_ROW_PADDING_TWIPS = 120;
|
|
48
|
+
|
|
49
|
+
export interface MeasureTableRowHeightsInput {
|
|
50
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
|
|
51
|
+
columnWidth: number;
|
|
52
|
+
measurementProvider?: LayoutMeasurementProvider;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Per-row twip heights matching the engine's existing
|
|
57
|
+
* `measureTableHeight` walk. Returns an array of length
|
|
58
|
+
* `block.rows.length`; rows with `heightRule === "exact"` are reported
|
|
59
|
+
* at their explicit height, `"atLeast"` at `max(explicit, content)`,
|
|
60
|
+
* and `"auto"` at content height clamped to `MIN_ROW_HEIGHT_TWIPS`.
|
|
61
|
+
*
|
|
62
|
+
* `verticalMerge: "continue"` cells contribute 0 height (the origin
|
|
63
|
+
* cell's height covers the chain), matching pagination semantics.
|
|
64
|
+
*/
|
|
65
|
+
export function measureTableRowHeights(
|
|
66
|
+
input: MeasureTableRowHeightsInput,
|
|
67
|
+
): number[] {
|
|
68
|
+
const { block, columnWidth, measurementProvider } = input;
|
|
69
|
+
const heights: number[] = [];
|
|
70
|
+
|
|
71
|
+
const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
|
|
72
|
+
const gridScale =
|
|
73
|
+
totalGridTwips > 0 && columnWidth > 0 ? columnWidth / totalGridTwips : 1;
|
|
74
|
+
|
|
75
|
+
for (const row of block.rows) {
|
|
76
|
+
const explicitHeight = row.height ?? 0;
|
|
77
|
+
const heightRule = row.heightRule ?? "auto";
|
|
78
|
+
const gridBefore = row.gridBefore ?? 0;
|
|
79
|
+
|
|
80
|
+
let contentHeight = MIN_ROW_HEIGHT_TWIPS;
|
|
81
|
+
let columnCursor = gridBefore;
|
|
82
|
+
|
|
83
|
+
for (const cell of row.cells) {
|
|
84
|
+
const span = Math.max(1, cell.colspan ?? 1);
|
|
85
|
+
const cellWidth = __resolveCellWidth(
|
|
86
|
+
block.gridColumns,
|
|
87
|
+
columnCursor,
|
|
88
|
+
span,
|
|
89
|
+
columnWidth,
|
|
90
|
+
gridScale,
|
|
91
|
+
);
|
|
92
|
+
columnCursor += span;
|
|
93
|
+
|
|
94
|
+
if (cell.verticalMerge === "continue") continue;
|
|
95
|
+
|
|
96
|
+
let cellContentHeight = 0;
|
|
97
|
+
for (const child of cell.content) {
|
|
98
|
+
if (child.kind === "paragraph") {
|
|
99
|
+
cellContentHeight += measureParagraphStandaloneHeight(
|
|
100
|
+
child,
|
|
101
|
+
cellWidth,
|
|
102
|
+
measurementProvider,
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
cellContentHeight += MIN_ROW_HEIGHT_TWIPS;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let rowHeight: number;
|
|
112
|
+
if (heightRule === "exact" && explicitHeight > 0) {
|
|
113
|
+
rowHeight = explicitHeight;
|
|
114
|
+
} else if (heightRule === "atLeast" && explicitHeight > 0) {
|
|
115
|
+
rowHeight = Math.max(explicitHeight, contentHeight);
|
|
116
|
+
} else if (explicitHeight > 0) {
|
|
117
|
+
rowHeight = Math.max(explicitHeight, contentHeight);
|
|
118
|
+
} else {
|
|
119
|
+
rowHeight = contentHeight;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
heights.push(Math.max(MIN_ROW_HEIGHT_TWIPS, rowHeight));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return heights;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Lightweight paragraph height estimate used when walking table cell
|
|
130
|
+
* content. Matches the engine's internal measureTableHeight math
|
|
131
|
+
* (MIN_ROW_HEIGHT_TWIPS per paragraph) rather than delegating back to
|
|
132
|
+
* `measureBlockHeight`, which would require threading the
|
|
133
|
+
* per-invocation cache through. Since cell content already re-runs
|
|
134
|
+
* through `measureBlockHeight` on the main pagination path, this
|
|
135
|
+
* helper is only used by `measureTableRowHeights` for the split-math
|
|
136
|
+
* preflight and does not affect canonical pagination.
|
|
137
|
+
*/
|
|
138
|
+
function measureParagraphStandaloneHeight(
|
|
139
|
+
_block: SurfaceBlockSnapshot,
|
|
140
|
+
_columnWidth: number,
|
|
141
|
+
_provider: LayoutMeasurementProvider | undefined,
|
|
142
|
+
): number {
|
|
143
|
+
// P6.b scope: the engine's existing table measurement treats every
|
|
144
|
+
// cell paragraph as MIN_ROW_HEIGHT_TWIPS (see `measureTableHeight`
|
|
145
|
+
// inner loop). P6.b preserves that exact constant so the split
|
|
146
|
+
// math agrees with pagination. P6.c can upgrade this to call
|
|
147
|
+
// `measureBlockHeight` via the cache once the cache is threaded in.
|
|
148
|
+
return MIN_ROW_HEIGHT_TWIPS;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FindTableRowSplitInput {
|
|
152
|
+
/** Per-row twip heights from `measureTableRowHeights`. */
|
|
153
|
+
rowHeights: readonly number[];
|
|
154
|
+
/** Parallel vector: true when row `k` has `w:cantSplit`. */
|
|
155
|
+
cantSplitFlags: readonly boolean[];
|
|
156
|
+
/** Parallel vector: true when row `k` has `w:tblHeader`. */
|
|
157
|
+
isHeaderFlags: readonly boolean[];
|
|
158
|
+
/** Twip space available on the current page before the table begins. */
|
|
159
|
+
remainingHeightTwips: number;
|
|
160
|
+
/**
|
|
161
|
+
* Twip height reserved on **continuation** pages for repeated header
|
|
162
|
+
* rows. The engine computes this as the sum of row heights for every
|
|
163
|
+
* row flagged `isHeader` at indices 0..startRow-1. P6.b accepts the
|
|
164
|
+
* value rather than recomputing it so the helper stays pure.
|
|
165
|
+
*/
|
|
166
|
+
repeatedHeaderHeightTwips: number;
|
|
167
|
+
/**
|
|
168
|
+
* 0-based index of the first row to consider. When a table has
|
|
169
|
+
* already been sliced onto the previous page, the caller passes the
|
|
170
|
+
* index of the first uncommitted row so split math resumes from the
|
|
171
|
+
* right place. Defaults to 0.
|
|
172
|
+
*/
|
|
173
|
+
startRow?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface TableRowSplitDecision {
|
|
177
|
+
/**
|
|
178
|
+
* Number of rows from `startRow` that fit on the current page. Zero
|
|
179
|
+
* when no row fits (caller should push the whole tail to the next
|
|
180
|
+
* page without splitting mid-table).
|
|
181
|
+
*/
|
|
182
|
+
rowsOnCurrentPage: number;
|
|
183
|
+
/**
|
|
184
|
+
* Absolute index (not relative to `startRow`) of the row that begins
|
|
185
|
+
* the continuation page. Equals `startRow + rowsOnCurrentPage`.
|
|
186
|
+
* When `rowsOnCurrentPage === 0`, equals `startRow`.
|
|
187
|
+
*/
|
|
188
|
+
splitRowIndex: number;
|
|
189
|
+
/**
|
|
190
|
+
* Whether a continuation page is needed — equivalent to
|
|
191
|
+
* `splitRowIndex < rowHeights.length`. Returned for convenience so
|
|
192
|
+
* callers can branch without recomputing.
|
|
193
|
+
*/
|
|
194
|
+
continuationRequired: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Walk rows greedily from `startRow` onward, accumulating heights
|
|
199
|
+
* until the next row would exceed `remainingHeightTwips`. Adjusts the
|
|
200
|
+
* split point backward past any `cantSplit` row that would straddle
|
|
201
|
+
* the boundary (so row K is never half on page N and half on page
|
|
202
|
+
* N+1 when K is marked cantSplit).
|
|
203
|
+
*
|
|
204
|
+
* Pure — no DOM, no state. Deterministic.
|
|
205
|
+
*/
|
|
206
|
+
export function findTableRowSplit(
|
|
207
|
+
input: FindTableRowSplitInput,
|
|
208
|
+
): TableRowSplitDecision {
|
|
209
|
+
const {
|
|
210
|
+
rowHeights,
|
|
211
|
+
cantSplitFlags,
|
|
212
|
+
isHeaderFlags,
|
|
213
|
+
remainingHeightTwips,
|
|
214
|
+
repeatedHeaderHeightTwips,
|
|
215
|
+
} = input;
|
|
216
|
+
const startRow = input.startRow ?? 0;
|
|
217
|
+
const totalRows = rowHeights.length;
|
|
218
|
+
|
|
219
|
+
if (startRow >= totalRows) {
|
|
220
|
+
return {
|
|
221
|
+
rowsOnCurrentPage: 0,
|
|
222
|
+
splitRowIndex: totalRows,
|
|
223
|
+
continuationRequired: false,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (remainingHeightTwips <= 0) {
|
|
227
|
+
return {
|
|
228
|
+
rowsOnCurrentPage: 0,
|
|
229
|
+
splitRowIndex: startRow,
|
|
230
|
+
continuationRequired: startRow < totalRows,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// When resuming from a continuation, the header rows are repeated at
|
|
235
|
+
// the top so they consume the same space BEFORE we start fitting
|
|
236
|
+
// body rows. `startRow === 0` means this is the first page and no
|
|
237
|
+
// headers are repeated yet.
|
|
238
|
+
const headerReservation = startRow > 0 ? repeatedHeaderHeightTwips : 0;
|
|
239
|
+
|
|
240
|
+
let consumed = headerReservation;
|
|
241
|
+
let candidate = startRow;
|
|
242
|
+
for (let k = startRow; k < totalRows; k += 1) {
|
|
243
|
+
const rowHeight = rowHeights[k] ?? MIN_ROW_HEIGHT_TWIPS;
|
|
244
|
+
if (consumed + rowHeight > remainingHeightTwips) {
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
consumed += rowHeight;
|
|
248
|
+
candidate = k + 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Honor cantSplit: if the row *before* the split boundary is marked
|
|
252
|
+
// cantSplit, the split index must move back past the entire
|
|
253
|
+
// contiguous run of cantSplit rows ending at `candidate - 1`. The
|
|
254
|
+
// visible effect: a cantSplit row never lands as the last row of a
|
|
255
|
+
// page when the NEXT row overflows (because keeping the row together
|
|
256
|
+
// with the content it should stay with is the whole point of
|
|
257
|
+
// cantSplit).
|
|
258
|
+
//
|
|
259
|
+
// Edge case: if the FIRST row in [startRow..totalRows) is cantSplit
|
|
260
|
+
// and doesn't fit, we can't split at all — return rowsOnCurrentPage
|
|
261
|
+
// = 0 so the caller pushes a clean page break.
|
|
262
|
+
if (candidate > startRow && candidate < totalRows) {
|
|
263
|
+
while (candidate > startRow && cantSplitFlags[candidate - 1] === true) {
|
|
264
|
+
candidate -= 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// If candidate collapsed back to startRow due to cantSplit, check
|
|
269
|
+
// whether the first row itself fits. If not, rowsOnCurrentPage = 0
|
|
270
|
+
// → push whole tail to next page. If yes, include it.
|
|
271
|
+
if (candidate === startRow) {
|
|
272
|
+
return {
|
|
273
|
+
rowsOnCurrentPage: 0,
|
|
274
|
+
splitRowIndex: startRow,
|
|
275
|
+
continuationRequired: startRow < totalRows,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
rowsOnCurrentPage: candidate - startRow,
|
|
281
|
+
splitRowIndex: candidate,
|
|
282
|
+
continuationRequired: candidate < totalRows,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Convenience: compute the repeated-header-row height sum for a table
|
|
288
|
+
* given the per-row heights and the header flags. This is the value
|
|
289
|
+
* the caller feeds into `findTableRowSplit.repeatedHeaderHeightTwips`.
|
|
290
|
+
*/
|
|
291
|
+
export function computeRepeatedHeaderHeight(
|
|
292
|
+
rowHeights: readonly number[],
|
|
293
|
+
isHeaderFlags: readonly boolean[],
|
|
294
|
+
): number {
|
|
295
|
+
let total = 0;
|
|
296
|
+
for (let k = 0; k < isHeaderFlags.length; k += 1) {
|
|
297
|
+
if (isHeaderFlags[k] === true) {
|
|
298
|
+
total += rowHeights[k] ?? 0;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return total;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Extract parallel `cantSplitFlags` + `isHeaderFlags` vectors from a
|
|
306
|
+
* surface table block. Small helper so tests + callers can feed the
|
|
307
|
+
* same representation into `findTableRowSplit` without re-walking the
|
|
308
|
+
* block.
|
|
309
|
+
*/
|
|
310
|
+
export function extractRowFlags(
|
|
311
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
|
|
312
|
+
): { cantSplitFlags: boolean[]; isHeaderFlags: boolean[] } {
|
|
313
|
+
const cantSplitFlags = block.rows.map((r) => r.cantSplit === true);
|
|
314
|
+
const isHeaderFlags = block.rows.map((r) => r.isHeader === true);
|
|
315
|
+
return { cantSplitFlags, isHeaderFlags };
|
|
316
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export interface SanitizeResult {
|
|
2
|
+
text: string;
|
|
3
|
+
sanitized: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimum-viable sanitizer for the bw:commentPresentation markdown subset.
|
|
8
|
+
*
|
|
9
|
+
* Rejects:
|
|
10
|
+
* - Raw HTML tags (`<script>`, `<img onerror=...>`, …).
|
|
11
|
+
* - Inline-image markdown pointing at anything other than `bw:attachment:` —
|
|
12
|
+
* external image URLs must go through the attachment table so the reader
|
|
13
|
+
* can render them against a known relationship.
|
|
14
|
+
* - Autolinks / link destinations using schemes other than `http:`, `https:`,
|
|
15
|
+
* `mailto:`, `bw:user:`, `bw:attachment:`.
|
|
16
|
+
*
|
|
17
|
+
* Preserves the rest of the text verbatim so the digest remains stable after
|
|
18
|
+
* one sanitize pass. The goal is tamper-evident rendering, not a full
|
|
19
|
+
* CommonMark parser — callers MUST still run a proper renderer with the same
|
|
20
|
+
* whitelist when displaying the text.
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizeMarkdown(raw: string): SanitizeResult {
|
|
23
|
+
let sanitized = false;
|
|
24
|
+
let text = raw;
|
|
25
|
+
|
|
26
|
+
// Autolinks (`<javascript:alert(1)>`, `<https://x>`, `<a@b.com>`) must be
|
|
27
|
+
// processed BEFORE the HTML-tag strip, otherwise the HTML regex eats the
|
|
28
|
+
// angle-bracket form as a pseudo-tag and the scheme check never runs.
|
|
29
|
+
text = text.replace(
|
|
30
|
+
/<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g,
|
|
31
|
+
(_match, target: string) => {
|
|
32
|
+
if (isAllowedLinkTarget(target)) {
|
|
33
|
+
return `<${target}>`;
|
|
34
|
+
}
|
|
35
|
+
sanitized = true;
|
|
36
|
+
// Wrap in backticks so downstream renderers treat it as inline code
|
|
37
|
+
// instead of a clickable link. Keeping the literal text stabilizes
|
|
38
|
+
// the digest across sanitizer revisions.
|
|
39
|
+
return `\`${target}\``;
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
// Email autolinks (no URI scheme) — accept as-is so the HTML strip
|
|
43
|
+
// below treats the angle-bracket form as ordinary prose and we do not
|
|
44
|
+
// flag sanitized unnecessarily.
|
|
45
|
+
text = text.replace(
|
|
46
|
+
/<([A-Za-z0-9][A-Za-z0-9._%+-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g,
|
|
47
|
+
(_match, addr: string) => `<${addr}>`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const strippedHtml = text.replace(
|
|
51
|
+
// Require a recognisable HTML tag name so we do not strip safe
|
|
52
|
+
// autolinks that survived the pass above (email autolinks, bare URIs).
|
|
53
|
+
/<\/?([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/?>/g,
|
|
54
|
+
(match, tagName: string) => {
|
|
55
|
+
// Skip angle-bracket content that looks like an autolink we just
|
|
56
|
+
// approved (email or scheme URI).
|
|
57
|
+
if (match.includes("://") || match.includes("@") || match.includes(":")) {
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
// Also skip plain URIs like `<example.com>` — rare, but preserve.
|
|
61
|
+
if (/^[a-z]+\.[a-z]/.test(tagName)) {
|
|
62
|
+
return match;
|
|
63
|
+
}
|
|
64
|
+
sanitized = true;
|
|
65
|
+
return "";
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
text = strippedHtml;
|
|
69
|
+
|
|
70
|
+
text = text.replace(
|
|
71
|
+
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
|
72
|
+
(_match, alt: string, target: string) => {
|
|
73
|
+
if (target.startsWith("bw:attachment:")) {
|
|
74
|
+
return ``;
|
|
75
|
+
}
|
|
76
|
+
sanitized = true;
|
|
77
|
+
return `[${alt}]`;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
text = text.replace(
|
|
82
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
83
|
+
(_match, label: string, target: string) => {
|
|
84
|
+
if (isAllowedLinkTarget(target)) {
|
|
85
|
+
return `[${label}](${target})`;
|
|
86
|
+
}
|
|
87
|
+
sanitized = true;
|
|
88
|
+
return label;
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Reference-style link definitions: `[label]: target "optional title"`
|
|
93
|
+
// Renderers resolve `[foo][label]` against this table at render time,
|
|
94
|
+
// so a `[x]: javascript:alert(1)` bypasses the inline-link pass above.
|
|
95
|
+
// Strip definitions whose target is not in the scheme allowlist.
|
|
96
|
+
text = text.replace(
|
|
97
|
+
/^[ \t]*\[([^\]\n]+)\]:[ \t]*(\S+)(?:[ \t]+["(][^"\n)]*[")])?[ \t]*$/gm,
|
|
98
|
+
(match, _label: string, target: string) => {
|
|
99
|
+
if (isAllowedLinkTarget(target)) {
|
|
100
|
+
return match;
|
|
101
|
+
}
|
|
102
|
+
sanitized = true;
|
|
103
|
+
return "";
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return { text, sanitized };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isAllowedLinkTarget(target: string): boolean {
|
|
111
|
+
return (
|
|
112
|
+
target.startsWith("http://") ||
|
|
113
|
+
target.startsWith("https://") ||
|
|
114
|
+
target.startsWith("mailto:") ||
|
|
115
|
+
target.startsWith("bw:user:") ||
|
|
116
|
+
target.startsWith("bw:attachment:")
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Synchronous SHA-256 hex digest used by `CommentBody.digest`. Uses the Node
|
|
122
|
+
* crypto module so the pipeline can hash during import/export without pulling
|
|
123
|
+
* in async. Callers that prefer WebCrypto can build their own.
|
|
124
|
+
*/
|
|
125
|
+
export async function sha256Hex(text: string): Promise<string> {
|
|
126
|
+
const { subtle } = globalThis.crypto;
|
|
127
|
+
const bytes = new TextEncoder().encode(text);
|
|
128
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
129
|
+
return Array.from(new Uint8Array(digest))
|
|
130
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
131
|
+
.join("");
|
|
132
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Participant,
|
|
5
|
+
ParticipantRoster,
|
|
6
|
+
} from "../api/participants-types.ts";
|
|
7
|
+
|
|
8
|
+
const MAP_KEY = "participants";
|
|
9
|
+
|
|
10
|
+
export interface ParticipantRosterStore {
|
|
11
|
+
get(userId: string): Participant | undefined;
|
|
12
|
+
/** Returns a synthetic "Unknown" row when the userId is not in the roster. */
|
|
13
|
+
resolve(userId: string): Participant;
|
|
14
|
+
upsert(entry: Participant): Participant;
|
|
15
|
+
remove(userId: string): void;
|
|
16
|
+
all(): Participant[];
|
|
17
|
+
snapshot(): ParticipantRoster;
|
|
18
|
+
subscribe(fn: (changedIds: string[]) => void): () => void;
|
|
19
|
+
ingestRemote(entry: Participant): void;
|
|
20
|
+
destroy(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createParticipantRoster(ydoc: Y.Doc): ParticipantRosterStore {
|
|
24
|
+
const yMap = ydoc.getMap<Participant>(MAP_KEY);
|
|
25
|
+
const listeners = new Set<(ids: string[]) => void>();
|
|
26
|
+
|
|
27
|
+
const onChange = (event: Y.YMapEvent<Participant>): void => {
|
|
28
|
+
if (listeners.size === 0) return;
|
|
29
|
+
const ids = Array.from(event.keysChanged);
|
|
30
|
+
for (const fn of listeners) fn(ids);
|
|
31
|
+
};
|
|
32
|
+
yMap.observe(onChange);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
get: (id) => cloneMaybe(yMap.get(id)),
|
|
36
|
+
resolve: (id) => yMap.get(id)
|
|
37
|
+
? clone(yMap.get(id)!)
|
|
38
|
+
: syntheticUnknown(id),
|
|
39
|
+
upsert: (entry) => {
|
|
40
|
+
const prev = yMap.get(entry.userId);
|
|
41
|
+
const normalized = normalize(entry);
|
|
42
|
+
const next: Participant = prev
|
|
43
|
+
? { ...prev, ...normalized }
|
|
44
|
+
: normalized;
|
|
45
|
+
validate(next);
|
|
46
|
+
yMap.set(entry.userId, next);
|
|
47
|
+
return clone(next);
|
|
48
|
+
},
|
|
49
|
+
remove: (id) => {
|
|
50
|
+
yMap.delete(id);
|
|
51
|
+
},
|
|
52
|
+
all: () => Array.from(yMap.values()).map(clone),
|
|
53
|
+
snapshot: () => ({
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
entries: Array.from(yMap.values()).map(clone),
|
|
56
|
+
}),
|
|
57
|
+
subscribe: (fn) => {
|
|
58
|
+
listeners.add(fn);
|
|
59
|
+
return () => {
|
|
60
|
+
listeners.delete(fn);
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
ingestRemote: (entry) => {
|
|
64
|
+
validate(entry);
|
|
65
|
+
yMap.set(entry.userId, clone(normalize(entry)));
|
|
66
|
+
},
|
|
67
|
+
destroy: () => {
|
|
68
|
+
yMap.unobserve(onChange);
|
|
69
|
+
listeners.clear();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalize(p: Participant): Participant {
|
|
75
|
+
const next: Participant = {
|
|
76
|
+
userId: p.userId,
|
|
77
|
+
email: p.email.toLowerCase(),
|
|
78
|
+
displayName: p.displayName,
|
|
79
|
+
collabIdentity: p.collabIdentity,
|
|
80
|
+
authorKind: p.authorKind,
|
|
81
|
+
};
|
|
82
|
+
if (p.role !== undefined) next.role = p.role;
|
|
83
|
+
if (p.organization !== undefined) next.organization = p.organization;
|
|
84
|
+
if (p.avatarHref !== undefined) {
|
|
85
|
+
if (p.avatarHref.startsWith("https://")) {
|
|
86
|
+
next.avatarHref = p.avatarHref;
|
|
87
|
+
}
|
|
88
|
+
// silently drop non-https: schemes per schema rule
|
|
89
|
+
}
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validate(p: Participant): void {
|
|
94
|
+
if (!p.userId) {
|
|
95
|
+
throw new Error("participant.userId is required");
|
|
96
|
+
}
|
|
97
|
+
if (!p.email) {
|
|
98
|
+
throw new Error(`participant.email is required (userId=${p.userId})`);
|
|
99
|
+
}
|
|
100
|
+
if (!p.displayName) {
|
|
101
|
+
throw new Error(`participant.displayName is required (userId=${p.userId})`);
|
|
102
|
+
}
|
|
103
|
+
if (!p.collabIdentity) {
|
|
104
|
+
throw new Error(`participant.collabIdentity is required (userId=${p.userId})`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clone(p: Participant): Participant {
|
|
109
|
+
const copy: Participant = {
|
|
110
|
+
userId: p.userId,
|
|
111
|
+
email: p.email,
|
|
112
|
+
displayName: p.displayName,
|
|
113
|
+
collabIdentity: p.collabIdentity,
|
|
114
|
+
authorKind: p.authorKind,
|
|
115
|
+
};
|
|
116
|
+
if (p.role !== undefined) copy.role = p.role;
|
|
117
|
+
if (p.organization !== undefined) copy.organization = p.organization;
|
|
118
|
+
if (p.avatarHref !== undefined) copy.avatarHref = p.avatarHref;
|
|
119
|
+
return copy;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cloneMaybe(p: Participant | undefined): Participant | undefined {
|
|
123
|
+
return p ? clone(p) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function syntheticUnknown(userId: string): Participant {
|
|
127
|
+
return {
|
|
128
|
+
userId,
|
|
129
|
+
email: "",
|
|
130
|
+
displayName: "Unknown",
|
|
131
|
+
collabIdentity: "",
|
|
132
|
+
authorKind: "human",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight string-keyed counters for L7 render-perf instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* Used by the runtime to prove (in tests and benchmarks) that a given
|
|
5
|
+
* facet rebuild ran or did not run during a sequence of operations.
|
|
6
|
+
* Phase 0 wires `refresh.all`; Phase 1 will add `facet.<name>.build`
|
|
7
|
+
* keyed on the per-facet builder.
|
|
8
|
+
*
|
|
9
|
+
* Cost guarantee: a `Map<string, number>` increment is < 100 ns. We do
|
|
10
|
+
* not need atomic counters — JS is single-threaded — and we deliberately
|
|
11
|
+
* keep the surface tiny so this module can never become a perf footgun
|
|
12
|
+
* on its own.
|
|
13
|
+
*/
|
|
14
|
+
export class PerfCounters {
|
|
15
|
+
private readonly counts = new Map<string, number>();
|
|
16
|
+
|
|
17
|
+
increment(key: string, delta = 1): void {
|
|
18
|
+
this.counts.set(key, (this.counts.get(key) ?? 0) + delta);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
snapshot(): Record<string, number> {
|
|
22
|
+
return Object.fromEntries(this.counts);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
reset(): void {
|
|
26
|
+
this.counts.clear();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -56,6 +56,23 @@ export interface RenderPageRegions {
|
|
|
56
56
|
footer?: RenderStoryRegion;
|
|
57
57
|
columns?: readonly RenderStoryRegion[];
|
|
58
58
|
footnoteArea?: RenderStoryRegion;
|
|
59
|
+
/**
|
|
60
|
+
* P8.3 — footnote areas reserved at the bottom of the page (above the
|
|
61
|
+
* footer band). Mirrors the page graph's `RuntimePageRegions.footnotes`
|
|
62
|
+
* — one entry per allocated region (typically one per page today, but
|
|
63
|
+
* shape allows for multiple should allocation-splitting land).
|
|
64
|
+
* Populated only when `PublicPageRegions.footnotes` is non-empty.
|
|
65
|
+
* Additive — back-compat safe; consumers that ignore this field see no
|
|
66
|
+
* change.
|
|
67
|
+
*/
|
|
68
|
+
footnotes?: readonly RenderStoryRegion[];
|
|
69
|
+
/**
|
|
70
|
+
* P8.3 — endnote regions. Reserved seam; per-page endnote projection is
|
|
71
|
+
* NOT populated today (endnotes use document-end placement via
|
|
72
|
+
* `facet.getDocumentEndnoteBlocks()`). Shape exists so a future
|
|
73
|
+
* per-section endnote renderer has a stable read surface.
|
|
74
|
+
*/
|
|
75
|
+
endnotes?: readonly RenderStoryRegion[];
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
export interface RenderStoryRegion {
|