@beyondwork/docx-react-component 1.0.41 → 1.0.42
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 +13 -1
- 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/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- 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 +568 -1
- package/src/index.ts +118 -1
- 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/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 +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -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 +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- 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/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- 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/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 +58 -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/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 +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- 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 +293 -34
|
@@ -77,6 +77,7 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
|
|
|
77
77
|
const DEFAULT_RAIL_LANE_PX = 44;
|
|
78
78
|
const STRIPE_WIDTH_PX = 4;
|
|
79
79
|
const LABEL_WIDTH_PX = 40;
|
|
80
|
+
const STACK_OFFSET_PX = 6;
|
|
80
81
|
|
|
81
82
|
export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
82
83
|
facet,
|
|
@@ -96,6 +97,27 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
96
97
|
return null;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// P2: which scopes currently have a `source: "ai"` candidate
|
|
101
|
+
// overlapping — drives the agent-pending shimmer class on their
|
|
102
|
+
// tints. Reads from the facet's card-model projection so the
|
|
103
|
+
// shimmer logic lives in the runtime, not the overlay.
|
|
104
|
+
const cardModels =
|
|
105
|
+
typeof facet.getAllScopeCardModels === "function"
|
|
106
|
+
? facet.getAllScopeCardModels()
|
|
107
|
+
: [];
|
|
108
|
+
const agentPendingByScope = new Map<string, boolean>();
|
|
109
|
+
for (const model of cardModels) {
|
|
110
|
+
if (model.agentPending) {
|
|
111
|
+
agentPendingByScope.set(model.scopeId, true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// P3c: stack offsets for overlapping scopes. Two scopes whose
|
|
116
|
+
// offset ranges intersect on the same page render as stacked
|
|
117
|
+
// stripes in the gutter; the inner stripe shifts STACK_OFFSET_PX
|
|
118
|
+
// further right per overlap count so all are clickable.
|
|
119
|
+
const stackIndexByScope = computeStackIndices(segments);
|
|
120
|
+
|
|
99
121
|
const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
|
|
100
122
|
|
|
101
123
|
return (
|
|
@@ -121,14 +143,20 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
121
143
|
const stripeTopPx = firstLine.topPx;
|
|
122
144
|
const stripeHeightPx =
|
|
123
145
|
lastLine.topPx + lastLine.heightPx - firstLine.topPx;
|
|
146
|
+
const stackIndex = stackIndexByScope.get(segment.scopeId) ?? 0;
|
|
147
|
+
const stackOffset = stackIndex * STACK_OFFSET_PX;
|
|
124
148
|
const stripeRect: RenderFrameRect = {
|
|
125
|
-
leftPx:
|
|
149
|
+
leftPx:
|
|
150
|
+
firstLine.leftPx
|
|
151
|
+
- railLaneWidthPx
|
|
152
|
+
+ (railLaneWidthPx - STRIPE_WIDTH_PX) / 2
|
|
153
|
+
+ stackOffset,
|
|
126
154
|
topPx: stripeTopPx,
|
|
127
155
|
widthPx: STRIPE_WIDTH_PX,
|
|
128
156
|
heightPx: Math.max(stripeHeightPx, 14),
|
|
129
157
|
};
|
|
130
158
|
const labelRect: RenderFrameRect = {
|
|
131
|
-
leftPx: firstLine.leftPx - railLaneWidthPx,
|
|
159
|
+
leftPx: firstLine.leftPx - railLaneWidthPx + stackOffset,
|
|
132
160
|
topPx: stripeTopPx,
|
|
133
161
|
widthPx: LABEL_WIDTH_PX,
|
|
134
162
|
heightPx: 20,
|
|
@@ -148,18 +176,28 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
148
176
|
return (
|
|
149
177
|
<React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
|
|
150
178
|
{/* Per-line tint behind the scoped text runs. */}
|
|
151
|
-
{lineRects.map((lineRect, index) =>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
179
|
+
{lineRects.map((lineRect, index) => {
|
|
180
|
+
const agentPending = agentPendingByScope.get(segment.scopeId) === true;
|
|
181
|
+
const tintClassList = [
|
|
182
|
+
"wre-scope-rail-tint",
|
|
183
|
+
`wre-scope-rail-tint-${style.tintToken}`,
|
|
184
|
+
];
|
|
185
|
+
if (isActive) tintClassList.push("wre-scope-rail-tint-active");
|
|
186
|
+
if (agentPending) {
|
|
187
|
+
tintClassList.push("wre-scope-rail-tint-agent-pending");
|
|
188
|
+
}
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
key={`tint:${index}`}
|
|
192
|
+
className={tintClassList.join(" ")}
|
|
193
|
+
data-scope-id={segment.scopeId}
|
|
194
|
+
data-posture={segment.posture}
|
|
195
|
+
data-line-index={index}
|
|
196
|
+
data-agent-pending={agentPending ? "true" : undefined}
|
|
197
|
+
style={projectRectToOverlay(lineRect, projectorSpace)}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
163
201
|
{/* Rail stripe in the gutter. */}
|
|
164
202
|
<button
|
|
165
203
|
type="button"
|
|
@@ -168,6 +206,7 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
168
206
|
}`}
|
|
169
207
|
data-scope-id={segment.scopeId}
|
|
170
208
|
data-posture={segment.posture}
|
|
209
|
+
data-stack-index={stackIndex}
|
|
171
210
|
data-testid="scope-rail-stripe"
|
|
172
211
|
aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
|
|
173
212
|
aria-expanded={isActive ? "true" : "false"}
|
|
@@ -246,4 +285,44 @@ function pushRectsFromPage(
|
|
|
246
285
|
}
|
|
247
286
|
}
|
|
248
287
|
|
|
288
|
+
/**
|
|
289
|
+
* P3c: Compute a stack index per scope so overlapping scopes render
|
|
290
|
+
* as offset stripes in the gutter instead of stacking on top of each
|
|
291
|
+
* other. For each scope's first segment, count earlier-document-
|
|
292
|
+
* order scopes whose offset range intersects on the same page — that
|
|
293
|
+
* count is the stack index (0 = outer / earliest, 1 = next, etc).
|
|
294
|
+
*
|
|
295
|
+
* Exported for unit testing.
|
|
296
|
+
*/
|
|
297
|
+
export function computeStackIndices(
|
|
298
|
+
segments: readonly ScopeRailSegment[],
|
|
299
|
+
): Map<string, number> {
|
|
300
|
+
const firstSegmentByScope = new Map<string, ScopeRailSegment>();
|
|
301
|
+
const scopeOrder: string[] = [];
|
|
302
|
+
for (const segment of segments) {
|
|
303
|
+
if (!firstSegmentByScope.has(segment.scopeId)) {
|
|
304
|
+
firstSegmentByScope.set(segment.scopeId, segment);
|
|
305
|
+
scopeOrder.push(segment.scopeId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const result = new Map<string, number>();
|
|
310
|
+
for (let i = 0; i < scopeOrder.length; i += 1) {
|
|
311
|
+
const scopeId = scopeOrder[i];
|
|
312
|
+
const seg = firstSegmentByScope.get(scopeId);
|
|
313
|
+
if (!seg) continue;
|
|
314
|
+
let overlapCount = 0;
|
|
315
|
+
for (let j = 0; j < i; j += 1) {
|
|
316
|
+
const other = firstSegmentByScope.get(scopeOrder[j]);
|
|
317
|
+
if (!other) continue;
|
|
318
|
+
if (other.pageIndex !== seg.pageIndex) continue;
|
|
319
|
+
if (other.toOffset > seg.fromOffset && other.fromOffset < seg.toOffset) {
|
|
320
|
+
overlapCount += 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
result.set(scopeId, overlapCount);
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
249
328
|
export default TwScopeRailLayer;
|
|
@@ -23,6 +23,39 @@ type RailDecorationSpec = {
|
|
|
23
23
|
attrs: Record<string, string>;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Validate and normalize a host-supplied CSS color before interpolating it
|
|
28
|
+
* into an inline-style string. Accepts only the narrow subset a
|
|
29
|
+
* host-issued metadata color would legitimately use:
|
|
30
|
+
* - `#rgb`, `#rrggbb`, `#rrggbbaa` hex literals
|
|
31
|
+
* - `rgb(...)` / `rgba(...)` with numeric/percent/alpha args, no nested
|
|
32
|
+
* comments or closing-paren escape tricks
|
|
33
|
+
* - named colors drawn from a short whitelist
|
|
34
|
+
* Any semicolon, brace, or parenthesis outside the matched shape is
|
|
35
|
+
* rejected. Returns `null` when the input is unsafe; callers must drop
|
|
36
|
+
* the style attribute entirely in that case.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeHostCssColor(raw: unknown): string | null {
|
|
39
|
+
if (typeof raw !== "string") return null;
|
|
40
|
+
const value = raw.trim();
|
|
41
|
+
if (value.length === 0 || value.length > 64) return null;
|
|
42
|
+
// Hex: #rgb / #rrggbb / #rrggbbaa
|
|
43
|
+
if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(value)) {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
// rgb()/rgba() with digits, dots, percent, commas and a single space.
|
|
47
|
+
if (/^rgba?\(\s*\d+(\.\d+)?%?(\s*,\s*\d+(\.\d+)?%?){2,3}\s*\)$/.test(value)) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
// Named colors — short whitelist that covers every host-chip use.
|
|
51
|
+
const named = new Set([
|
|
52
|
+
"black", "white", "red", "green", "blue", "yellow", "orange",
|
|
53
|
+
"purple", "pink", "brown", "gray", "grey", "transparent",
|
|
54
|
+
]);
|
|
55
|
+
if (named.has(value.toLowerCase())) return value.toLowerCase();
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
26
59
|
function isSelectionZoneScope(scope: WorkflowScope): boolean {
|
|
27
60
|
return (
|
|
28
61
|
scope.scopeId.startsWith("scope-lab-") ||
|
|
@@ -511,10 +544,11 @@ export function buildDecorations(
|
|
|
511
544
|
const pmRange = buildAnchorPmRange(metadata.anchor, positionMap);
|
|
512
545
|
if (!pmRange || !pmRange.allowInline || pmRange.from >= pmRange.to) continue;
|
|
513
546
|
|
|
547
|
+
const safeColor = sanitizeHostCssColor(metadata.color);
|
|
514
548
|
decorations.push(
|
|
515
549
|
Decoration.inline(pmRange.from, pmRange.to, {
|
|
516
550
|
class: getWorkflowMetadataInlineClass(),
|
|
517
|
-
...(
|
|
551
|
+
...(safeColor ? { style: `--wre-workflow-metadata-color: ${safeColor};` } : {}),
|
|
518
552
|
"data-workflow-entry-id": metadata.entryId,
|
|
519
553
|
"data-workflow-metadata-id": metadata.metadataId,
|
|
520
554
|
}),
|
|
@@ -178,7 +178,60 @@ interface ChromeWidgetInput {
|
|
|
178
178
|
nextHeaderPreview: string;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// P14.c — cache the widget DOM by input identity. PM rebuilds the
|
|
182
|
+
// page-break decorations on every commit (37 widgets per 38-page doc);
|
|
183
|
+
// each widget's DOM is ~10 elements. When the page IDs, labels, and
|
|
184
|
+
// preview text haven't changed (the common case during typing inside
|
|
185
|
+
// one page), returning the cached DOM lets PM's decoration
|
|
186
|
+
// reconciliation skip the remount entirely. Bounded LRU at 256 entries
|
|
187
|
+
// so a long session with significant content churn doesn't grow without
|
|
188
|
+
// bound.
|
|
189
|
+
const widgetDomCache = new Map<string, HTMLElement>();
|
|
190
|
+
const WIDGET_DOM_CACHE_LIMIT = 256;
|
|
191
|
+
|
|
192
|
+
function widgetCacheKey(input: ChromeWidgetInput): string {
|
|
193
|
+
return [
|
|
194
|
+
input.posture,
|
|
195
|
+
input.prevPageId,
|
|
196
|
+
input.nextPageId,
|
|
197
|
+
input.prevPageIndex,
|
|
198
|
+
input.nextPageIndex,
|
|
199
|
+
input.headerBandPx,
|
|
200
|
+
input.footerBandPx,
|
|
201
|
+
input.interGapPx,
|
|
202
|
+
input.prevPageLabel,
|
|
203
|
+
input.nextPageLabel,
|
|
204
|
+
input.hasPrevFooterStory ? "1" : "0",
|
|
205
|
+
input.hasNextHeaderStory ? "1" : "0",
|
|
206
|
+
input.prevFooterPreview,
|
|
207
|
+
input.nextHeaderPreview,
|
|
208
|
+
].join("\x1f");
|
|
209
|
+
}
|
|
210
|
+
|
|
181
211
|
function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
|
|
212
|
+
const key = widgetCacheKey(input);
|
|
213
|
+
const cached = widgetDomCache.get(key);
|
|
214
|
+
if (cached) {
|
|
215
|
+
// Touch LRU recency by re-inserting.
|
|
216
|
+
widgetDomCache.delete(key);
|
|
217
|
+
widgetDomCache.set(key, cached);
|
|
218
|
+
return cached;
|
|
219
|
+
}
|
|
220
|
+
const built = buildChromeWidgetDomUncached(input);
|
|
221
|
+
widgetDomCache.set(key, built);
|
|
222
|
+
if (widgetDomCache.size > WIDGET_DOM_CACHE_LIMIT) {
|
|
223
|
+
const oldest = widgetDomCache.keys().next().value;
|
|
224
|
+
if (oldest !== undefined) widgetDomCache.delete(oldest);
|
|
225
|
+
}
|
|
226
|
+
return built;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Test-only export — clears the cache between assertions. */
|
|
230
|
+
export function __resetPageBreakWidgetCache(): void {
|
|
231
|
+
widgetDomCache.clear();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
|
|
182
235
|
const root = document.createElement("div");
|
|
183
236
|
root.className = "wre-page-chrome-widget";
|
|
184
237
|
root.setAttribute("data-kind", "page-chrome-widget");
|
|
@@ -187,6 +240,12 @@ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
|
|
|
187
240
|
root.setAttribute("data-next-page-id", input.nextPageId);
|
|
188
241
|
root.setAttribute("data-prev-page-index", String(input.prevPageIndex));
|
|
189
242
|
root.setAttribute("data-next-page-index", String(input.nextPageIndex));
|
|
243
|
+
// P3.a: expose page-frame boundary markers so the page stack and tests
|
|
244
|
+
// can enumerate pages without re-walking the render graph. Each widget
|
|
245
|
+
// ends page N (`prev`) and starts page N+1 (`next`); the outer workspace
|
|
246
|
+
// frame still accounts for the boundaries at both ends of the document.
|
|
247
|
+
root.setAttribute("data-page-frame-end", input.prevPageId);
|
|
248
|
+
root.setAttribute("data-page-frame-start", input.nextPageId);
|
|
190
249
|
root.contentEditable = "false";
|
|
191
250
|
root.setAttribute("aria-hidden", "false");
|
|
192
251
|
root.style.display = "block";
|
|
@@ -245,16 +304,40 @@ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
|
|
|
245
304
|
});
|
|
246
305
|
root.appendChild(footer);
|
|
247
306
|
|
|
307
|
+
// P3.a: the inter-page gap is now a visible canvas strip that reads as
|
|
308
|
+
// "the space between two papers", not a subtle gradient inside one white
|
|
309
|
+
// page. The strip paints in the workspace canvas color (the same color
|
|
310
|
+
// the page frames float on in page mode), with subtle drop/rise shadows
|
|
311
|
+
// on either edge so the preceding footer reads as "bottom of page N's
|
|
312
|
+
// paper" and the following header reads as "top of page N+1's paper".
|
|
313
|
+
//
|
|
314
|
+
// The visual goal: bringing the user closer to a Word-native perception
|
|
315
|
+
// of distinct pages without requiring the PM editable tree to be split
|
|
316
|
+
// into per-page subtrees (that lands in P3.b).
|
|
248
317
|
const separator = document.createElement("div");
|
|
249
318
|
separator.className = "wre-page-chrome-separator";
|
|
319
|
+
separator.setAttribute("data-kind", "page-chrome-separator");
|
|
250
320
|
separator.style.position = "absolute";
|
|
251
321
|
separator.style.left = "0";
|
|
252
322
|
separator.style.right = "0";
|
|
253
323
|
separator.style.top = `${input.footerBandPx}px`;
|
|
254
324
|
separator.style.height = `${input.interGapPx}px`;
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
325
|
+
// Canvas color (same as the scroll root's bg-surface) so the strip reads
|
|
326
|
+
// as "gap between two papers". Page mode and canvas mode both use the
|
|
327
|
+
// same token so the UX remains consistent at any chrome preset.
|
|
328
|
+
separator.style.backgroundColor = "var(--color-surface, #f1f5f9)";
|
|
329
|
+
// Inner shadows on top/bottom: the previous footer's bottom edge gains
|
|
330
|
+
// a subtle paper-edge shadow, and the next header's top edge likewise.
|
|
331
|
+
// Inset shadows let us avoid touching the footer / header DOM while
|
|
332
|
+
// keeping the shadows flush with the band borders.
|
|
333
|
+
separator.style.boxShadow = [
|
|
334
|
+
// Top edge — simulates the bottom shadow of page N's paper.
|
|
335
|
+
"inset 0 1px 0 rgba(15, 23, 42, 0.06)",
|
|
336
|
+
"inset 0 2px 3px -2px rgba(15, 23, 42, 0.12)",
|
|
337
|
+
// Bottom edge — simulates the top shadow of page N+1's paper.
|
|
338
|
+
"inset 0 -1px 0 rgba(15, 23, 42, 0.06)",
|
|
339
|
+
"inset 0 -2px 3px -2px rgba(15, 23, 42, 0.12)",
|
|
340
|
+
].join(", ");
|
|
258
341
|
root.appendChild(separator);
|
|
259
342
|
|
|
260
343
|
const header = buildBand({
|
|
@@ -178,24 +178,24 @@ export const editorSchema = new Schema({
|
|
|
178
178
|
const spacingBefore = node.attrs.spacingBefore as number | null;
|
|
179
179
|
const contextualSpacingBefore = node.attrs.contextualSpacingBefore as boolean | null;
|
|
180
180
|
if (contextualSpacingBefore) styles.push("margin-top: 0");
|
|
181
|
-
else if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}
|
|
181
|
+
else if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}pt`);
|
|
182
182
|
const contextualSpacingAfter = node.attrs.contextualSpacingAfter as boolean | null;
|
|
183
183
|
const spacingAfter = node.attrs.spacingAfter as number | null;
|
|
184
184
|
if (contextualSpacingAfter) styles.push("margin-bottom: 0");
|
|
185
|
-
else if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}
|
|
185
|
+
else if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}pt`);
|
|
186
186
|
const lineSpacing = node.attrs.lineSpacing as number | null;
|
|
187
187
|
const lineRule = node.attrs.lineRule as string | null;
|
|
188
188
|
if (lineSpacing && lineRule === "auto") styles.push(`line-height: ${lineSpacing / 240}`);
|
|
189
|
-
else if (lineSpacing && lineRule === "exact") styles.push(`line-height: ${lineSpacing / 20}
|
|
190
|
-
else if (lineSpacing && lineRule === "atLeast") styles.push(`min-height: ${lineSpacing / 20}
|
|
189
|
+
else if (lineSpacing && lineRule === "exact") styles.push(`line-height: ${lineSpacing / 20}pt`);
|
|
190
|
+
else if (lineSpacing && lineRule === "atLeast") styles.push(`min-height: ${lineSpacing / 20}pt`);
|
|
191
191
|
const indentLeft = node.attrs.indentLeft as number | null;
|
|
192
|
-
if (indentLeft) styles.push(`padding-left: ${indentLeft / 20}
|
|
192
|
+
if (indentLeft) styles.push(`padding-left: ${indentLeft / 20}pt`);
|
|
193
193
|
const indentRight = node.attrs.indentRight as number | null;
|
|
194
|
-
if (indentRight) styles.push(`padding-right: ${indentRight / 20}
|
|
194
|
+
if (indentRight) styles.push(`padding-right: ${indentRight / 20}pt`);
|
|
195
195
|
const indentFirstLine = node.attrs.indentFirstLine as number | null;
|
|
196
196
|
const indentHanging = node.attrs.indentHanging as number | null;
|
|
197
|
-
if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}
|
|
198
|
-
else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}
|
|
197
|
+
if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}pt`);
|
|
198
|
+
else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}pt`);
|
|
199
199
|
const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
|
|
200
200
|
if (shadingColor) styles.push(`background-color: ${shadingColor}`);
|
|
201
201
|
for (const [side, attrName] of [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]] as const) {
|
|
@@ -309,11 +309,13 @@ export const editorSchema = new Schema({
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
if (hasResolvedMarkerWidth) {
|
|
312
|
-
|
|
312
|
+
// P13.a: emit marker geometry in pt (twips/20 == pt) so it
|
|
313
|
+
// self-scales under CSS `zoom` and matches Word's intent.
|
|
314
|
+
const markerWidthPt = Math.max(1, numberingMarkerWidth / 20);
|
|
313
315
|
prefixStyles.push(
|
|
314
|
-
`width: ${
|
|
315
|
-
`min-width: ${
|
|
316
|
-
`flex-basis: ${
|
|
316
|
+
`width: ${markerWidthPt}pt`,
|
|
317
|
+
`min-width: ${markerWidthPt}pt`,
|
|
318
|
+
`flex-basis: ${markerWidthPt}pt`,
|
|
317
319
|
`margin-right: 0`,
|
|
318
320
|
`overflow: visible`,
|
|
319
321
|
);
|
|
@@ -889,7 +891,7 @@ export const editorSchema = new Schema({
|
|
|
889
891
|
attrs: { value: { default: 0 } },
|
|
890
892
|
toDOM(mark) {
|
|
891
893
|
const twips = mark.attrs.value as number;
|
|
892
|
-
return ["span", { style: `letter-spacing: ${twips / 20}
|
|
894
|
+
return ["span", { style: `letter-spacing: ${twips / 20}pt` }, 0];
|
|
893
895
|
},
|
|
894
896
|
},
|
|
895
897
|
font_kerning: {
|
|
@@ -12,6 +12,23 @@ import {
|
|
|
12
12
|
type RemoteCursorState,
|
|
13
13
|
} from "../../runtime/collab/remote-cursor-awareness";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Remote `cursor.color` comes straight off Yjs Awareness — another peer
|
|
17
|
+
* can set it to anything. Validate to a narrow CSS-color allowlist
|
|
18
|
+
* before interpolating into an inline style, otherwise a malicious peer
|
|
19
|
+
* could inject arbitrary CSS declarations via `; background: url(...)`.
|
|
20
|
+
* Returns a fallback neutral color when the input is rejected.
|
|
21
|
+
*/
|
|
22
|
+
const SAFE_REMOTE_CURSOR_FALLBACK = "#6b7280";
|
|
23
|
+
function safeRemoteCursorColor(raw: unknown): string {
|
|
24
|
+
if (typeof raw !== "string") return SAFE_REMOTE_CURSOR_FALLBACK;
|
|
25
|
+
const value = raw.trim();
|
|
26
|
+
if (value.length === 0 || value.length > 32) return SAFE_REMOTE_CURSOR_FALLBACK;
|
|
27
|
+
if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(value)) return value;
|
|
28
|
+
if (/^rgba?\(\s*\d+(\.\d+)?%?(\s*,\s*\d+(\.\d+)?%?){2,3}\s*\)$/.test(value)) return value;
|
|
29
|
+
return SAFE_REMOTE_CURSOR_FALLBACK;
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
interface RemoteCursorPluginState {
|
|
16
33
|
cursors: RemoteCursorState[];
|
|
17
34
|
}
|
|
@@ -68,7 +85,7 @@ export function createRemoteCursorPlugin(
|
|
|
68
85
|
if (from !== to) {
|
|
69
86
|
decorations.push(
|
|
70
87
|
Decoration.inline(from, to, {
|
|
71
|
-
style: `background-color: ${cursor.color}33;`,
|
|
88
|
+
style: `background-color: ${safeRemoteCursorColor(cursor.color)}33;`,
|
|
72
89
|
class: "remote-cursor-selection",
|
|
73
90
|
}),
|
|
74
91
|
);
|
|
@@ -149,7 +166,7 @@ function createCursorWidget(cursor: RemoteCursorState): HTMLElement {
|
|
|
149
166
|
top: -0.2em;
|
|
150
167
|
width: 2px;
|
|
151
168
|
height: 1.2em;
|
|
152
|
-
background-color: ${cursor.color};
|
|
169
|
+
background-color: ${safeRemoteCursorColor(cursor.color)};
|
|
153
170
|
border-radius: 1px;
|
|
154
171
|
`;
|
|
155
172
|
container.appendChild(caret);
|
|
@@ -163,7 +180,7 @@ function createCursorWidget(cursor: RemoteCursorState): HTMLElement {
|
|
|
163
180
|
font-family: system-ui, -apple-system, sans-serif;
|
|
164
181
|
font-weight: 500;
|
|
165
182
|
color: white;
|
|
166
|
-
background-color: ${cursor.color};
|
|
183
|
+
background-color: ${safeRemoteCursorColor(cursor.color)};
|
|
167
184
|
padding: 1px 4px;
|
|
168
185
|
border-radius: 3px;
|
|
169
186
|
white-space: nowrap;
|
|
@@ -91,17 +91,17 @@ function buildParagraphStyle(
|
|
|
91
91
|
block.spacing?.lineRule ?? block.resolvedParagraphFormatting?.spacing?.lineRule;
|
|
92
92
|
|
|
93
93
|
if (spacingBefore != null) {
|
|
94
|
-
style.marginTop = `${spacingBefore / 20}
|
|
94
|
+
style.marginTop = `${spacingBefore / 20}pt`;
|
|
95
95
|
}
|
|
96
96
|
if (spacingAfter != null) {
|
|
97
|
-
style.marginBottom = `${spacingAfter / 20}
|
|
97
|
+
style.marginBottom = `${spacingAfter / 20}pt`;
|
|
98
98
|
}
|
|
99
99
|
if (lineSpacing && lineRule === "auto") {
|
|
100
100
|
style.lineHeight = String(lineSpacing / 240);
|
|
101
101
|
} else if (lineSpacing && lineRule === "exact") {
|
|
102
|
-
style.lineHeight = `${lineSpacing / 20}
|
|
102
|
+
style.lineHeight = `${lineSpacing / 20}pt`;
|
|
103
103
|
} else if (lineSpacing && lineRule === "atLeast") {
|
|
104
|
-
style.minHeight = `${lineSpacing / 20}
|
|
104
|
+
style.minHeight = `${lineSpacing / 20}pt`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
// Indentation
|
|
@@ -114,10 +114,10 @@ function buildParagraphStyle(
|
|
|
114
114
|
const indentHanging =
|
|
115
115
|
block.indentation?.hanging ?? block.resolvedParagraphFormatting?.indentation?.hanging;
|
|
116
116
|
|
|
117
|
-
if (indentLeft) style.paddingLeft = `${indentLeft / 20}
|
|
118
|
-
if (indentRight) style.paddingRight = `${indentRight / 20}
|
|
119
|
-
if (indentHanging) style.textIndent = `-${indentHanging / 20}
|
|
120
|
-
else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}
|
|
117
|
+
if (indentLeft) style.paddingLeft = `${indentLeft / 20}pt`;
|
|
118
|
+
if (indentRight) style.paddingRight = `${indentRight / 20}pt`;
|
|
119
|
+
if (indentHanging) style.textIndent = `-${indentHanging / 20}pt`;
|
|
120
|
+
else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}pt`;
|
|
121
121
|
|
|
122
122
|
// Shading
|
|
123
123
|
const shadingFill = block.shading?.fill;
|
|
@@ -171,10 +171,11 @@ function buildMarkerStyle(
|
|
|
171
171
|
|
|
172
172
|
const hasResolvedMarkerWidth = typeof markerWidth === "number" && markerWidth > 0;
|
|
173
173
|
if (hasResolvedMarkerWidth) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
style.
|
|
177
|
-
style.
|
|
174
|
+
// P13.a: emit marker geometry in pt so it self-scales under CSS `zoom`.
|
|
175
|
+
const markerWidthPt = Math.max(1, markerWidth! / 20);
|
|
176
|
+
style.width = `${markerWidthPt}pt`;
|
|
177
|
+
style.minWidth = `${markerWidthPt}pt`;
|
|
178
|
+
style.flexBasis = `${markerWidthPt}pt`;
|
|
178
179
|
style.marginRight = 0;
|
|
179
180
|
style.overflow = "visible";
|
|
180
181
|
} else {
|
|
@@ -450,9 +451,9 @@ function TableBlock({
|
|
|
450
451
|
key={rowIdx}
|
|
451
452
|
style={
|
|
452
453
|
row.height != null && row.heightRule === "exact"
|
|
453
|
-
? { height: `${row.height / 20}
|
|
454
|
+
? { height: `${row.height / 20}pt` }
|
|
454
455
|
: row.height != null && row.heightRule === "atLeast"
|
|
455
|
-
? { minHeight: `${row.height / 20}
|
|
456
|
+
? { minHeight: `${row.height / 20}pt` }
|
|
456
457
|
: undefined
|
|
457
458
|
}
|
|
458
459
|
>
|
|
@@ -294,6 +294,72 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
|
|
|
294
294
|
} else if (alignment === "right") {
|
|
295
295
|
table.style.marginLeft = "auto";
|
|
296
296
|
}
|
|
297
|
+
syncColgroup(table, node);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* P6.a: real `<colgroup>` driven by the table's canonical `gridColumns`.
|
|
302
|
+
*
|
|
303
|
+
* Pre-P6 tables relied on per-row padding cells (`data-row-padding`) to
|
|
304
|
+
* approximate column widths. That produced correct-looking tables on
|
|
305
|
+
* simple CCEP fixtures but broke as soon as a row used `w:gridBefore` /
|
|
306
|
+
* `w:gridAfter` or a cell spanned multiple logical columns with
|
|
307
|
+
* non-uniform widths. HTML's `<colgroup>` is the canonical place to
|
|
308
|
+
* declare per-column widths; every browser honors it exactly and we
|
|
309
|
+
* avoid the padding-cell trick entirely for width purposes.
|
|
310
|
+
*
|
|
311
|
+
* Each `<col>` uses its twips width converted to `pt` via `twips / 20`
|
|
312
|
+
* (the same unit the row-padding helper already uses for its
|
|
313
|
+
* `style.width`). Keeping `pt` units avoids tying the NodeView to a
|
|
314
|
+
* pxPerTwip value — the CSS `zoom` applied at the workspace root
|
|
315
|
+
* rescales `pt` alongside every other length, so the columns follow
|
|
316
|
+
* zoom automatically.
|
|
317
|
+
*
|
|
318
|
+
* When `gridColumns` is empty (malformed input, or a table built
|
|
319
|
+
* programmatically without a declared grid), the colgroup is removed
|
|
320
|
+
* so the browser falls back to auto-sizing.
|
|
321
|
+
*/
|
|
322
|
+
function syncColgroup(table: HTMLTableElement, node: PMNode): void {
|
|
323
|
+
const gridColumns = Array.isArray(node.attrs.gridColumns)
|
|
324
|
+
? (node.attrs.gridColumns as number[])
|
|
325
|
+
: [];
|
|
326
|
+
const existing = Array.from(table.children).find(
|
|
327
|
+
(child): child is HTMLTableColElement =>
|
|
328
|
+
child instanceof (table.ownerDocument?.defaultView?.HTMLTableColElement ??
|
|
329
|
+
HTMLTableColElement) && child.tagName === "COLGROUP",
|
|
330
|
+
) as HTMLElement | undefined;
|
|
331
|
+
|
|
332
|
+
if (gridColumns.length === 0) {
|
|
333
|
+
existing?.remove();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const owner = table.ownerDocument ?? document;
|
|
338
|
+
const colgroup = existing ?? owner.createElement("colgroup");
|
|
339
|
+
colgroup.setAttribute("data-pm-table-colgroup", "true");
|
|
340
|
+
// Reuse existing <col> children where possible; add/remove tail cols.
|
|
341
|
+
const desired = gridColumns.length;
|
|
342
|
+
const actual = colgroup.childElementCount;
|
|
343
|
+
for (let i = actual; i < desired; i += 1) {
|
|
344
|
+
colgroup.appendChild(owner.createElement("col"));
|
|
345
|
+
}
|
|
346
|
+
while (colgroup.childElementCount > desired) {
|
|
347
|
+
colgroup.lastElementChild?.remove();
|
|
348
|
+
}
|
|
349
|
+
for (let i = 0; i < desired; i += 1) {
|
|
350
|
+
const col = colgroup.children[i] as HTMLTableColElement;
|
|
351
|
+
const twips = gridColumns[i] ?? 0;
|
|
352
|
+
col.setAttribute("data-col-index", String(i));
|
|
353
|
+
col.setAttribute("data-col-twips", String(twips));
|
|
354
|
+
col.style.width = twips > 0 ? `${twips / 20}pt` : "";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!existing) {
|
|
358
|
+
// Colgroup must be the first child of <table> per HTML spec so the
|
|
359
|
+
// column widths propagate to every row. `insertBefore(first)`
|
|
360
|
+
// handles both empty and populated tables uniformly.
|
|
361
|
+
table.insertBefore(colgroup, table.firstChild);
|
|
362
|
+
}
|
|
297
363
|
}
|
|
298
364
|
|
|
299
365
|
function applyRowAttrs(row: HTMLTableRowElement, node: PMNode): void {
|
package/src/ui-tailwind/index.ts
CHANGED
|
@@ -53,6 +53,38 @@ export {
|
|
|
53
53
|
type TwModeDockProps,
|
|
54
54
|
} from "./chrome/tw-mode-dock";
|
|
55
55
|
|
|
56
|
+
// Collab chrome (P9) — mount when chromePreset === "collab"; each
|
|
57
|
+
// component is pure presentational and takes snapshots + callbacks.
|
|
58
|
+
export {
|
|
59
|
+
CollabPresenceStrip,
|
|
60
|
+
type CollabPresenceStripProps,
|
|
61
|
+
} from "./chrome/collab-presence-strip";
|
|
62
|
+
export {
|
|
63
|
+
CollabRoleChip,
|
|
64
|
+
type CollabRoleChipProps,
|
|
65
|
+
} from "./chrome/collab-role-chip";
|
|
66
|
+
export {
|
|
67
|
+
CollabAudienceChip,
|
|
68
|
+
type CollabAudienceChipProps,
|
|
69
|
+
} from "./chrome/collab-audience-chip";
|
|
70
|
+
export {
|
|
71
|
+
CollabTamperBanner,
|
|
72
|
+
type CollabTamperBannerProps,
|
|
73
|
+
} from "./chrome/collab-tamper-banner";
|
|
74
|
+
export {
|
|
75
|
+
CollabNegotiationActionBar,
|
|
76
|
+
type CollabNegotiationActionBarProps,
|
|
77
|
+
} from "./chrome/collab-negotiation-action-bar";
|
|
78
|
+
export {
|
|
79
|
+
CollabSendToSupplierButton,
|
|
80
|
+
type CollabSendToSupplierButtonProps,
|
|
81
|
+
} from "./chrome/collab-send-to-supplier-button";
|
|
82
|
+
export {
|
|
83
|
+
CollabSendToSupplierModal,
|
|
84
|
+
type CollabSendToSupplierModalProps,
|
|
85
|
+
type CollabSendToSupplierSubmitArgs,
|
|
86
|
+
} from "./chrome/collab-send-to-supplier-modal";
|
|
87
|
+
|
|
56
88
|
// Chrome overlay plane (R3a — scope rail layer)
|
|
57
89
|
export {
|
|
58
90
|
TwChromeOverlay,
|
|
@@ -17,11 +17,36 @@ export interface TwReviewRailFooterProps {
|
|
|
17
17
|
const focusRingClass =
|
|
18
18
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Accept only http(s) and mailto help links. Rejects javascript:, data:,
|
|
22
|
+
* and any other scheme that could execute code when a reviewer clicks the
|
|
23
|
+
* help button. Host-supplied; treated as untrusted.
|
|
24
|
+
*/
|
|
25
|
+
function safeHelpHref(raw: string | undefined): string | undefined {
|
|
26
|
+
if (typeof raw !== "string") return undefined;
|
|
27
|
+
const value = raw.trim();
|
|
28
|
+
if (value.length === 0) return undefined;
|
|
29
|
+
// Allow relative paths (no scheme) — hosts often use `/docs/help`.
|
|
30
|
+
if (value.startsWith("/") || value.startsWith("#") || value.startsWith("?")) {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(value, "https://placeholder.invalid/");
|
|
35
|
+
if (url.protocol === "https:" || url.protocol === "http:" || url.protocol === "mailto:") {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// fall through
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
export function TwReviewRailFooter(props: TwReviewRailFooterProps) {
|
|
21
45
|
const searchLabel = props.searchLabel ?? "SEARCH";
|
|
22
46
|
const helpLabel = props.helpLabel ?? "HELP";
|
|
47
|
+
const helpHref = safeHelpHref(props.helpHref);
|
|
23
48
|
|
|
24
|
-
if (!props.onSearch && !
|
|
49
|
+
if (!props.onSearch && !helpHref) {
|
|
25
50
|
return null;
|
|
26
51
|
}
|
|
27
52
|
|
|
@@ -38,9 +63,10 @@ export function TwReviewRailFooter(props: TwReviewRailFooterProps) {
|
|
|
38
63
|
</button>
|
|
39
64
|
) : null}
|
|
40
65
|
|
|
41
|
-
{
|
|
66
|
+
{helpHref ? (
|
|
42
67
|
<a
|
|
43
|
-
href={
|
|
68
|
+
href={helpHref}
|
|
69
|
+
rel="noopener noreferrer"
|
|
44
70
|
className={`inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-tertiary transition-colors hover:text-secondary ${focusRingClass}`}
|
|
45
71
|
>
|
|
46
72
|
<HelpCircle aria-hidden="true" className="h-3 w-3" />
|