@beyondwork/docx-react-component 1.0.56 → 1.0.58
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/README.md +1 -1
- package/package.json +1 -1
- package/src/api/public-types.ts +330 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +17 -11
- package/src/core/selection/mapping.ts +18 -1
- package/src/core/selection/review-anchors.ts +29 -18
- package/src/io/chart-preview-resolver.ts +175 -41
- package/src/io/docx-session.ts +57 -2
- package/src/io/export/serialize-main-document.ts +82 -0
- package/src/io/export/serialize-styles.ts +61 -3
- package/src/io/export/table-properties-xml.ts +19 -4
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-anchor.ts +182 -0
- package/src/io/ooxml/parse-drawing.ts +319 -0
- package/src/io/ooxml/parse-fields.ts +115 -2
- package/src/io/ooxml/parse-fill.ts +215 -0
- package/src/io/ooxml/parse-font-table.ts +190 -0
- package/src/io/ooxml/parse-footnotes.ts +52 -1
- package/src/io/ooxml/parse-main-document.ts +241 -1
- package/src/io/ooxml/parse-numbering.ts +96 -0
- package/src/io/ooxml/parse-picture.ts +158 -0
- package/src/io/ooxml/parse-settings.ts +34 -0
- package/src/io/ooxml/parse-shapes.ts +87 -0
- package/src/io/ooxml/parse-solid-fill.ts +11 -0
- package/src/io/ooxml/parse-styles.ts +74 -1
- package/src/io/ooxml/parse-theme.ts +60 -0
- package/src/io/paste/html-clipboard.ts +449 -0
- package/src/io/paste/word-clipboard.ts +5 -1
- package/src/legal/_document-root.ts +26 -0
- package/src/legal/bookmarks.ts +4 -3
- package/src/legal/cross-references.ts +3 -2
- package/src/legal/defined-terms.ts +2 -1
- package/src/legal/signature-blocks.ts +2 -1
- package/src/model/canonical-document.ts +421 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +760 -41
- package/src/runtime/document-search.ts +61 -0
- package/src/runtime/edit-ops/index.ts +129 -0
- package/src/runtime/event-refresh-hints.ts +7 -0
- package/src/runtime/field-resolver.ts +341 -0
- package/src/runtime/footnote-resolver.ts +55 -0
- package/src/runtime/hyperlink-color-resolver.ts +13 -10
- package/src/runtime/object-grab/index.ts +51 -0
- package/src/runtime/paragraph-style-resolver.ts +105 -0
- package/src/runtime/query-scopes.ts +186 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/scope-resolver.ts +60 -0
- package/src/runtime/selection/cursor-ops.ts +186 -15
- package/src/runtime/selection/index.ts +17 -1
- package/src/runtime/structure-ops/index.ts +77 -0
- package/src/runtime/styles-cascade.ts +33 -0
- package/src/runtime/surface-projection.ts +192 -12
- package/src/runtime/theme-color-resolver.ts +189 -44
- package/src/runtime/units.ts +46 -0
- package/src/runtime/view-state.ts +13 -2
- package/src/ui/WordReviewEditor.tsx +239 -11
- package/src/ui/editor-runtime-boundary.ts +97 -1
- package/src/ui/editor-shell-view.tsx +1 -1
- package/src/ui/runtime-shortcut-dispatch.ts +17 -3
- package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
- package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
- package/src/ui-tailwind/chart/render/area.tsx +22 -4
- package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
- package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
- package/src/ui-tailwind/chart/render/combo.tsx +37 -4
- package/src/ui-tailwind/chart/render/line.tsx +28 -5
- package/src/ui-tailwind/chart/render/pie.tsx +36 -16
- package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
- package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
- package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
- package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
- package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
- package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
- package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
- package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
- package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
- package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
- package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
- package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
- package/src/ui-tailwind/theme/editor-theme.css +1 -0
- package/src/ui-tailwind/theme/tokens.css +6 -0
- package/src/ui-tailwind/theme/tokens.ts +10 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
- package/src/validation/compatibility-engine.ts +2 -0
- package/src/validation/docx-comment-proof.ts +12 -3
|
@@ -5,6 +5,17 @@ import {
|
|
|
5
5
|
tableCellNodeSpec,
|
|
6
6
|
tableHeaderCellNodeSpec,
|
|
7
7
|
} from "../../runtime/table-schema.ts";
|
|
8
|
+
import {
|
|
9
|
+
isSupportedShapeGeometry,
|
|
10
|
+
renderShapeSvg,
|
|
11
|
+
type ShapeFill,
|
|
12
|
+
type ShapeLine,
|
|
13
|
+
} from "./shape-renderer.ts";
|
|
14
|
+
import {
|
|
15
|
+
EMU_PER_PX,
|
|
16
|
+
ROTATION_UNITS_PER_DEGREE,
|
|
17
|
+
SRCRECT_UNITS_PER_PERCENT,
|
|
18
|
+
} from "../../runtime/units.ts";
|
|
8
19
|
|
|
9
20
|
const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
|
|
10
21
|
const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
|
|
@@ -442,6 +453,22 @@ export const editorSchema = new Schema({
|
|
|
442
453
|
src: { default: null },
|
|
443
454
|
widthEmu: { default: null },
|
|
444
455
|
heightEmu: { default: null },
|
|
456
|
+
// Lane 6d N11 — picture effects from `SurfacePictureEffects`.
|
|
457
|
+
// Defaults are no-effect (null/false) so legacy callers stay
|
|
458
|
+
// visually identical. OOXML units: rotation = 60000ths of a
|
|
459
|
+
// degree; srcRect = 1/1000 of a percent.
|
|
460
|
+
rotation: { default: null },
|
|
461
|
+
flipH: { default: false },
|
|
462
|
+
flipV: { default: false },
|
|
463
|
+
srcRect: { default: null },
|
|
464
|
+
// Lane 6d N9 — float-wrap fields surfaced from `SurfaceDrawingAnchor`.
|
|
465
|
+
wrapMode: { default: null },
|
|
466
|
+
distMargins: { default: null },
|
|
467
|
+
positionH: { default: null },
|
|
468
|
+
// Lane 6d N11.b — CSS filter effects (soft-edge, outer shadow, glow).
|
|
469
|
+
softEdgeRadius: { default: null },
|
|
470
|
+
outerShadow: { default: null },
|
|
471
|
+
glow: { default: null },
|
|
445
472
|
},
|
|
446
473
|
toDOM(node) {
|
|
447
474
|
const isMissing = node.attrs.state === "missing";
|
|
@@ -450,22 +477,94 @@ export const editorSchema = new Schema({
|
|
|
450
477
|
const widthEmu = node.attrs.widthEmu as number | null;
|
|
451
478
|
const heightEmu = node.attrs.heightEmu as number | null;
|
|
452
479
|
if (!isMissing && src) {
|
|
453
|
-
const widthPx = widthEmu ? Math.max(24, Math.round(widthEmu /
|
|
454
|
-
const heightPx = heightEmu ? Math.max(24, Math.round(heightEmu /
|
|
480
|
+
const widthPx = widthEmu ? Math.max(24, Math.round(widthEmu / EMU_PER_PX)) : undefined;
|
|
481
|
+
const heightPx = heightEmu ? Math.max(24, Math.round(heightEmu / EMU_PER_PX)) : undefined;
|
|
482
|
+
// N11 picture effects → CSS transform + clip-path.
|
|
483
|
+
const rotation = node.attrs.rotation as number | null;
|
|
484
|
+
const flipH = Boolean(node.attrs.flipH);
|
|
485
|
+
const flipV = Boolean(node.attrs.flipV);
|
|
486
|
+
const srcRect = node.attrs.srcRect as
|
|
487
|
+
| { top: number; bottom: number; left: number; right: number }
|
|
488
|
+
| null;
|
|
489
|
+
const transformParts: string[] = [];
|
|
490
|
+
if (rotation && rotation !== 0) {
|
|
491
|
+
transformParts.push(`rotate(${(rotation / ROTATION_UNITS_PER_DEGREE).toFixed(3)}deg)`);
|
|
492
|
+
}
|
|
493
|
+
if (flipH) transformParts.push("scaleX(-1)");
|
|
494
|
+
if (flipV) transformParts.push("scaleY(-1)");
|
|
495
|
+
// OOXML srcRect uses 1/1000 of a percent (e.g. 5000 = 5%).
|
|
496
|
+
// CSS clip-path inset() uses literal percent.
|
|
497
|
+
const clipParts: string[] = [];
|
|
498
|
+
if (srcRect) {
|
|
499
|
+
clipParts.push(
|
|
500
|
+
`inset(${(srcRect.top / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.right / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.bottom / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.left / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}%)`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
// N11.b filter effects → CSS filter on the img element.
|
|
504
|
+
const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
|
|
505
|
+
const outerShadow = node.attrs.outerShadow as {
|
|
506
|
+
blurRad: number; dist: number; dir: number; color: string;
|
|
507
|
+
} | null;
|
|
508
|
+
const glow = node.attrs.glow as { radius: number; color: string } | null;
|
|
509
|
+
const filterParts: string[] = [];
|
|
510
|
+
if (softEdgeRadius) {
|
|
511
|
+
filterParts.push(`blur(${(softEdgeRadius / EMU_PER_PX).toFixed(2)}px)`);
|
|
512
|
+
}
|
|
513
|
+
if (glow) {
|
|
514
|
+
filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px #${glow.color})`);
|
|
515
|
+
}
|
|
516
|
+
if (outerShadow) {
|
|
517
|
+
const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
|
|
518
|
+
const distPx = outerShadow.dist / EMU_PER_PX;
|
|
519
|
+
const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
|
|
520
|
+
const dx = (distPx * Math.cos(dirRad)).toFixed(2);
|
|
521
|
+
const dy = (distPx * Math.sin(dirRad)).toFixed(2);
|
|
522
|
+
filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px #${outerShadow.color})`);
|
|
523
|
+
}
|
|
524
|
+
// N9 float-wrap → CSS float + shape-outside on the wrapper span.
|
|
525
|
+
const wrapMode = node.attrs.wrapMode as string | null;
|
|
526
|
+
const positionH = node.attrs.positionH as { align?: string } | null;
|
|
527
|
+
const distMargins = node.attrs.distMargins as
|
|
528
|
+
| { top?: number; bottom?: number; left?: number; right?: number }
|
|
529
|
+
| null;
|
|
530
|
+
const wrapperStyleParts: string[] = [];
|
|
531
|
+
if (isFloating && wrapMode === "square") {
|
|
532
|
+
const floatSide = positionH?.align === "right" ? "right" : "left";
|
|
533
|
+
wrapperStyleParts.push(
|
|
534
|
+
`float:${floatSide}`,
|
|
535
|
+
"shape-outside:margin-box",
|
|
536
|
+
);
|
|
537
|
+
} else if (isFloating && wrapMode === "topAndBottom") {
|
|
538
|
+
wrapperStyleParts.push("clear:both", "display:block");
|
|
539
|
+
}
|
|
540
|
+
// distMargins from EMU → px on the wrapper.
|
|
541
|
+
if (isFloating && distMargins) {
|
|
542
|
+
const m = (v?: number) => (v ? Math.round(v / EMU_PER_PX) : 0);
|
|
543
|
+
wrapperStyleParts.push(
|
|
544
|
+
`margin:${m(distMargins.top)}px ${m(distMargins.right)}px ${m(distMargins.bottom)}px ${m(distMargins.left)}px`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
455
547
|
const style = [
|
|
456
548
|
"display:inline-block",
|
|
457
549
|
"vertical-align:middle",
|
|
458
|
-
"margin:0 4px",
|
|
550
|
+
wrapperStyleParts.length === 0 ? "margin:0 4px" : "",
|
|
459
551
|
widthPx ? `width:${widthPx}px` : "",
|
|
460
552
|
heightPx ? `height:${heightPx}px` : "",
|
|
553
|
+
transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
|
|
554
|
+
clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
|
|
555
|
+
filterParts.length > 0 ? `filter:${filterParts.join(" ")}` : "",
|
|
461
556
|
].filter(Boolean).join(";");
|
|
557
|
+
const wrapperStyle = wrapperStyleParts.join(";");
|
|
558
|
+
const wrapperAttrs: Record<string, string> = {
|
|
559
|
+
class: "inline-flex items-center rounded",
|
|
560
|
+
"data-node-type": "image",
|
|
561
|
+
title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Image",
|
|
562
|
+
};
|
|
563
|
+
if (wrapMode) wrapperAttrs["data-wrap-mode"] = wrapMode;
|
|
564
|
+
if (wrapperStyle) wrapperAttrs.style = wrapperStyle;
|
|
462
565
|
return [
|
|
463
566
|
"span",
|
|
464
|
-
|
|
465
|
-
class: "inline-flex items-center rounded",
|
|
466
|
-
"data-node-type": "image",
|
|
467
|
-
title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Image",
|
|
468
|
-
},
|
|
567
|
+
wrapperAttrs,
|
|
469
568
|
[
|
|
470
569
|
"img",
|
|
471
570
|
{
|
|
@@ -774,19 +873,123 @@ export const editorSchema = new Schema({
|
|
|
774
873
|
text: { default: null },
|
|
775
874
|
geometry: { default: null },
|
|
776
875
|
detail: { default: null },
|
|
876
|
+
// V2c.5 additions — populated when the segment originates from a
|
|
877
|
+
// canonical `DrawingFrameNode` with `content.type === "shape"`.
|
|
878
|
+
// Legacy debug-mode opaque-preview consumers leave these at their
|
|
879
|
+
// defaults so the original toDOM output is unchanged.
|
|
880
|
+
label: { default: null },
|
|
881
|
+
fill: { default: null },
|
|
882
|
+
line: { default: null },
|
|
883
|
+
isTextBox: { default: false },
|
|
884
|
+
txbxText: { default: null },
|
|
885
|
+
wrapMode: { default: "none" },
|
|
886
|
+
display: { default: "inline" },
|
|
887
|
+
widthEmu: { default: null },
|
|
888
|
+
heightEmu: { default: null },
|
|
777
889
|
},
|
|
778
890
|
toDOM(node) {
|
|
891
|
+
// V2c.5: when the rich attrs are present (DrawingFrame source),
|
|
892
|
+
// render either an SVG (N10 supported geometries) or a chip
|
|
893
|
+
// fallback with shape-aware data attributes. Otherwise fall back
|
|
894
|
+
// to the legacy opaque-preview chip so existing harness debug
|
|
895
|
+
// previews stay visually identical.
|
|
896
|
+
const isV2c5 = node.attrs.label !== null || node.attrs.isTextBox === true;
|
|
897
|
+
if (isV2c5) {
|
|
898
|
+
const geometry = node.attrs.geometry as string | null;
|
|
899
|
+
const fill = node.attrs.fill as
|
|
900
|
+
| ShapeFill
|
|
901
|
+
| {
|
|
902
|
+
kind: "gradient";
|
|
903
|
+
stops: Array<{ pos: number; color: string; colorType: "srgbClr" | "schemeClr" }>;
|
|
904
|
+
direction:
|
|
905
|
+
| { kind: "linear"; angle: number; scaled?: boolean }
|
|
906
|
+
| { kind: "path"; path: "circle" | "rect" | "shape" };
|
|
907
|
+
rotWithShape?: boolean;
|
|
908
|
+
}
|
|
909
|
+
| {
|
|
910
|
+
kind: "pattern";
|
|
911
|
+
preset: string;
|
|
912
|
+
fg?: { color: string; colorType: "srgbClr" | "schemeClr" };
|
|
913
|
+
bg?: { color: string; colorType: "srgbClr" | "schemeClr" };
|
|
914
|
+
}
|
|
915
|
+
| null;
|
|
916
|
+
const widthEmu = node.attrs.widthEmu as number | null;
|
|
917
|
+
const heightEmu = node.attrs.heightEmu as number | null;
|
|
918
|
+
const widthPx = widthEmu ? Math.max(8, Math.round(widthEmu / EMU_PER_PX)) : null;
|
|
919
|
+
const heightPx = heightEmu ? Math.max(8, Math.round(heightEmu / EMU_PER_PX)) : null;
|
|
920
|
+
const svgFill =
|
|
921
|
+
fill === undefined || fill === null
|
|
922
|
+
? undefined
|
|
923
|
+
: fill.kind === "solid" || fill.kind === "none"
|
|
924
|
+
? fill
|
|
925
|
+
: undefined;
|
|
926
|
+
// N10 — try SVG render path for supported geometries with extent.
|
|
927
|
+
if (
|
|
928
|
+
geometry &&
|
|
929
|
+
isSupportedShapeGeometry(geometry) &&
|
|
930
|
+
widthPx &&
|
|
931
|
+
heightPx &&
|
|
932
|
+
svgFill !== undefined
|
|
933
|
+
) {
|
|
934
|
+
const svgSpec = renderShapeSvg(
|
|
935
|
+
{
|
|
936
|
+
geometry,
|
|
937
|
+
fill: svgFill,
|
|
938
|
+
line: node.attrs.line as ShapeLine | undefined,
|
|
939
|
+
},
|
|
940
|
+
widthPx,
|
|
941
|
+
heightPx,
|
|
942
|
+
);
|
|
943
|
+
if (svgSpec) {
|
|
944
|
+
return [
|
|
945
|
+
"span",
|
|
946
|
+
{
|
|
947
|
+
class: "inline-block align-middle mx-0.5",
|
|
948
|
+
style: `display:inline-block;width:${widthPx}px;height:${heightPx}px;vertical-align:middle`,
|
|
949
|
+
"data-node-type": "shape",
|
|
950
|
+
"data-shape-geometry": geometry,
|
|
951
|
+
"data-shape-wrap": (node.attrs.wrapMode as string) ?? "none",
|
|
952
|
+
contenteditable: "false",
|
|
953
|
+
title: (node.attrs.detail as string) || (node.attrs.label as string) || "Shape",
|
|
954
|
+
},
|
|
955
|
+
svgSpec as unknown as readonly [string, Record<string, string>],
|
|
956
|
+
];
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
// Chip fallback for unsupported geometry / missing extent.
|
|
960
|
+
const isTextBox = Boolean(node.attrs.isTextBox);
|
|
961
|
+
const txbxText = node.attrs.txbxText as string | null;
|
|
962
|
+
const display =
|
|
963
|
+
isTextBox && txbxText
|
|
964
|
+
? `\u25A1 ${txbxText}`
|
|
965
|
+
: isTextBox
|
|
966
|
+
? "\u25A1 Text box"
|
|
967
|
+
: "\u25C7 Shape";
|
|
968
|
+
return [
|
|
969
|
+
"span",
|
|
970
|
+
{
|
|
971
|
+
class:
|
|
972
|
+
"inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs border-none text-secondary bg-surface",
|
|
973
|
+
"data-node-type": "shape",
|
|
974
|
+
"data-shape-geometry": (node.attrs.geometry as string) ?? "",
|
|
975
|
+
"data-shape-wrap": (node.attrs.wrapMode as string) ?? "none",
|
|
976
|
+
contenteditable: "false",
|
|
977
|
+
title: (node.attrs.detail as string) || (node.attrs.label as string) || "Shape",
|
|
978
|
+
},
|
|
979
|
+
display,
|
|
980
|
+
];
|
|
981
|
+
}
|
|
779
982
|
const text = node.attrs.text as string | null;
|
|
780
|
-
const
|
|
983
|
+
const legacyLabel = text ? `Shape: ${text}` : "Shape";
|
|
781
984
|
return [
|
|
782
985
|
"span",
|
|
783
986
|
{
|
|
784
987
|
class: "inline-flex items-center gap-1 mx-0.5 rounded border border-success/25 bg-success-soft px-1.5 py-0.5 text-xs text-success",
|
|
785
988
|
"data-node-type": "shape_atom",
|
|
786
989
|
contenteditable: "false",
|
|
787
|
-
title: (node.attrs.detail as string) ??
|
|
990
|
+
title: (node.attrs.detail as string) ?? legacyLabel,
|
|
788
991
|
},
|
|
789
|
-
"\u25A1 " +
|
|
992
|
+
"\u25A1 " + legacyLabel,
|
|
790
993
|
];
|
|
791
994
|
},
|
|
792
995
|
},
|
|
@@ -453,8 +453,21 @@ function buildInlineContent(
|
|
|
453
453
|
display: segment.display ?? "inline",
|
|
454
454
|
detail: segment.detail ?? null,
|
|
455
455
|
src: preview?.src ?? null,
|
|
456
|
-
widthEmu: preview?.widthEmu ?? null,
|
|
457
|
-
heightEmu: preview?.heightEmu ?? null,
|
|
456
|
+
widthEmu: preview?.widthEmu ?? preview?.widthEmu ?? segment.anchor?.extent.widthEmu ?? null,
|
|
457
|
+
heightEmu: preview?.heightEmu ?? segment.anchor?.extent.heightEmu ?? null,
|
|
458
|
+
// Lane 6d N11 — picture effects.
|
|
459
|
+
rotation: segment.pictureEffects?.rotation ?? null,
|
|
460
|
+
flipH: segment.pictureEffects?.flipH ?? false,
|
|
461
|
+
flipV: segment.pictureEffects?.flipV ?? false,
|
|
462
|
+
srcRect: segment.pictureEffects?.srcRect ?? null,
|
|
463
|
+
// Lane 6d N9 — float-wrap.
|
|
464
|
+
wrapMode: segment.anchor?.wrapMode ?? null,
|
|
465
|
+
distMargins: segment.anchor?.distMargins ?? null,
|
|
466
|
+
positionH: segment.anchor?.positionH ?? null,
|
|
467
|
+
// Lane 6d N11.b — filter effects.
|
|
468
|
+
softEdgeRadius: segment.pictureEffects?.softEdgeRadius ?? null,
|
|
469
|
+
outerShadow: segment.pictureEffects?.outerShadow ?? null,
|
|
470
|
+
glow: segment.pictureEffects?.glow ?? null,
|
|
458
471
|
}),
|
|
459
472
|
];
|
|
460
473
|
}
|
|
@@ -462,6 +475,23 @@ function buildInlineContent(
|
|
|
462
475
|
case "opaque_inline":
|
|
463
476
|
return [buildOpaqueInlineOrComplexAtom(segment, mediaPreviews, showUnsupportedObjectPreviews)];
|
|
464
477
|
|
|
478
|
+
case "shape":
|
|
479
|
+
return [
|
|
480
|
+
editorSchema.nodes.shape_atom.create({
|
|
481
|
+
label: segment.label,
|
|
482
|
+
detail: segment.detail,
|
|
483
|
+
geometry: segment.geometry ?? null,
|
|
484
|
+
fill: segment.fill ?? null,
|
|
485
|
+
line: segment.line ?? null,
|
|
486
|
+
isTextBox: segment.isTextBox ?? false,
|
|
487
|
+
txbxText: segment.txbxText ?? null,
|
|
488
|
+
wrapMode: segment.anchor?.wrapMode ?? "none",
|
|
489
|
+
display: segment.anchor?.display ?? "inline",
|
|
490
|
+
widthEmu: segment.anchor?.extent.widthEmu ?? null,
|
|
491
|
+
heightEmu: segment.anchor?.extent.heightEmu ?? null,
|
|
492
|
+
}),
|
|
493
|
+
];
|
|
494
|
+
|
|
465
495
|
case "note_ref": {
|
|
466
496
|
const text = editorSchema.text(
|
|
467
497
|
segment.label,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — Slice N10 (P12.1 + P12.2): pure SVG shape renderer.
|
|
3
|
+
*
|
|
4
|
+
* Maps the V2c.5 `kind: "shape"` surface segment onto inline SVG markup
|
|
5
|
+
* for the most common geometries (rect / ellipse / roundRect). Pure —
|
|
6
|
+
* no DOM access, no React; returns a plain markup string the toDOM can
|
|
7
|
+
* embed via attrs. Returns `null` for unsupported geometries so the
|
|
8
|
+
* caller falls back to the existing chip render.
|
|
9
|
+
*
|
|
10
|
+
* v1 fill coverage: solid (srgbClr) → `#${hex}`; solid (schemeClr) →
|
|
11
|
+
* `currentColor` placeholder until Lane 6a theme resolver wires through;
|
|
12
|
+
* none → `none`. Gradient + pattern fills are dropped at the surface
|
|
13
|
+
* boundary (V2c.5 surface only carries solid/none) — they remain on the
|
|
14
|
+
* canonical model for a future renderer extension.
|
|
15
|
+
*
|
|
16
|
+
* v1 line coverage: width = `widthEmu / 9525` px; color same handling as
|
|
17
|
+
* fill; `noLine: true` → `stroke: "none"`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { EMU_PER_PX } from "../../runtime/units";
|
|
21
|
+
|
|
22
|
+
const SUPPORTED_GEOMETRIES = new Set(["rect", "ellipse", "roundRect"]);
|
|
23
|
+
|
|
24
|
+
export type ShapeFill =
|
|
25
|
+
| { kind: "solid"; color: string; colorType: "srgbClr" | "schemeClr" }
|
|
26
|
+
| { kind: "none" };
|
|
27
|
+
|
|
28
|
+
export type ShapeLine = {
|
|
29
|
+
color?: string;
|
|
30
|
+
widthEmu?: number;
|
|
31
|
+
noLine?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface ResolvedFillCss {
|
|
35
|
+
fill: string;
|
|
36
|
+
/** True when caller should warn about unresolved scheme color. */
|
|
37
|
+
isSchemePlaceholder: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedLineCss {
|
|
41
|
+
stroke: string;
|
|
42
|
+
strokeWidth: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a shape fill into a CSS color string. schemeClr falls back
|
|
47
|
+
* to `currentColor` until Lane 6a's theme resolver is wired here —
|
|
48
|
+
* `isSchemePlaceholder` flags the placeholder so callers (or follow-up
|
|
49
|
+
* slices) can later substitute the resolved theme color.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveFillCss(fill: ShapeFill | undefined): ResolvedFillCss {
|
|
52
|
+
if (!fill || fill.kind === "none") {
|
|
53
|
+
return { fill: "none", isSchemePlaceholder: false };
|
|
54
|
+
}
|
|
55
|
+
if (fill.colorType === "schemeClr") {
|
|
56
|
+
return { fill: "currentColor", isSchemePlaceholder: true };
|
|
57
|
+
}
|
|
58
|
+
// srgbClr — color is uppercase hex without leading `#`.
|
|
59
|
+
return { fill: `#${fill.color}`, isSchemePlaceholder: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a shape line into stroke + stroke-width values. `noLine: true`
|
|
64
|
+
* collapses to `stroke: "none"` per OOXML semantics. Default stroke
|
|
65
|
+
* width is 1 px when widthEmu is absent or zero.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveLineCss(line: ShapeLine | undefined): ResolvedLineCss {
|
|
68
|
+
if (!line || line.noLine) {
|
|
69
|
+
return { stroke: "none", strokeWidth: 0 };
|
|
70
|
+
}
|
|
71
|
+
// Default stroke (no widthEmu) matches the floor used when widthEmu
|
|
72
|
+
// is set — otherwise a `widthEmu: 0` shape would get a fatter stroke
|
|
73
|
+
// (1 px) than a `widthEmu: 1` shape (0.5 px from the Math.max floor).
|
|
74
|
+
const strokeWidth = line.widthEmu && line.widthEmu > 0
|
|
75
|
+
? Math.max(0.5, line.widthEmu / EMU_PER_PX)
|
|
76
|
+
: 0.5;
|
|
77
|
+
// line.color uses OOXML hex without `#`; "auto" → currentColor.
|
|
78
|
+
const stroke = !line.color || line.color === "auto"
|
|
79
|
+
? "currentColor"
|
|
80
|
+
: `#${line.color}`;
|
|
81
|
+
return { stroke, strokeWidth };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ShapeSegmentLike {
|
|
85
|
+
geometry?: string;
|
|
86
|
+
fill?: ShapeFill;
|
|
87
|
+
line?: ShapeLine;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* PM `DOMOutputSpec`-style array tree for an SVG element. Each inner
|
|
92
|
+
* tuple is `[tag, attrs, ...children]`. Used by ProseMirror's
|
|
93
|
+
* `toDOM` to construct DOM nodes without string parsing.
|
|
94
|
+
*/
|
|
95
|
+
export type SvgSpec = readonly [string, Record<string, string>, ...SvgSpec[]];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Render a supported geometry into a PM-compatible DOMOutputSpec tree
|
|
99
|
+
* for an inline `<svg>`. Returns `null` when the geometry is unsupported
|
|
100
|
+
* or the caller did not supply pixel dimensions (a chip-fallback signal).
|
|
101
|
+
*
|
|
102
|
+
* The SVG is sized 1:1 to its container; the wrapper span owns the
|
|
103
|
+
* outer `width:Xpx; height:Ypx`.
|
|
104
|
+
*/
|
|
105
|
+
export function renderShapeSvg(
|
|
106
|
+
segment: ShapeSegmentLike,
|
|
107
|
+
widthPx: number,
|
|
108
|
+
heightPx: number,
|
|
109
|
+
): SvgSpec | null {
|
|
110
|
+
if (!widthPx || !heightPx) return null;
|
|
111
|
+
if (!segment.geometry || !SUPPORTED_GEOMETRIES.has(segment.geometry)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const fillCss = resolveFillCss(segment.fill);
|
|
115
|
+
const lineCss = resolveLineCss(segment.line);
|
|
116
|
+
const sw = lineCss.strokeWidth;
|
|
117
|
+
// Inset the geometry by half the stroke so the stroke paints inside
|
|
118
|
+
// the bounding box (browsers default to centered stroke).
|
|
119
|
+
const insetX = sw / 2;
|
|
120
|
+
const insetY = sw / 2;
|
|
121
|
+
const innerW = Math.max(0, widthPx - sw);
|
|
122
|
+
const innerH = Math.max(0, heightPx - sw);
|
|
123
|
+
|
|
124
|
+
let geometryEl: SvgSpec;
|
|
125
|
+
switch (segment.geometry) {
|
|
126
|
+
case "rect":
|
|
127
|
+
geometryEl = [
|
|
128
|
+
"rect",
|
|
129
|
+
{
|
|
130
|
+
x: String(insetX),
|
|
131
|
+
y: String(insetY),
|
|
132
|
+
width: String(innerW),
|
|
133
|
+
height: String(innerH),
|
|
134
|
+
fill: fillCss.fill,
|
|
135
|
+
stroke: lineCss.stroke,
|
|
136
|
+
"stroke-width": String(sw),
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
break;
|
|
140
|
+
case "roundRect": {
|
|
141
|
+
// Default OOXML preset uses ~10% of the shorter side for corner radius.
|
|
142
|
+
const r = Math.min(innerW, innerH) * 0.1;
|
|
143
|
+
geometryEl = [
|
|
144
|
+
"rect",
|
|
145
|
+
{
|
|
146
|
+
x: String(insetX),
|
|
147
|
+
y: String(insetY),
|
|
148
|
+
width: String(innerW),
|
|
149
|
+
height: String(innerH),
|
|
150
|
+
rx: String(r),
|
|
151
|
+
ry: String(r),
|
|
152
|
+
fill: fillCss.fill,
|
|
153
|
+
stroke: lineCss.stroke,
|
|
154
|
+
"stroke-width": String(sw),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "ellipse": {
|
|
160
|
+
const cx = widthPx / 2;
|
|
161
|
+
const cy = heightPx / 2;
|
|
162
|
+
const rx = Math.max(0, (widthPx - sw) / 2);
|
|
163
|
+
const ry = Math.max(0, (heightPx - sw) / 2);
|
|
164
|
+
geometryEl = [
|
|
165
|
+
"ellipse",
|
|
166
|
+
{
|
|
167
|
+
cx: String(cx),
|
|
168
|
+
cy: String(cy),
|
|
169
|
+
rx: String(rx),
|
|
170
|
+
ry: String(ry),
|
|
171
|
+
fill: fillCss.fill,
|
|
172
|
+
stroke: lineCss.stroke,
|
|
173
|
+
"stroke-width": String(sw),
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// PM's DOMSerializer parses the tag string for an SVG namespace prefix:
|
|
183
|
+
// "http://www.w3.org/2000/svg svg" → createElementNS(). Setting an
|
|
184
|
+
// `xmlns` *attribute* after createElement() is meaningless — the
|
|
185
|
+
// resulting node is HTMLUnknownElement and won't paint as SVG.
|
|
186
|
+
// Children inherit the namespace, so the geometry tag stays bare.
|
|
187
|
+
return [
|
|
188
|
+
"http://www.w3.org/2000/svg svg",
|
|
189
|
+
{
|
|
190
|
+
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
|
191
|
+
width: String(widthPx),
|
|
192
|
+
height: String(heightPx),
|
|
193
|
+
preserveAspectRatio: "none",
|
|
194
|
+
"aria-hidden": "true",
|
|
195
|
+
},
|
|
196
|
+
geometryEl,
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether N10 v1 supports rendering this geometry as SVG. Useful for
|
|
202
|
+
* caller-side branching when the chip fallback should fire.
|
|
203
|
+
*/
|
|
204
|
+
export function isSupportedShapeGeometry(geometry: string | undefined): boolean {
|
|
205
|
+
return geometry !== undefined && SUPPORTED_GEOMETRIES.has(geometry);
|
|
206
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R.4 SurfaceLayer — named shell for the outermost input surface layer.
|
|
3
|
+
* See `docs/plans/lane-1-editing-foundation.md` §R.4.
|
|
4
|
+
*
|
|
5
|
+
* Analogous to LibreOffice `SwWrtShell`. Owns: keyboard resolution, paste/drop
|
|
6
|
+
* routing decisions, composition state. Does NOT own: PM view construction,
|
|
7
|
+
* DOM manipulation, visible selection chrome — those stay in
|
|
8
|
+
* `pm-command-bridge.ts` as a thin plugin factory that forwards events here.
|
|
9
|
+
*
|
|
10
|
+
* The R.4 scope shipped in this first extraction is the TYPED RESULT SHAPE +
|
|
11
|
+
* a testable dispatch seam for the keyboard path. Tests can now exercise
|
|
12
|
+
* "Ctrl+F without React render" by calling `surfaceLayer.resolveKeyDown`
|
|
13
|
+
* directly — no PM boot, no DOM.
|
|
14
|
+
*
|
|
15
|
+
* Follow-up work (not in this slice): migrate `pm-command-bridge.ts`
|
|
16
|
+
* `handleKeyDown` / `handlePaste` / `handleDrop` to call into
|
|
17
|
+
* `surfaceLayer.dispatchPaste` / `surfaceLayer.dispatchDrop` so the bridge
|
|
18
|
+
* becomes a plugin factory with zero dispatch logic.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
resolveSurfaceShortcut,
|
|
23
|
+
resolveShellShortcut,
|
|
24
|
+
type ShellShortcutContext,
|
|
25
|
+
type ShellShortcutResolution,
|
|
26
|
+
type ShortcutKeyInput,
|
|
27
|
+
type SurfaceShortcutContext,
|
|
28
|
+
type SurfaceShortcutResolution,
|
|
29
|
+
} from "../../ui/runtime-shortcut-dispatch";
|
|
30
|
+
|
|
31
|
+
export type SurfaceResult =
|
|
32
|
+
| { kind: "dispatched"; surface: SurfaceShortcutResolution }
|
|
33
|
+
| { kind: "shell"; shell: ShellShortcutResolution }
|
|
34
|
+
| { kind: "pass-through" };
|
|
35
|
+
|
|
36
|
+
export interface SurfaceLayer {
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a keydown event. Consults the shell-level dispatcher first
|
|
39
|
+
* (Ctrl+F / Ctrl+Shift+E / F5 etc.) and falls back to the surface-level
|
|
40
|
+
* dispatcher (typing, arrows, Tab). Returns a typed `SurfaceResult` so
|
|
41
|
+
* callers can switch on the kind without reaching into either
|
|
42
|
+
* dispatcher's specific resolution types.
|
|
43
|
+
*/
|
|
44
|
+
resolveKeyDown(
|
|
45
|
+
input: ShortcutKeyInput,
|
|
46
|
+
shell: ShellShortcutContext,
|
|
47
|
+
surface: SurfaceShortcutContext,
|
|
48
|
+
): SurfaceResult;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default stateless SurfaceLayer instance. Safe to share across runtimes.
|
|
53
|
+
*/
|
|
54
|
+
export const surfaceLayer: SurfaceLayer = {
|
|
55
|
+
resolveKeyDown(input, shell, surface) {
|
|
56
|
+
const shellResolution = resolveShellShortcut(input, shell);
|
|
57
|
+
if (shellResolution.kind !== "none") {
|
|
58
|
+
return { kind: "shell", shell: shellResolution };
|
|
59
|
+
}
|
|
60
|
+
const surfaceResolution = resolveSurfaceShortcut(input, surface);
|
|
61
|
+
if (surfaceResolution.kind !== "none") {
|
|
62
|
+
return { kind: "dispatched", surface: surfaceResolution };
|
|
63
|
+
}
|
|
64
|
+
return { kind: "pass-through" };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -93,6 +93,35 @@ export function TwInlineToken(props: TwInlineTokenProps) {
|
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// V2c.5 — shape segment (rect/ellipse/text-box/etc). Lane 6d N10 will
|
|
97
|
+
// upgrade this to proper SVG/HTML shape rendering; for now we render a
|
|
98
|
+
// selectable chip so reviewers can see the shape exists in the doc.
|
|
99
|
+
if (segment.kind === "shape") {
|
|
100
|
+
const isTextBox = Boolean(segment.isTextBox);
|
|
101
|
+
const label = isTextBox && segment.txbxText ? segment.txbxText : segment.label;
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
tabIndex={-1}
|
|
106
|
+
onMouseDown={(e) => {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
props.onSelectionChange?.(createSelectionSnapshot(segment.from, segment.to));
|
|
109
|
+
}}
|
|
110
|
+
className={`inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs border-none cursor-pointer ${commentClass} text-secondary bg-surface ${
|
|
111
|
+
selected ? "ring-1 ring-accent/30" : ""
|
|
112
|
+
} ${focusRingClass}`}
|
|
113
|
+
title={segment.detail || segment.label}
|
|
114
|
+
data-segment-kind="shape"
|
|
115
|
+
data-shape-geometry={segment.geometry ?? ""}
|
|
116
|
+
>
|
|
117
|
+
{renderTwCaret(selection, segment.from)}
|
|
118
|
+
<span>{isTextBox ? "□" : "◇"}</span>
|
|
119
|
+
{label}
|
|
120
|
+
{renderTwCaret(selection, segment.to)}
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
// opaque_inline
|
|
97
126
|
if (segment.kind === "opaque_inline") {
|
|
98
127
|
if (segment.presentation === "quiet-marker") {
|
|
@@ -23,7 +23,13 @@ export function TwSegmentView(props: TwSegmentViewProps) {
|
|
|
23
23
|
const { segment, selection, markupDisplay } = props;
|
|
24
24
|
|
|
25
25
|
// Non-text segments delegate to TwInlineToken
|
|
26
|
-
if (
|
|
26
|
+
if (
|
|
27
|
+
segment.kind === "tab" ||
|
|
28
|
+
segment.kind === "hard_break" ||
|
|
29
|
+
segment.kind === "image" ||
|
|
30
|
+
segment.kind === "opaque_inline" ||
|
|
31
|
+
segment.kind === "shape"
|
|
32
|
+
) {
|
|
27
33
|
return (
|
|
28
34
|
<TwInlineToken
|
|
29
35
|
segment={segment}
|