From 26235c91b8c344947faa19722bd56f29970fdf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Mon, 26 Oct 2026 14:26:17 +0100 Subject: [PATCH 1/5] chore: initial commit From 3d9d14b81cc053a72bde81b720c64c1ace2a2349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Mon, 26 Oct 2026 14:36:53 +0100 Subject: [PATCH 2/5] docs(openspec): propose shape-label-native-engine (GH-1226) --- .../shape-label-native-engine/.openspec.yaml | 2 + .../shape-label-native-engine/design.md | 38 ++++++++++++ .../shape-label-native-engine/proposal.md | 30 +++++++++ .../specs/shape-label/engine/spec.md | 61 +++++++++++++++++++ .../shape-label-native-engine/tasks.md | 34 +++++++++++ 5 files changed, 165 insertions(+) create mode 100644 openspec/changes/shape-label-native-engine/.openspec.yaml create mode 100644 openspec/changes/shape-label-native-engine/design.md create mode 100644 openspec/changes/shape-label-native-engine/proposal.md create mode 100644 openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md create mode 100644 openspec/changes/shape-label-native-engine/tasks.md diff --git a/openspec/changes/shape-label-native-engine/.openspec.yaml b/openspec/changes/shape-label-native-engine/.openspec.yaml new file mode 100644 index 000000000..d58ba0232 --- /dev/null +++ b/openspec/changes/shape-label-native-engine/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-26 diff --git a/openspec/changes/shape-label-native-engine/design.md b/openspec/changes/shape-label-native-engine/design.md new file mode 100644 index 000000000..bbb197e5b --- /dev/null +++ b/openspec/changes/shape-label-native-engine/design.md @@ -0,0 +1,38 @@ +## Context + +`WeaveShapeLabelEditor` is reached by rectangle, ellipse and polygon through a lazy getter and six calls. It uses `Konva.Text`, a DOM textarea with CSS rotation, a pan listener, a user-select hack and `runLiveResizeLoop` (max 5 passes plus a final corrective grow). `TextEditSession` (#1231) takes a `TextEditTarget` (blocks, layout, absolute transform incl. rotation, content offset, size, `setBlocks`) and a `TextEditHost` (`onChange`, `onRequestExit`, caret/outline options). `WeaveTextV2Shape` has a Konva.Text-compatible facade. + +## Goals / Non-Goals + +**Goals:** +- Opt-in native label engine without any behavior change by default. +- Reuse text-v2 layout and session unchanged. + +**Non-Goals:** +- Schema changes, `labelBlocks`, rich-text toolbar for labels, shape geometry changes, removing legacy. + +## Decisions + +1. **Strategy seam.** Extract `ShapeLabelEngine` from the existing public methods; `WeaveShapeLabelEditor` implements it untouched (legacy). New `WeaveShapeLabelNativeEditor` implements it. Alternative (subclass or flag inside one class) rejected: mixes two lifecycles in 756 lines. +2. **Selection by node config**, default `legacy`. Mirrors node-level config used elsewhere (`config.transform`). Alternative persisted `labelEngine` prop rejected: schema leak and migration. +3. **Plain-text persistence.** Native wraps `labelText` in blocks for the session and flattens back on commit. Rich labels are a follow-up adding an additive field. +4. **Native target.** `getContentOffset` supplies padding and vertical align; `getAbsoluteTransform` supplies rotation; `setBlocks` re-lays out and calls `growCallback`. The convergence loop re-measures layout synchronously (grow -> re-layout -> compare), MAX 5 passes plus final corrective grow, same as legacy. +5. **Label node typing.** `shape-label.utils.ts` depends on a minimal `LabelNode` interface (text, height, clone, getClientRect) satisfied by both `Konva.Text` and `WeaveTextV2Shape`. +6. **Hide during edit.** Native hides the label node while a session runs unless the session paints content itself; verify against text-v2 behavior. +7. **`repositionTextArea`** is a no-op in native (overlay is on-canvas). + +## Risks / Trade-offs + +- `WeaveTextV2Shape` facade may differ from `Konva.Text` on `height(undefined)` auto-height, `clone`, `getClientRect` -> contract test run on both engines. +- Minor visual differences between engines on wrapping -> parity tests on plain text. +- Node backend painting of `WeaveTextV2Shape` unverified -> spike first (`shape.ts` export path). +- Two engines to maintain until deprecation. + +## Migration Plan + +None required; default unchanged. Deprecation of legacy is a later change. + +## Open Questions + +- Config property name and location (`label: { engine }` per node config). +- Do label edit events exist beyond mutex/stage mode? Audit before wiring. diff --git a/openspec/changes/shape-label-native-engine/proposal.md b/openspec/changes/shape-label-native-engine/proposal.md new file mode 100644 index 000000000..46defa9c8 --- /dev/null +++ b/openspec/changes/shape-label-native-engine/proposal.md @@ -0,0 +1,30 @@ +# Proposal + +## Why + +The shape label editor (`nodes/shared/shape-label-editor.ts`, used by rectangle, ellipse and polygon) is a second copy of the old text node's DOM textarea overlay. It carries the same overlay-vs-canvas misalignment class of bugs and the `enableStageTextSelection` user-select hack. The native-canvas text engine (`WeaveTextV2Shape` + `TextEditSession`) landed in #1231 and #1234, so labels can now use it. Existing apps must not be forced to migrate. + +## What Changes + +- Introduce a `ShapeLabelEngine` interface covering the calls shapes already make: `renderLabel`, `updateLabel`, `triggerEditMode`, `exitEditMode`, `isEditing`, `repositionTextArea`. +- Keep the current `WeaveShapeLabelEditor` as the **legacy** engine, behavior unchanged, and the **default**. +- Add a **native** engine: label rendered as `WeaveTextV2Shape`, edited via `TextEditSession`, no DOM overlay, rotation and vertical alignment from the session target, live shape growth through `setBlocks`/`onChange` with a bounded convergence loop. +- Add an optional per-node-type config on rectangle, ellipse and polygon to select the engine (`legacy` default, `native` opt-in). +- Make `shape-label.utils.ts` min-size helpers work against a shared label-node interface instead of `Konva.Text` only. +- No persisted schema change: both engines read/write `labelText` and the `label*` props. Native flattens to plain text on commit. +- No **BREAKING** changes. Legacy removal is deferred to a separate deprecation change. + +## Capabilities + +### New Capabilities +- `shape-label/engine`: engine selection, shared lifecycle contract (mutex, stage mode, selection plugin, commit/exit), and native-engine editing and rendering behavior for shape labels. + +### Modified Capabilities + +## Impact + +- Code: `code/packages/sdk/src/nodes/shared/shape-label-editor.ts`, `shape-label.utils.ts`, `shape-label.constants.ts`, `shape-label.types.ts`; `nodes/rectangle`, `nodes/ellipse`, `nodes/polygon` (config + engine wiring); new native engine file(s). +- Tests: existing `shape-label-editor.test.ts` and `shape-label.utils.test.ts` must pass unmodified on legacy; new native suite and shared contract test. +- Public API: additive config option and exported interface only. +- Docs: node pages for rectangle, ellipse, polygon (config option). +- Server-side export: labels must render in both engines (to verify for `WeaveTextV2Shape` in the node backend). diff --git a/openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md b/openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md new file mode 100644 index 000000000..267563e6a --- /dev/null +++ b/openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Selectable label engine +Rectangle, ellipse and polygon nodes SHALL accept an optional config selecting the label engine, `legacy` or `native`. When unset, the engine SHALL be `legacy`. + +#### Scenario: Default is legacy +- **WHEN** a shape node is created without a label engine config +- **THEN** its label renders as `Konva.Text` and edits through the DOM textarea overlay exactly as before + +#### Scenario: Opt in to native +- **WHEN** the node config sets the engine to `native` +- **THEN** its label renders with `WeaveTextV2Shape` and edits through `TextEditSession` + +### Requirement: No schema or contract change +Both engines SHALL read and write the same `labelText` and `label*` props, keep the label child id `labelId(id)`, the `'label-edit'` mutex operation and `WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE`. No new persisted field SHALL be added. + +#### Scenario: Mixed engines +- **WHEN** one client uses `legacy` and another `native` on the same document +- **THEN** both display and edit the same `labelText` + +### Requirement: Shared edit lifecycle +Both engines SHALL acquire the mutex, set the label edition stage mode, disable the nodes selection plugin while editing, and on exit release the lock, restore the default mode and re-select the shape. + +#### Scenario: Exit re-selects +- **WHEN** the user commits via Escape or outside click +- **THEN** the lock is released, `labelText` is updated through `updateNode`, and the shape is selected + +### Requirement: Native engine has no DOM overlay +In `native`, the editor SHALL NOT create a DOM element mimicking the label text; caret and selection SHALL be drawn on the utility layer and the proxy input SHALL be the only DOM element. + +#### Scenario: Rotated shape +- **WHEN** a rotated shape is edited +- **THEN** caret, selection and text align with the rendered label with no CSS transform + +### Requirement: Native label bounds and growth +In `native`, the label SHALL stay inside the shape's text bounds with `labelPadding*` and `labelVerticalAlign` applied. While typing, the shape SHALL grow via the existing growth callback and SHALL NOT shrink below its original size; growth SHALL converge in a bounded number of passes. + +#### Scenario: Typing overflows +- **WHEN** typed text needs more height than the text bounds +- **THEN** the shape grows to fit and shrinks back, down to its original height, when text is removed + +### Requirement: Native commit flattens to plain text +On commit the native engine SHALL persist the plain-text content as `labelText`. + +#### Scenario: Commit +- **WHEN** edit ends with changed content +- **THEN** `labelText` equals the plain text of the edited blocks + +### Requirement: Label min-size helpers are engine-agnostic +The min-size helpers SHALL work with labels from either engine. + +#### Scenario: Native label min size +- **WHEN** the min size is computed for a shape with a native label +- **THEN** the result matches the legacy result for the same plain text and typography + +### Requirement: Server-side export renders labels +Export SHALL render labels in both engines. + +#### Scenario: Export with native label +- **WHEN** a shape with a native label is exported server-side +- **THEN** the label text appears in the output diff --git a/openspec/changes/shape-label-native-engine/tasks.md b/openspec/changes/shape-label-native-engine/tasks.md new file mode 100644 index 000000000..e4cdae111 --- /dev/null +++ b/openspec/changes/shape-label-native-engine/tasks.md @@ -0,0 +1,34 @@ +## 1. Spikes + +- [ ] 1.1 Verify `WeaveTextV2Shape` paints in the node backend export path and supports `height(undefined)`, `clone`, `getClientRect` like `Konva.Text` +- [ ] 1.2 Audit label-related events and public exports of the label editor + +## 2. Seam + +- [ ] 2.1 Define `ShapeLabelEngine` and `LabelNode` interfaces in `shape-label.types.ts` +- [ ] 2.2 Make `WeaveShapeLabelEditor` implement the interface with no behavior change +- [ ] 2.3 Type `shape-label.utils.ts` helpers against `LabelNode` +- [ ] 2.4 Confirm existing `shape-label-editor.test.ts` and `shape-label.utils.test.ts` pass unmodified + +## 3. Native engine + +- [ ] 3.1 Implement `WeaveShapeLabelNativeEditor` render/update using `WeaveTextV2Shape` with `labelId(id)` and `label*` props +- [ ] 3.2 Build the `TextEditTarget` (blocks from `labelText`, content offset, transform, `setBlocks` with grow callback) +- [ ] 3.3 Wire `TextEditSession` host: mutex `'label-edit'`, stage mode, selection plugin disable, Escape/outside exit, re-select on exit +- [ ] 3.4 Implement bounded grow/shrink convergence loop with original-height floor +- [ ] 3.5 Commit flattens blocks to `labelText`; `repositionTextArea` no-op + +## 4. Wiring + +- [ ] 4.1 Add optional engine config (default `legacy`) to rectangle, ellipse, polygon and select the engine in each +- [ ] 4.2 Export new types additively + +## 5. Tests + +- [ ] 5.1 Shared contract test run against both engines +- [ ] 5.2 Native suite: no DOM overlay, rotation, vertical align, growth/shrink, commit, mixed-engine `labelText` +- [ ] 5.3 Server-side export test for both engines + +## 6. Docs + +- [ ] 6.1 Document the engine option on rectangle, ellipse and polygon pages From c59d26082c37d2c43f67d3a78067f1d145174774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Mon, 26 Oct 2026 15:44:28 +0100 Subject: [PATCH 3/5] feat(sdk): opt-in native label engine for shape labels (GH-1226) Rectangle, ellipse and polygon can select config.label.engine ('native') to render labels with the native-canvas text engine and edit them with TextEditSession, with rich text and optional labelBlocks. 'legacy' stays the default. Also stop the text-v2 Enter keypress handler from cancelling Enter typed in other editors, and sync the OpenSpec specs and archives (shape-label engine, text-v2 rich text). --- AGENTS.md | 3 + code/CHANGELOG.md | 1 + .../packages/sdk/src/nodes/ellipse/ellipse.ts | 22 +- code/packages/sdk/src/nodes/ellipse/types.ts | 4 + .../packages/sdk/src/nodes/polygon/polygon.ts | 31 +- code/packages/sdk/src/nodes/polygon/types.ts | 3 + .../sdk/src/nodes/rectangle/rectangle.ts | 22 +- .../packages/sdk/src/nodes/rectangle/types.ts | 4 + .../__tests__/shape-label-engines.test.ts | 312 ++++++++++++++ .../__tests__/shape-label-server.test.ts | 53 +++ .../src/nodes/shared/shape-label-editor.ts | 7 +- .../nodes/shared/shape-label-native-editor.ts | 381 ++++++++++++++++++ .../src/nodes/shared/shape-label.constants.ts | 2 +- .../src/nodes/shared/shape-label.engine.ts | 25 ++ .../sdk/src/nodes/shared/shape-label.types.ts | 64 +++ .../sdk/src/nodes/shared/shape-label.utils.ts | 49 ++- .../text-v2/__tests__/enter-keypress.test.ts | 59 +++ .../packages/sdk/src/nodes/text-v2/text-v2.ts | 20 +- .../pages/api-reference/nodes/ellipse.adoc | 28 ++ .../pages/api-reference/nodes/polygon.adoc | 35 ++ .../pages/api-reference/nodes/rectangle.adoc | 28 ++ .../.openspec.yaml | 0 .../design.md | 4 +- .../proposal.md | 4 +- .../specs/shape-label/engine/spec.md | 29 +- .../tasks.md | 41 ++ .../shape-label-native-engine/tasks.md | 34 -- openspec/specs/shape-label/engine/spec.md | 80 ++++ 28 files changed, 1273 insertions(+), 72 deletions(-) create mode 100644 code/packages/sdk/src/nodes/shared/__tests__/shape-label-engines.test.ts create mode 100644 code/packages/sdk/src/nodes/shared/__tests__/shape-label-server.test.ts create mode 100644 code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts create mode 100644 code/packages/sdk/src/nodes/shared/shape-label.engine.ts create mode 100644 code/packages/sdk/src/nodes/text-v2/__tests__/enter-keypress.test.ts rename openspec/changes/{shape-label-native-engine => archive/2026-10-26-shape-label-native-engine}/.openspec.yaml (100%) rename openspec/changes/{shape-label-native-engine => archive/2026-10-26-shape-label-native-engine}/design.md (85%) rename openspec/changes/{shape-label-native-engine => archive/2026-10-26-shape-label-native-engine}/proposal.md (88%) rename openspec/changes/{shape-label-native-engine => archive/2026-10-26-shape-label-native-engine}/specs/shape-label/engine/spec.md (65%) create mode 100644 openspec/changes/archive/2026-10-26-shape-label-native-engine/tasks.md delete mode 100644 openspec/changes/shape-label-native-engine/tasks.md create mode 100644 openspec/specs/shape-label/engine/spec.md diff --git a/AGENTS.md b/AGENTS.md index 99b5e6869..c8585e4d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,3 +119,6 @@ npx skills@latest add InditexTech/docouture --all | sdk:api-reference/nodes/text-v2.adoc | `code/packages/sdk/src/nodes/text-v2/*` | updated for unreleased changes | | main:build/actions/text-tool.adoc | `code/packages/sdk/src/actions/text-tool/text-tool.ts` | updated for unreleased changes | | sdk:api-reference/actions/text-tool.adoc | `code/packages/sdk/src/actions/text-tool/text-tool.ts` | updated for unreleased changes | +| sdk:api-reference/nodes/rectangle.adoc | `code/packages/sdk/src/nodes/rectangle/types.ts`, `code/packages/sdk/src/nodes/shared/shape-label*.ts` | updated for unreleased changes | +| sdk:api-reference/nodes/ellipse.adoc | `code/packages/sdk/src/nodes/ellipse/types.ts`, `code/packages/sdk/src/nodes/shared/shape-label*.ts` | updated for unreleased changes | +| sdk:api-reference/nodes/polygon.adoc | `code/packages/sdk/src/nodes/polygon/types.ts`, `code/packages/sdk/src/nodes/shared/shape-label*.ts` | updated for unreleased changes | diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md index 384b9a985..e50d95aa8 100644 --- a/code/CHANGELOG.md +++ b/code/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#1226](https://github.com/InditexTech/weavejs/issues/1226) Shape label: opt-in `native` label engine (`config.label.engine`) for rectangle, ellipse and polygon, using the native-canvas text engine and editor session with rich text (optional `labelBlocks` prop). The `legacy` engine stays the default - [#1225](https://github.com/InditexTech/weavejs/issues/1225) Text node: rich text formatting, enabled via node config ### Fixed diff --git a/code/packages/sdk/src/nodes/ellipse/ellipse.ts b/code/packages/sdk/src/nodes/ellipse/ellipse.ts index 3098bdc05..f748f0c1d 100644 --- a/code/packages/sdk/src/nodes/ellipse/ellipse.ts +++ b/code/packages/sdk/src/nodes/ellipse/ellipse.ts @@ -14,22 +14,23 @@ import { WEAVE_ELLIPSE_NODE_TYPE } from './constants'; import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import type { WeaveEllipseNodeParams, WeaveEllipseProperties } from './types'; import { mergeExceptArrays } from '@/utils/utils'; -import { WeaveShapeLabelEditor } from '@/nodes/shared/shape-label-editor'; +import { createShapeLabelEngine } from '@/nodes/shared/shape-label.engine'; +import type { WeaveShapeLabelEngine } from '@/nodes/shared/shape-label.types'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS, } from '@/nodes/shared/shape-label.constants'; -import { computeEllipseLabelMinSize, spreadLabelProps, getShapeLabelSchemaFields } from '@/nodes/shared/shape-label.utils'; +import { computeEllipseLabelMinSize, spreadLabelProps, getShapeLabelSchemaFields, applyLabelBlocksState } from '@/nodes/shared/shape-label.utils'; export class WeaveEllipseNode extends WeaveNode { private config: WeaveEllipseProperties; protected nodeType: string = WEAVE_ELLIPSE_NODE_TYPE; initialize = undefined; - private _shapeLabelEditor: WeaveShapeLabelEditor | undefined; + private _shapeLabelEditor: WeaveShapeLabelEngine | undefined; private _transforming = false; - private get shapeLabelEditor(): WeaveShapeLabelEditor { - return (this._shapeLabelEditor ??= new WeaveShapeLabelEditor(this.instance)); + private get shapeLabelEditor(): WeaveShapeLabelEngine { + return (this._shapeLabelEditor ??= createShapeLabelEngine(this.instance, this.config.label)); } constructor(params?: WeaveEllipseNodeParams) { @@ -41,6 +42,7 @@ export class WeaveEllipseNode extends WeaveNode { transform: { ...config?.transform, }, + label: { ...config?.label }, }; } @@ -188,15 +190,20 @@ export class WeaveEllipseNode extends WeaveNode { return; } - const onCommit = (labelText: string) => { + const onCommit = ( + labelText: string, + labelBlocks?: WeaveElementAttributes['labelBlocks'] + ) => { const updatedGroup = this.instance .getStage() .findOne(`#${props.id}`); if (!updatedGroup) { return; } + updatedGroup.setAttr('labelBlocks', labelBlocks); const serialized = this.serialize(updatedGroup); serialized.props.labelText = labelText; + serialized.props.labelBlocks = labelBlocks; this.instance.updateNode(serialized); }; @@ -452,7 +459,7 @@ export class WeaveEllipseNode extends WeaveNode { prevNodeState: WeaveStateElement, nextProps: WeaveElementAttributes ): WeaveStateElement { - return mergeExceptArrays(prevNodeState, { + const next = mergeExceptArrays(prevNodeState, { props: { x: nextProps.x, y: nextProps.y, @@ -467,6 +474,7 @@ export class WeaveEllipseNode extends WeaveNode { ...spreadLabelProps(nextProps), }, }); + return applyLabelBlocksState(next, nextProps); } static getSchema() { diff --git a/code/packages/sdk/src/nodes/ellipse/types.ts b/code/packages/sdk/src/nodes/ellipse/types.ts index b4c93e68f..dbd87de92 100644 --- a/code/packages/sdk/src/nodes/ellipse/types.ts +++ b/code/packages/sdk/src/nodes/ellipse/types.ts @@ -6,10 +6,14 @@ import { type WeaveNodeTransformerProperties } from '@inditextech/weave-types'; export type WeaveEllipseProperties = { transform: WeaveNodeTransformerProperties; + /** Label engine; defaults to `legacy`. */ + label?: Partial; }; export type WeaveEllipseNodeParams = { config: Partial; }; +import type { WeaveShapeLabelConfig } from '@/nodes/shared/shape-label.types'; + export type { WeaveShapeLabelProps } from '@/nodes/shared/shape-label.types'; diff --git a/code/packages/sdk/src/nodes/polygon/polygon.ts b/code/packages/sdk/src/nodes/polygon/polygon.ts index 8f6f5d587..2aeb396e0 100644 --- a/code/packages/sdk/src/nodes/polygon/polygon.ts +++ b/code/packages/sdk/src/nodes/polygon/polygon.ts @@ -14,7 +14,12 @@ import { WEAVE_POLYGON_NODE_TYPE } from './constants'; import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import type { WeavePolygonNodeParams, WeavePolygonProperties, WeavePolygonPoint, WeavePolygonInnerRect } from './types'; import { mergeExceptArrays } from '@/utils/utils'; -import { WeaveShapeLabelEditor } from '@/nodes/shared/shape-label-editor'; +import { + applyLabelBlocksState, + getShapeLabelSchemaFields, +} from '@/nodes/shared/shape-label.utils'; +import { createShapeLabelEngine } from '@/nodes/shared/shape-label.engine'; +import type { WeaveShapeLabelEngine } from '@/nodes/shared/shape-label.types'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS, @@ -123,11 +128,11 @@ export class WeavePolygonNode extends WeaveNode { private readonly config: WeavePolygonProperties; protected nodeType: string = WEAVE_POLYGON_NODE_TYPE; initialize = undefined; - private _shapeLabelEditor: WeaveShapeLabelEditor | undefined; + private _shapeLabelEditor: WeaveShapeLabelEngine | undefined; private _transforming = false; - private get shapeLabelEditor(): WeaveShapeLabelEditor { - this._shapeLabelEditor ??= new WeaveShapeLabelEditor(this.instance); + private get shapeLabelEditor(): WeaveShapeLabelEngine { + this._shapeLabelEditor ??= createShapeLabelEngine(this.instance, this.config.label); return this._shapeLabelEditor; } @@ -138,6 +143,7 @@ export class WeavePolygonNode extends WeaveNode { this.config = { transform: { ...config?.transform }, + label: { ...config?.label }, }; } @@ -248,13 +254,18 @@ export class WeavePolygonNode extends WeaveNode { polygon: Konva.Group, props: WeaveElementAttributes ): void { - const onCommit = (labelText: string) => { + const onCommit = ( + labelText: string, + labelBlocks?: WeaveElementAttributes['labelBlocks'] + ) => { const updatedGroup = this.instance .getStage() .findOne(`#${props.id}`); if (!updatedGroup) return; + updatedGroup.setAttr('labelBlocks', labelBlocks); const serialized = this.serialize(updatedGroup); serialized.props.labelText = labelText; + serialized.props.labelBlocks = labelBlocks; this.instance.updateNode(serialized); }; @@ -605,6 +616,9 @@ export class WeavePolygonNode extends WeaveNode { strokeWidth: props.strokeWidth, }), ...(props.labelText !== undefined && { labelText: props.labelText }), + ...(props.labelBlocks !== undefined && { + labelBlocks: props.labelBlocks, + }), ...(props.labelFontFamily !== undefined && { labelFontFamily: props.labelFontFamily, }), @@ -642,7 +656,7 @@ export class WeavePolygonNode extends WeaveNode { prevNodeState: WeaveStateElement, nextProps: WeaveElementAttributes ): WeaveStateElement { - return mergeExceptArrays(prevNodeState, { + const next = mergeExceptArrays(prevNodeState, { props: { x: nextProps.x, y: nextProps.y, @@ -660,6 +674,9 @@ export class WeavePolygonNode extends WeaveNode { ...(nextProps.labelText !== undefined && { labelText: nextProps.labelText, }), + ...(nextProps.labelBlocks !== undefined && { + labelBlocks: nextProps.labelBlocks, + }), ...(nextProps.labelFontFamily !== undefined && { labelFontFamily: nextProps.labelFontFamily, }), @@ -695,6 +712,7 @@ export class WeavePolygonNode extends WeaveNode { }), }, }); + return applyLabelBlocksState(next, nextProps); } static getSchema() { @@ -768,6 +786,7 @@ export class WeavePolygonNode extends WeaveNode { 'Whether the polygon stroke width should scale when the node is scaled. Defaults to false.' ), + labelBlocks: getShapeLabelSchemaFields().labelBlocks, labelText: z .string() .optional() diff --git a/code/packages/sdk/src/nodes/polygon/types.ts b/code/packages/sdk/src/nodes/polygon/types.ts index 2445ca487..289759e82 100644 --- a/code/packages/sdk/src/nodes/polygon/types.ts +++ b/code/packages/sdk/src/nodes/polygon/types.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WeaveNodeTransformerProperties } from '@inditextech/weave-types'; +import type { WeaveShapeLabelConfig } from '@/nodes/shared/shape-label.types'; export type { WeaveShapeLabelProps } from '@/nodes/shared/shape-label.types'; @@ -17,6 +18,8 @@ export type WeavePolygonInnerRect = { export type WeavePolygonProperties = { transform: WeaveNodeTransformerProperties; + /** Label engine; defaults to `legacy`. */ + label?: Partial; }; export type WeavePolygonNodeParams = { diff --git a/code/packages/sdk/src/nodes/rectangle/rectangle.ts b/code/packages/sdk/src/nodes/rectangle/rectangle.ts index f13dbbfdd..776e29224 100644 --- a/code/packages/sdk/src/nodes/rectangle/rectangle.ts +++ b/code/packages/sdk/src/nodes/rectangle/rectangle.ts @@ -17,22 +17,23 @@ import type { WeaveRectangleProperties, } from './types'; import { mergeExceptArrays } from '@/index'; -import { WeaveShapeLabelEditor } from '@/nodes/shared/shape-label-editor'; +import { createShapeLabelEngine } from '@/nodes/shared/shape-label.engine'; +import type { WeaveShapeLabelEngine } from '@/nodes/shared/shape-label.types'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS, } from '@/nodes/shared/shape-label.constants'; -import { computeRectangleLabelMinSize, spreadLabelProps, getShapeLabelSchemaFields } from '@/nodes/shared/shape-label.utils'; +import { computeRectangleLabelMinSize, spreadLabelProps, getShapeLabelSchemaFields, applyLabelBlocksState } from '@/nodes/shared/shape-label.utils'; export class WeaveRectangleNode extends WeaveNode { private config: WeaveRectangleProperties; protected nodeType: string = WEAVE_RECTANGLE_NODE_TYPE; initialize = undefined; - private _shapeLabelEditor: WeaveShapeLabelEditor | undefined; + private _shapeLabelEditor: WeaveShapeLabelEngine | undefined; private _transforming = false; - private get shapeLabelEditor(): WeaveShapeLabelEditor { - return (this._shapeLabelEditor ??= new WeaveShapeLabelEditor(this.instance)); + private get shapeLabelEditor(): WeaveShapeLabelEngine { + return (this._shapeLabelEditor ??= createShapeLabelEngine(this.instance, this.config.label)); } constructor(params?: WeaveRectangleNodeParams) { @@ -44,6 +45,7 @@ export class WeaveRectangleNode extends WeaveNode { transform: { ...config?.transform, }, + label: { ...config?.label }, }; } @@ -142,15 +144,20 @@ export class WeaveRectangleNode extends WeaveNode { return; } - const onCommit = (labelText: string) => { + const onCommit = ( + labelText: string, + labelBlocks?: WeaveElementAttributes['labelBlocks'] + ) => { const updatedGroup = this.instance .getStage() .findOne(`#${props.id}`); if (!updatedGroup) { return; } + updatedGroup.setAttr('labelBlocks', labelBlocks); const serialized = this.serialize(updatedGroup); serialized.props.labelText = labelText; + serialized.props.labelBlocks = labelBlocks; this.instance.updateNode(serialized); }; @@ -348,7 +355,7 @@ export class WeaveRectangleNode extends WeaveNode { prevNodeState: WeaveStateElement, nextProps: WeaveElementAttributes ): WeaveStateElement { - return mergeExceptArrays(prevNodeState, { + const next = mergeExceptArrays(prevNodeState, { props: { x: nextProps.x, y: nextProps.y, @@ -363,6 +370,7 @@ export class WeaveRectangleNode extends WeaveNode { ...spreadLabelProps(nextProps), }, }); + return applyLabelBlocksState(next, nextProps); } static getSchema() { diff --git a/code/packages/sdk/src/nodes/rectangle/types.ts b/code/packages/sdk/src/nodes/rectangle/types.ts index 1a5041044..c0e079f16 100644 --- a/code/packages/sdk/src/nodes/rectangle/types.ts +++ b/code/packages/sdk/src/nodes/rectangle/types.ts @@ -6,10 +6,14 @@ import { type WeaveNodeTransformerProperties } from '@inditextech/weave-types'; export type WeaveRectangleProperties = { transform: WeaveNodeTransformerProperties; + /** Label engine; defaults to `legacy`. */ + label?: Partial; }; export type WeaveRectangleNodeParams = { config: Partial; }; +import type { WeaveShapeLabelConfig } from '@/nodes/shared/shape-label.types'; + export type { WeaveShapeLabelProps } from '@/nodes/shared/shape-label.types'; diff --git a/code/packages/sdk/src/nodes/shared/__tests__/shape-label-engines.test.ts b/code/packages/sdk/src/nodes/shared/__tests__/shape-label-engines.test.ts new file mode 100644 index 000000000..b2081e4a9 --- /dev/null +++ b/code/packages/sdk/src/nodes/shared/__tests__/shape-label-engines.test.ts @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +// @vitest-environment jsdom + +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import Konva from 'konva'; +import { WeaveShapeLabelEditor } from '../shape-label-editor'; +import { WeaveShapeLabelNativeEditor } from '../shape-label-native-editor'; +import { createShapeLabelEngine } from '../shape-label.engine'; +import { computeRectangleLabelMinSize } from '../shape-label.utils'; +import { + labelId, + WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE, +} from '../shape-label.constants'; +import type { WeaveShapeLabelEngine } from '../shape-label.types'; +import { augmentKonvaNodeClass } from '../../node'; +import { WeaveTextV2Shape } from '../../text-v2/shape'; + +vi.mock('@/weave', () => ({ Weave: class MockWeave {} })); + +function setup() { + const container = document.createElement('div'); + document.body.appendChild(container); + const stage = new Konva.Stage({ container, width: 600, height: 400 }); + const layer = new Konva.Layer(); + const utility = new Konva.Layer(); + stage.add(layer, utility); + const modes: string[] = []; + (stage as unknown as { mode: (m?: string) => void }).mode = (m?: string) => { + if (m) modes.push(m); + }; + const tr = { hide: vi.fn() }; + const instance = { + getStage: () => stage, + getUtilityLayer: () => utility, + getPlugin: vi + .fn() + .mockReturnValue({ getTransformer: () => tr, setSelectedNodes: vi.fn() }), + setMutexLock: vi.fn().mockReturnValue(true), + releaseMutexLock: vi.fn(), + disablePlugin: vi.fn(), + enablePlugin: vi.fn(), + getEventsController: () => new AbortController(), + getFonts: () => [], + emitEvent: vi.fn(), + }; + const group = new Konva.Group({ id: 'shape', labelText: 'Hello', x: 10, y: 10, width: 200, height: 100 }); + layer.add(group); + return { container, stage, layer, group, instance, modes }; +} + +const bounds = { x: 8, y: 8, width: 184, height: 84 }; +const props = { id: 'shape', labelText: 'Hello' }; + +beforeAll(() => { + augmentKonvaNodeClass(); +}); + +let containers: HTMLElement[] = []; +afterEach(() => { + containers.forEach((c) => c.remove()); + containers = []; +}); + +type Factory = (inst: ReturnType['instance']) => WeaveShapeLabelEngine; +const engines: [string, Factory][] = [ + ['legacy', (i) => new WeaveShapeLabelEditor(i as never)], + ['native', (i) => new WeaveShapeLabelNativeEditor(i as never)], +]; + +describe.each(engines)('shape label engine contract: %s', (_name, make) => { + it('renders the label child with labelId(id) and the label text', () => { + const s = setup(); + containers.push(s.container); + const engine = make(s.instance); + const node = engine.renderLabel(s.group, props, bounds); + expect(node.id()).toBe(labelId('shape')); + expect(node.getAttr('text')).toBe('Hello'); + expect(node.visible()).toBe(true); + expect(s.group.findOne(`#${labelId('shape')}`)).toBe(node); + }); + + it('hides an empty label', () => { + const s = setup(); + containers.push(s.container); + const node = make(s.instance).renderLabel( + s.group, + { ...props, labelText: '' }, + bounds + ); + expect(node.visible()).toBe(false); + }); + + it('computes min size from the label node', () => { + const s = setup(); + containers.push(s.container); + make(s.instance).renderLabel(s.group, props, bounds); + const size = computeRectangleLabelMinSize(s.stage, s.group); + expect(size.width).toBeGreaterThan(0); + expect(size.height).toBeGreaterThan(0); + }); + + it('acquires the label-edit lock, sets the mode and releases on exit', () => { + const s = setup(); + containers.push(s.container); + const engine = make(s.instance); + engine.renderLabel(s.group, props, bounds); + engine.triggerEditMode(s.group, bounds, vi.fn()); + expect(engine.isEditing()).toBe(true); + expect(s.instance.setMutexLock).toHaveBeenCalledWith({ + nodeIds: ['shape'], + operation: 'label-edit', + }); + expect(s.modes).toContain(WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE); + expect(s.instance.disablePlugin).toHaveBeenCalledWith('nodesSelection'); + engine.exitEditMode(); + expect(engine.isEditing()).toBe(false); + expect(s.instance.releaseMutexLock).toHaveBeenCalled(); + expect(s.instance.enablePlugin).toHaveBeenCalledWith('nodesSelection'); + }); + + it('does not enter edit when the lock is refused', () => { + const s = setup(); + containers.push(s.container); + s.instance.setMutexLock.mockReturnValue(false); + const engine = make(s.instance); + engine.renderLabel(s.group, props, bounds); + engine.triggerEditMode(s.group, bounds, vi.fn()); + expect(engine.isEditing()).toBe(false); + }); +}); + +describe('createShapeLabelEngine', () => { + it('defaults to legacy', () => { + const s = setup(); + containers.push(s.container); + expect(createShapeLabelEngine(s.instance as never)).toBeInstanceOf( + WeaveShapeLabelEditor + ); + expect( + createShapeLabelEngine(s.instance as never, { engine: 'legacy' }) + ).toBeInstanceOf(WeaveShapeLabelEditor); + }); + + it('returns native when configured', () => { + const s = setup(); + containers.push(s.container); + expect( + createShapeLabelEngine(s.instance as never, { engine: 'native' }) + ).toBeInstanceOf(WeaveShapeLabelNativeEditor); + }); +}); + +describe('legacy engine rendering is unchanged', () => { + it('renders a Konva.Text', () => { + const s = setup(); + containers.push(s.container); + const node = new WeaveShapeLabelEditor(s.instance as never).renderLabel( + s.group, + props, + bounds + ); + expect(node).toBeInstanceOf(Konva.Text); + }); +}); + +describe('native engine', () => { + function native() { + const s = setup(); + containers.push(s.container); + const engine = new WeaveShapeLabelNativeEditor(s.instance as never); + const node = engine.renderLabel(s.group, props, bounds); + return { s, engine, node }; + } + + it('renders a WeaveTextV2Shape with label typography', () => { + const s = setup(); + containers.push(s.container); + const node = new WeaveShapeLabelNativeEditor(s.instance as never).renderLabel( + s.group, + { ...props, labelFontSize: 20, labelAlign: 'left', labelVerticalAlign: 'top' }, + bounds + ); + expect(node).toBeInstanceOf(WeaveTextV2Shape); + expect(node.fontSize()).toBe(20); + expect(node.align()).toBe('left'); + expect(node.verticalAlign()).toBe('top'); + }); + + it('enables rich text and persists rich content on commit', () => { + const { s, engine } = native(); + const onCommit = vi.fn(); + engine.triggerEditMode(s.group, bounds, onCommit); + const ta = s.container.querySelector('textarea') as HTMLTextAreaElement; + ta.value = 'Hi'; + ta.setSelectionRange(2, 2); + ta.dispatchEvent(new InputEvent('input', { inputType: 'insertText', bubbles: true })); + ta.setSelectionRange(0, 2); + ta.dispatchEvent(new Event('select')); + ta.dispatchEvent( + new KeyboardEvent('keydown', { key: 'b', metaKey: true, ctrlKey: true, bubbles: true, cancelable: true }) + ); + ta.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + expect(onCommit).toHaveBeenCalledTimes(1); + const [text, blocks] = onCommit.mock.calls[0]; + expect(text).toBe('Hi'); + expect(blocks?.[0].runs[0].m?.bold).toBe(true); + }); + + it('renders stored labelBlocks and ignores them when stale', () => { + const s = setup(); + containers.push(s.container); + const engine = new WeaveShapeLabelNativeEditor(s.instance as never); + const blocks = [{ runs: [{ t: 'Hello', m: { bold: true } }] }]; + const node = engine.renderLabel( + s.group, + { id: 'shape', labelText: 'Hello', labelBlocks: blocks }, + bounds + ); + expect(node.getBlocks()[0].runs[0].m?.bold).toBe(true); + engine.updateLabel( + s.group, + { id: 'shape', labelText: 'Changed', labelBlocks: blocks }, + bounds + ); + expect(node.getBlocks()[0].runs[0].m?.bold).toBeUndefined(); + expect(node.text()).toBe('Changed'); + }); + + it('creates no label DOM overlay, only the proxy textarea', () => { + const { s, engine } = native(); + engine.triggerEditMode(s.group, bounds, vi.fn()); + expect(s.container.querySelector('#shape_label_textarea')).toBeNull(); + expect(s.container.querySelectorAll('textarea')).toHaveLength(1); + engine.exitEditMode(); + expect(s.container.querySelectorAll('textarea')).toHaveLength(0); + }); + + it('flattens to plain text on Escape commit', () => { + const { s, engine } = native(); + const onCommit = vi.fn(); + engine.triggerEditMode(s.group, bounds, onCommit); + const ta = s.container.querySelector('textarea') as HTMLTextAreaElement; + ta.value = 'Hello world'; + ta.setSelectionRange(11, 11); + ta.dispatchEvent(new InputEvent('input', { inputType: 'insertText', bubbles: true })); + ta.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + expect(onCommit).toHaveBeenCalledWith('Hello world', undefined); + expect(engine.isEditing()).toBe(false); + expect(s.instance.releaseMutexLock).toHaveBeenCalled(); + }); + + it('grows while typing and restores down to the original size', () => { + const { s, engine } = native(); + const heights: number[] = []; + const original = 100; + engine.triggerEditMode(s.group, bounds, vi.fn(), (needed) => { + heights.push(Math.max(needed, original)); + }); + const ta = s.container.querySelector('textarea') as HTMLTextAreaElement; + const type = (v: string) => { + ta.value = v; + ta.setSelectionRange(v.length, v.length); + ta.dispatchEvent(new InputEvent('input', { inputType: 'insertText', bubbles: true })); + }; + type(Array.from({ length: 30 }, () => 'line').join('\n')); + expect(Math.max(...heights)).toBeGreaterThan(original); + type('x'); + expect(heights.at(-1)).toBe(original); + engine.exitEditMode(); + }); + + it('does not overwrite session content on update while editing', () => { + const { s, engine, node } = native(); + engine.triggerEditMode(s.group, bounds, vi.fn()); + const ta = s.container.querySelector('textarea') as HTMLTextAreaElement; + ta.value = 'edited'; + ta.setSelectionRange(6, 6); + ta.dispatchEvent(new InputEvent('input', { inputType: 'insertText', bubbles: true })); + engine.updateLabel(s.group, props, bounds); + expect(node.text()).toBe('edited'); + engine.exitEditMode(); + }); + + it('repositions the label bounds through repositionTextArea', () => { + const { s, engine, node } = native(); + engine.triggerEditMode(s.group, bounds, vi.fn()); + engine.repositionTextArea(s.group, { x: 20, y: 30, width: 100, height: 40 }); + expect(node.x()).toBe(20); + expect(node.y()).toBe(30); + expect(node.width()).toBe(100); + engine.exitEditMode(); + }); + + it('keeps min size equal to legacy for the same plain text', () => { + const a = setup(); + const b = setup(); + containers.push(a.container, b.container); + new WeaveShapeLabelEditor(a.instance as never).renderLabel(a.group, props, bounds); + new WeaveShapeLabelNativeEditor(b.instance as never).renderLabel(b.group, props, bounds); + const la = computeRectangleLabelMinSize(a.stage, a.group); + const lb = computeRectangleLabelMinSize(b.stage, b.group); + expect(lb.width).toBe(la.width); + expect(Math.abs(lb.height - la.height)).toBeLessThan(8); + }); +}); diff --git a/code/packages/sdk/src/nodes/shared/__tests__/shape-label-server.test.ts b/code/packages/sdk/src/nodes/shared/__tests__/shape-label-server.test.ts new file mode 100644 index 000000000..baedd178f --- /dev/null +++ b/code/packages/sdk/src/nodes/shared/__tests__/shape-label-server.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +// @vitest-environment node + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@/weave', () => ({ Weave: class MockWeave {} })); + +// skia-canvas is an optional peer dependency: skip when not installed. +const hasSkia = await import('skia-canvas').then( + () => true, + () => false +); + +describe.skipIf(!hasSkia)('shape labels on the skia-canvas backend', () => { + it.each(['legacy', 'native'] as const)( + 'paints the label ink with the %s engine', + async (engineKind) => { + await import('konva/skia-backend'); + const { default: Konva } = await import('konva'); + const { createShapeLabelEngine } = await import('../shape-label.engine'); + + const stage = new Konva.Stage({ width: 220, height: 120 }); + const layer = new Konva.Layer(); + stage.add(layer); + const group = new Konva.Group({ id: 'shape', width: 200, height: 100 }); + layer.add(group); + + const engine = createShapeLabelEngine({} as never, { engine: engineKind }); + engine.renderLabel( + group, + { id: 'shape', labelText: 'Exported label', labelFontSize: 20 }, + { x: 8, y: 8, width: 184, height: 84 } + ); + layer.draw(); + + const data = ( + layer.getCanvas()._canvas as unknown as { + getContext(t: string): { + getImageData(...a: number[]): { data: Uint8ClampedArray }; + }; + } + ) + .getContext('2d') + .getImageData(0, 0, 220, 120).data; + let ink = 0; + for (let i = 3; i < data.length; i += 4) if (data[i] > 0) ink++; + expect(ink).toBeGreaterThan(50); + } + ); +}); diff --git a/code/packages/sdk/src/nodes/shared/shape-label-editor.ts b/code/packages/sdk/src/nodes/shared/shape-label-editor.ts index 1adf92a18..26daffbe3 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label-editor.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label-editor.ts @@ -13,9 +13,12 @@ import { WEAVE_SHAPE_LABEL_DEFAULTS, WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE, } from './shape-label.constants'; -import type { WeaveShapeLabelTextBounds } from './shape-label.types'; +import type { + WeaveShapeLabelEngine, + WeaveShapeLabelTextBounds, +} from './shape-label.types'; -export class WeaveShapeLabelEditor { +export class WeaveShapeLabelEditor implements WeaveShapeLabelEngine { private readonly instance: Weave; private editing: boolean = false; private editingGroup: Konva.Group | null = null; diff --git a/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts b/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts new file mode 100644 index 000000000..3aa08c7ba --- /dev/null +++ b/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import Konva from 'konva'; +import type { Weave } from '@/weave'; +import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; +import { WEAVE_STAGE_DEFAULT_MODE } from '@/nodes/stage/constants'; +import { WeaveTextV2Shape } from '@/nodes/text-v2/shape'; +import { blocksToText, hasMarks, type WeaveTextBlock } from '@/nodes/text-v2/model'; +import { resolveLabelBlocks } from './shape-label.utils'; +import { WEAVE_TEXT_V2_SELECTION_COLOR } from '@/nodes/text-v2/constants'; +import { + TextEditSession, + type TextEditHost, + type TextEditTarget, +} from '@/nodes/text-v2/edit/session'; +import { + labelId, + WEAVE_SHAPE_LABEL_DEFAULTS, + WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE, +} from './shape-label.constants'; +import type { + WeaveShapeLabelOnEnterEditMode, + WeaveShapeLabelOnExitEditMode, + WeaveShapeLabelOnFormatChange, + WeaveShapeLabelEngine, + WeaveShapeLabelTextBounds, +} from './shape-label.types'; + +const MAX_GROW_PASSES = 5; + +function labelShapeAttrs( + props: WeaveElementAttributes, + textBounds: WeaveShapeLabelTextBounds +) { + const d = WEAVE_SHAPE_LABEL_DEFAULTS; + return { + x: textBounds.x, + y: textBounds.y, + width: textBounds.width, + height: textBounds.height, + fontFamily: props.labelFontFamily ?? d.labelFontFamily, + fontSize: props.labelFontSize ?? d.labelFontSize, + fontStyle: props.labelFontStyle ?? d.labelFontStyle, + fontVariant: props.labelFontVariant ?? d.labelFontVariant, + textDecoration: props.labelTextDecoration ?? d.labelTextDecoration, + fill: props.labelFill ?? d.labelFill, + align: props.labelAlign ?? d.labelAlign, + verticalAlign: props.labelVerticalAlign ?? d.labelVerticalAlign, + letterSpacing: props.labelLetterSpacing ?? d.labelLetterSpacing, + lineHeight: props.labelLineHeight ?? d.labelLineHeight, + padding: 0, + wrap: 'word' as const, + }; +} + +/** + * Native label engine: the label is a `WeaveTextV2Shape` and editing runs + * through `TextEditSession` (caret and selection painted on the utility + * layer, no DOM overlay). Persists plain `labelText` only. + */ +export class WeaveShapeLabelNativeEditor implements WeaveShapeLabelEngine { + private readonly instance: Weave; + private editing = false; + private editingGroup: Konva.Group | null = null; + private editingShape: WeaveTextV2Shape | null = null; + private editingBounds: WeaveShapeLabelTextBounds | null = null; + private session: TextEditSession | null = null; + private onCommit: + | ((labelText: string, labelBlocks?: WeaveTextBlock[]) => void) + | null = null; + private onLiveResize: ((neededShapeHeight: number) => void) | null = null; + + constructor(instance: Weave) { + this.instance = instance; + } + + isEditing(): boolean { + return this.editing; + } + + renderLabel( + group: Konva.Group, + props: WeaveElementAttributes, + textBounds: WeaveShapeLabelTextBounds + ): WeaveTextV2Shape { + const labelText = props.labelText ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelText; + const labelNode = new WeaveTextV2Shape({ + id: labelId(props.id as string), + text: labelText, + ...labelShapeAttrs(props, textBounds), + listening: false, + visible: labelText !== '', + }); + const blocks = resolveLabelBlocks(props); + if (blocks) labelNode.setBlocks(blocks); + group.add(labelNode); + return labelNode; + } + + updateLabel( + group: Konva.Group, + nextProps: WeaveElementAttributes, + textBounds: WeaveShapeLabelTextBounds, + growCallback?: (neededShapeHeight: number) => void + ): void { + const labelNode = group.findOne( + `#${labelId(nextProps.id as string)}` + ); + if (!labelNode) return; + + const labelText = + nextProps.labelText ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelText; + + // While editing, the session owns the content: never overwrite it with + // the (not yet committed) stored text. + if (this.editing && this.editingShape === labelNode) { + labelNode.setAttrs(labelShapeAttrs(nextProps, textBounds)); + this.editingBounds = textBounds; + this.fitHeight(labelNode, textBounds); + return; + } + + labelNode.setAttrs({ + text: labelText, + ...labelShapeAttrs(nextProps, textBounds), + visible: labelText !== '', + }); + // `text` above resets rich content: re-apply the stored blocks. + const blocks = resolveLabelBlocks(nextProps); + if (blocks) labelNode.setBlocks(blocks); + + if (labelText !== '') { + labelNode.setAttr('height', undefined); + const measuredHeight = labelNode.height(); + labelNode.height(Math.max(textBounds.height, measuredHeight)); + + if (growCallback && measuredHeight > textBounds.height) { + const paddingY = + nextProps.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + growCallback(measuredHeight + paddingY * 2); + } + } + } + + /** Keep the box at least as tall as bounds and content (no negative offset). */ + private fitHeight( + shape: WeaveTextV2Shape, + bounds: WeaveShapeLabelTextBounds + ): void { + const content = this.naturalContentHeight(shape); + const height = Math.max(bounds.height, content); + shape.height(height); + } + + /** + * Content height at the current width. The layout clamps to an explicit + * box height, so clear it first (auto-height), as the legacy label does. + */ + private naturalContentHeight(shape: WeaveTextV2Shape): number { + shape.setAttr('height', undefined); + return shape.ensureLayout().contentHeight; + } + + triggerEditMode( + group: Konva.Group, + textBounds: WeaveShapeLabelTextBounds, + onCommit: (labelText: string, labelBlocks?: WeaveTextBlock[]) => void, + onLiveResize?: (neededShapeHeight: number) => void + ): void { + if (this.editing) return; + + const shape = group.findOne(`#${labelId(group.id())}`); + if (!shape) return; + + const lockAcquired = this.instance.setMutexLock({ + nodeIds: [group.id()], + operation: 'label-edit', + }); + if (!lockAcquired) return; + + this.editing = true; + this.editingGroup = group; + this.editingShape = shape; + this.editingBounds = textBounds; + this.onCommit = onCommit; + this.onLiveResize = onLiveResize ?? null; + + shape.visible(true); + + const selectionPlugin = + this.instance.getPlugin('nodesSelection'); + if (selectionPlugin) { + const tr = selectionPlugin.getTransformer(); + this.instance.disablePlugin('nodesSelection'); + tr.hide(); + } + + const stage = this.instance.getStage(); + const container = stage.container(); + const rect = () => container.getBoundingClientRect(); + + const host: TextEditHost = { + utilityLayer: this.instance.getUtilityLayer(), + container, + clientToStage: (cx, cy) => { + const r = rect(); + const sx = r.width ? stage.width() / r.width : 1; + const sy = r.height ? stage.height() / r.height : 1; + return { x: (cx - r.left) * sx, y: (cy - r.top) * sy }; + }, + stageToContainer: (p) => { + const r = rect(); + const sx = r.width ? r.width / stage.width() : 1; + const sy = r.height ? r.height / stage.height() : 1; + return { x: p.x * sx, y: p.y * sy }; + }, + caretColor: '#000000', + selectionColor: WEAVE_TEXT_V2_SELECTION_COLOR, + selectAllOnStart: true, + richText: true, + onFormatChange: () => this.emitLabelFormat(group), + onSelectionChange: () => this.emitLabelFormat(group), + signal: this.instance.getEventsController().signal, + onRequestExit: () => this.commit(), + }; + + this.session = new TextEditSession( + this.editTarget(group, shape), + host, + `${group.id()}-label` + ); + + stage.mode(WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE); + this.session.start(); + this.instance.emitEvent( + 'onEnterShapeLabelEditMode', + { node: group, session: this.session } + ); + this.emitLabelFormat(group); + } + + private emitLabelFormat(group: Konva.Group): void { + if (!this.session) return; + this.instance.emitEvent( + 'onShapeLabelFormatChange', + { node: group, format: this.session.getFormat() } + ); + } + + private editTarget( + group: Konva.Group, + shape: WeaveTextV2Shape + ): TextEditTarget { + return { + getBlocks: () => shape.getBlocks(), + setBlocks: (blocks) => { + shape.setBlocks(blocks); + shape.visible(true); + this.grow(group, shape); + }, + getLayout: () => shape.ensureLayout(), + measure: (t, f) => shape.measureText(t, f), + getAbsoluteTransform: () => shape.getAbsoluteTransform(), + getContentOffset: () => shape.getContentOffset(), + getSize: () => ({ width: shape.getWidth(), height: shape.getHeight() }), + getFontSize: () => shape.fontSize(), + requestRedraw: () => shape.getLayer()?.batchDraw(), + warmLayout: (blocks, budget) => shape.warmLayout(blocks, budget), + }; + } + + /** + * Grow/restore the owning shape to fit the content. Growth can change the + * label width (polygon, ellipse), which changes wrapping, so re-measure + * until stable; same bounds as the legacy convergence loop. + */ + private grow(group: Konva.Group, shape: WeaveTextV2Shape): void { + const bounds = this.editingBounds; + if (!bounds) return; + const attrs = group.getAttrs() as WeaveElementAttributes; + const paddingY = + attrs.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + const content = () => this.naturalContentHeight(shape); + + if (this.onLiveResize) { + let prev = content(); + let max = prev; + let lastUsed = 0; + for (let pass = 0; pass < MAX_GROW_PASSES; pass++) { + lastUsed = prev; + this.onLiveResize(prev + paddingY * 2); + const measured = content(); + if (measured > max) max = measured; + if (measured === prev) break; + prev = measured; + } + if (prev > lastUsed) this.onLiveResize(max + paddingY * 2); + } + + this.fitHeight(shape, this.editingBounds ?? bounds); + shape.getLayer()?.batchDraw(); + } + + /** Called by shapes that grow on more than one axis. */ + repositionTextArea( + _group: Konva.Group, + textBounds: WeaveShapeLabelTextBounds + ): void { + if (!this.editing || !this.editingShape) return; + this.editingBounds = textBounds; + this.editingShape.setAttrs({ + x: textBounds.x, + y: textBounds.y, + width: textBounds.width, + }); + this.fitHeight(this.editingShape, textBounds); + } + + private commit(): void { + const session = this.session; + const onCommit = this.onCommit; + if (!session || !this.editing) return; + const { blocks } = session.getResult(); + const text = blocksToText(blocks); + // Only persist rich content; plain text stays in `labelText` alone. + const rich = hasMarks(blocks) || blocks.some((b) => b.kind || b.align); + const groupId = this.editingGroup?.id() ?? null; + this.exitEditMode(); + if (groupId) { + const live = this.instance + .getStage() + .findOne(`#${groupId}`) + ?.findOne(`#${labelId(groupId)}`); + live?.visible(text !== ''); + } + onCommit?.(text, rich ? blocks : undefined); + } + + exitEditMode(): void { + if (!this.editing) return; + + this.session?.destroy(); + this.session = null; + + this.instance.releaseMutexLock(); + this.instance.getStage().mode(WEAVE_STAGE_DEFAULT_MODE); + this.editing = false; + + const editedGroupId = this.editingGroup?.id() ?? null; + const exitedGroup = this.editingGroup; + this.editingGroup = null; + this.editingShape = null; + this.editingBounds = null; + this.onCommit = null; + this.onLiveResize = null; + + if (exitedGroup) { + this.instance.emitEvent( + 'onExitShapeLabelEditMode', + { node: exitedGroup } + ); + } + + const selectionPlugin = + this.instance.getPlugin('nodesSelection'); + if (selectionPlugin) { + this.instance.enablePlugin('nodesSelection'); + if (editedGroupId) { + requestAnimationFrame(() => { + const liveGroup = this.instance + .getStage() + .findOne(`#${editedGroupId}`); + if (liveGroup) selectionPlugin.setSelectedNodes([liveGroup]); + }); + } + } + } +} diff --git a/code/packages/sdk/src/nodes/shared/shape-label.constants.ts b/code/packages/sdk/src/nodes/shared/shape-label.constants.ts index 45ebe24b6..4c3ce075c 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label.constants.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label.constants.ts @@ -6,7 +6,7 @@ import type { WeaveShapeLabelProps } from './shape-label.types'; export const WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE = 'shape-label-edition'; -export const WEAVE_SHAPE_LABEL_DEFAULTS: Required = { +export const WEAVE_SHAPE_LABEL_DEFAULTS: Required> = { labelText: '', labelFontFamily: 'Arial, sans-serif', labelFontSize: 14, diff --git a/code/packages/sdk/src/nodes/shared/shape-label.engine.ts b/code/packages/sdk/src/nodes/shared/shape-label.engine.ts new file mode 100644 index 000000000..d1bf27147 --- /dev/null +++ b/code/packages/sdk/src/nodes/shared/shape-label.engine.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type { Weave } from '@/weave'; +import { WeaveShapeLabelEditor } from './shape-label-editor'; +import { WeaveShapeLabelNativeEditor } from './shape-label-native-editor'; +import type { + WeaveShapeLabelConfig, + WeaveShapeLabelEngine, +} from './shape-label.types'; + +export const WEAVE_SHAPE_LABEL_DEFAULT_CONFIG: WeaveShapeLabelConfig = { + engine: 'legacy', +}; + +/** Resolves the label engine; anything but `native` falls back to `legacy`. */ +export function createShapeLabelEngine( + instance: Weave, + config?: Partial +): WeaveShapeLabelEngine { + return config?.engine === 'native' + ? new WeaveShapeLabelNativeEditor(instance) + : new WeaveShapeLabelEditor(instance); +} diff --git a/code/packages/sdk/src/nodes/shared/shape-label.types.ts b/code/packages/sdk/src/nodes/shared/shape-label.types.ts index 66bbed07e..776dc6e34 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label.types.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label.types.ts @@ -2,6 +2,14 @@ // // SPDX-License-Identifier: Apache-2.0 +import type Konva from 'konva'; +import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { + WeaveTextBlock, + WeaveTextSelectionFormat, +} from '@/nodes/text-v2/model'; +import type { TextEditSession } from '@/nodes/text-v2/edit/session'; + export type WeaveShapeLabelProps = { labelText?: string; labelFontFamily?: string; @@ -16,6 +24,8 @@ export type WeaveShapeLabelProps = { labelLineHeight?: number; labelPaddingX?: number; labelPaddingY?: number; + /** Rich content written by the `native` engine; `labelText` is its plain mirror. */ + labelBlocks?: WeaveTextBlock[]; }; export type WeaveShapeLabelTextBounds = { @@ -24,3 +34,57 @@ export type WeaveShapeLabelTextBounds = { width: number; height: number; }; + +/** Label engine: `legacy` (Konva.Text + DOM textarea) or `native` (on-canvas). */ +export type WeaveShapeLabelEngineKind = 'legacy' | 'native'; + +export type WeaveShapeLabelConfig = { + /** Engine used to render and edit labels. Defaults to `legacy`. */ + engine: WeaveShapeLabelEngineKind; +}; + +/** Node rendering the label text. Both engines return a `Konva.Shape`. */ +export type WeaveShapeLabelNode = Konva.Shape; + +/** Contract shared by the legacy and native label engines. */ +export interface WeaveShapeLabelEngine { + isEditing(): boolean; + renderLabel( + group: Konva.Group, + props: WeaveElementAttributes, + textBounds: WeaveShapeLabelTextBounds + ): WeaveShapeLabelNode; + updateLabel( + group: Konva.Group, + nextProps: WeaveElementAttributes, + textBounds: WeaveShapeLabelTextBounds, + growCallback?: (neededShapeHeight: number) => void + ): void; + triggerEditMode( + group: Konva.Group, + textBounds: WeaveShapeLabelTextBounds, + onCommit: (labelText: string, labelBlocks?: WeaveTextBlock[]) => void, + onLiveResize?: (neededShapeHeight: number) => void + ): void; + exitEditMode(): void; + repositionTextArea( + group: Konva.Group, + textBounds: WeaveShapeLabelTextBounds + ): void; +} + +/** Emitted by the `native` label engine when a label edit session starts. */ +export type WeaveShapeLabelOnEnterEditMode = { + node: Konva.Group; + /** Live session: run format commands (`toggleMark`, ...) on it. */ + session: TextEditSession; +}; + +/** Selection format changed during a native label edit session. */ +export type WeaveShapeLabelOnFormatChange = { + node: Konva.Group; + format: WeaveTextSelectionFormat; +}; + +/** Emitted by the `native` label engine when a label edit session ends. */ +export type WeaveShapeLabelOnExitEditMode = { node: Konva.Group }; diff --git a/code/packages/sdk/src/nodes/shared/shape-label.utils.ts b/code/packages/sdk/src/nodes/shared/shape-label.utils.ts index 02143118c..a0b6d7f6e 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label.utils.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label.utils.ts @@ -5,7 +5,12 @@ import { z } from 'zod'; import Konva from 'konva'; import type { WeaveElementAttributes } from '@inditextech/weave-types'; -import type { WeaveShapeLabelTextBounds } from './shape-label.types'; +import type { + WeaveShapeLabelNode, + WeaveShapeLabelTextBounds, +} from './shape-label.types'; +import { blocksToText } from '@/nodes/text-v2/model'; +import type { WeaveTextBlock } from '@/nodes/text-v2/model'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS } from './shape-label.constants'; /** @@ -14,6 +19,40 @@ import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS } from './shape-label.constants'; * `addNodeState` / `updateNodeState` to avoid duplicating the 12-field pattern * across every shape node that supports inline text labels. */ +const labelBlocksSchema = z.array( + z.object({ + runs: z.array( + z.object({ t: z.string(), m: z.object({}).passthrough().optional() }) + ), + kind: z.enum(['ul', 'ol']).optional(), + depth: z.number().int().min(0).optional(), + start: z.number().int().min(1).optional(), + align: z.enum(['left', 'center', 'right']).optional(), + }) +); + +/** + * `labelBlocks` are replaced wholesale and cleared when the next props carry + * none (the state merge alone would keep a stale value). + */ +export function applyLabelBlocksState }>( + state: T, + nextProps: WeaveElementAttributes +): T { + if (nextProps.labelBlocks) state.props.labelBlocks = nextProps.labelBlocks; + else delete state.props.labelBlocks; + return state; +} + +/** Stored rich blocks, only when they still match the plain `labelText`. */ +export function resolveLabelBlocks( + props: WeaveElementAttributes +): WeaveTextBlock[] | undefined { + const blocks = props.labelBlocks as WeaveTextBlock[] | undefined; + if (!Array.isArray(blocks) || blocks.length === 0) return undefined; + return blocksToText(blocks) === (props.labelText ?? '') ? blocks : undefined; +} + export function spreadLabelProps(props: WeaveElementAttributes): Partial { return { ...(props.labelText !== undefined && { labelText: props.labelText }), @@ -28,6 +67,7 @@ export function spreadLabelProps(props: WeaveElementAttributes): Partial(`#${labelId(group.id())}`); + const labelNode = group.findOne(`#${labelId(group.id())}`); if (!labelNode) return null; const paddingX = diff --git a/code/packages/sdk/src/nodes/text-v2/__tests__/enter-keypress.test.ts b/code/packages/sdk/src/nodes/text-v2/__tests__/enter-keypress.test.ts new file mode 100644 index 000000000..fa2ce8243 --- /dev/null +++ b/code/packages/sdk/src/nodes/text-v2/__tests__/enter-keypress.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +// @vitest-environment jsdom + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { WeaveTextV2Node } from '../text-v2'; + +vi.mock('@/weave', () => ({ Weave: class MockWeave {} })); + +function setup() { + const node = new WeaveTextV2Node(); + const instance = { + isServerSide: () => false, + getEventsController: () => new AbortController(), + getActiveAction: () => 'selectionTool', + getPlugin: () => undefined, + }; + (node as unknown as { instance: unknown }).instance = instance; + node.onAdd?.(); + return node; +} + +function enter(target: EventTarget) { + const e = new KeyboardEvent('keypress', { + code: 'Enter', + key: 'Enter', + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(e); + return e; +} + +afterEach(() => { + delete (window as unknown as { weaveTextEditing?: unknown }).weaveTextEditing; + document.body.innerHTML = ''; +}); + +describe('text-v2 Enter keypress handler', () => { + it('does not cancel Enter typed into a textarea', () => { + setup(); + const ta = document.body.appendChild(document.createElement('textarea')); + expect(enter(ta).defaultPrevented).toBe(false); + }); + + it('does not cancel Enter while another edit session is active', () => { + setup(); + (window as unknown as { weaveTextEditing: Record }).weaveTextEditing = + { 'shape-label': 'shape-label' }; + expect(enter(document.body).defaultPrevented).toBe(false); + }); + + it('still handles Enter on the page when nothing is being edited', () => { + setup(); + expect(enter(document.body).defaultPrevented).toBe(true); + }); +}); diff --git a/code/packages/sdk/src/nodes/text-v2/text-v2.ts b/code/packages/sdk/src/nodes/text-v2/text-v2.ts index f8b448575..701c6741f 100644 --- a/code/packages/sdk/src/nodes/text-v2/text-v2.ts +++ b/code/packages/sdk/src/nodes/text-v2/text-v2.ts @@ -275,11 +275,29 @@ export class WeaveTextV2Node extends WeaveNode { // ---- edit mode ----------------------------------------------------------- + /** + * Enter typed into another editor (a shape label session or any text + * field) must keep its default action (a new line). + */ + private isEditingElsewhere(e: KeyboardEvent): boolean { + const sessions = (window as Window & { + weaveTextEditing?: Record; + }).weaveTextEditing; + if (sessions && Object.keys(sessions).length > 0) return true; + const t = e.target; + return ( + t instanceof HTMLTextAreaElement || + t instanceof HTMLInputElement || + (t instanceof HTMLElement && t.isContentEditable) + ); + } + private readonly handleKeyPress = (e: KeyboardEvent) => { if ( e.code !== 'Enter' || this.instance.getActiveAction() !== SELECTION_TOOL_ACTION_NAME || - this.editing + this.editing || + this.isEditingElsewhere(e) ) { return; } diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/ellipse.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/ellipse.adoc index d31c03692..b707d8740 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/ellipse.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/ellipse.adoc @@ -16,6 +16,28 @@ All label appearance properties (font, colour, alignment, padding, …) are conf The class extends the xref:sdk:api-reference/nodes/node.adoc[WeaveNode] class +== Label engine + +By default the label is rendered with Konva's text and edited through a DOM textarea overlay (`legacy`). +Set `config.label.engine` to `native` to render the label with the on-canvas text engine and edit it +with the native editing session instead: no DOM overlay, and caret and selection are drawn on the canvas. + +[source,ts] +---- +new WeaveEllipseNode({ config: { label: { engine: "native" } } }); +---- + +Both engines read and write the same `labelText` and `label*` props, so clients using different engines +can edit the same document. The `native` engine enables rich text in labels (bold, italic, underline, lists, alignment). Formatted content is stored in the optional `labelBlocks` prop, and `labelText` always holds the plain text, so the `legacy` engine shows the plain text. + +== Label edit events + +With the `native` label engine the node emits `onEnterShapeLabelEditMode` when a label edit session starts and +`onExitShapeLabelEditMode` when it ends. Exit receives `{ node: Konva.Group }`; enter also receives the live +`session`, so an app can build a formatting toolbar by running format commands on it. While editing, +`onShapeLabelFormatChange` receives `{ node, format }` whenever the selection format changes. +The `legacy` engine does not emit them. + == Type This node `nodeType` property value is `ellipse`. @@ -40,8 +62,13 @@ new WeaveEllipseNode(params?: WeaveEllipseNodeParams); ---- type WeaveNodeTransformerProperties = Konva.TransformerConfig; +type WeaveShapeLabelConfig = { + engine: "legacy" | "native"; // default "legacy" +}; + type WeaveEllipseProperties = { transform: WeaveNodeTransformerProperties; + label?: Partial; }; type WeaveEllipseNodeParams = { @@ -62,6 +89,7 @@ type WeaveShapeLabelProps = { labelLineHeight?: number; labelPaddingX?: number; labelPaddingY?: number; + labelBlocks?: WeaveTextBlock[]; // rich content written by the native engine }; ---- diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/polygon.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/polygon.adoc index 423c1ee3a..490c95fb5 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/polygon.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/polygon.adoc @@ -15,6 +15,28 @@ The node also supports an inline text label whose bounding box is derived from t The class extends the xref:sdk:api-reference/nodes/node.adoc[WeaveNode] class. +== Label engine + +By default the label is rendered with Konva's text and edited through a DOM textarea overlay (`legacy`). +Set `config.label.engine` to `native` to render the label with the on-canvas text engine and edit it +with the native editing session instead: no DOM overlay, and caret and selection are drawn on the canvas. + +[source,ts] +---- +new WeavePolygonNode({ config: { label: { engine: "native" } } }); +---- + +Both engines read and write the same `labelText` and `label*` props, so clients using different engines +can edit the same document. The `native` engine enables rich text in labels (bold, italic, underline, lists, alignment). Formatted content is stored in the optional `labelBlocks` prop, and `labelText` always holds the plain text, so the `legacy` engine shows the plain text. + +== Label edit events + +With the `native` label engine the node emits `onEnterShapeLabelEditMode` when a label edit session starts and +`onExitShapeLabelEditMode` when it ends. Exit receives `{ node: Konva.Group }`; enter also receives the live +`session`, so an app can build a formatting toolbar by running format commands on it. While editing, +`onShapeLabelFormatChange` receives `{ node, format }` whenever the selection format changes. +The `legacy` engine does not emit them. + == Type This node `nodeType` property value is `polygon`. @@ -48,8 +70,21 @@ type WeavePolygonInnerRect = { type WeaveNodeTransformerProperties = Konva.TransformerConfig; +type WeaveShapeLabelProps = { + labelText?: string; + labelBlocks?: WeaveTextBlock[]; // rich content written by the native engine + // plus labelFontFamily, labelFontSize, labelFontStyle, labelFontVariant, labelTextDecoration, + // labelFill, labelAlign, labelVerticalAlign, labelLetterSpacing, labelLineHeight, + // labelPaddingX, labelPaddingY +}; + +type WeaveShapeLabelConfig = { + engine: "legacy" | "native"; // default "legacy" +}; + type WeavePolygonProperties = { transform: WeaveNodeTransformerProperties; + label?: Partial; }; type WeavePolygonNodeParams = { diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/rectangle.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/rectangle.adoc index fde741812..775e4a550 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/rectangle.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/rectangle.adoc @@ -23,6 +23,28 @@ All label appearance properties (font, colour, alignment, padding, …) are conf The class extends the xref:sdk:api-reference/nodes/node.adoc[WeaveNode] class +== Label engine + +By default the label is rendered with Konva's text and edited through a DOM textarea overlay (`legacy`). +Set `config.label.engine` to `native` to render the label with the on-canvas text engine and edit it +with the native editing session instead: no DOM overlay, and caret and selection are drawn on the canvas. + +[source,ts] +---- +new WeaveRectangleNode({ config: { label: { engine: "native" } } }); +---- + +Both engines read and write the same `labelText` and `label*` props, so clients using different engines +can edit the same document. The `native` engine enables rich text in labels (bold, italic, underline, lists, alignment). Formatted content is stored in the optional `labelBlocks` prop, and `labelText` always holds the plain text, so the `legacy` engine shows the plain text. + +== Label edit events + +With the `native` label engine the node emits `onEnterShapeLabelEditMode` when a label edit session starts and +`onExitShapeLabelEditMode` when it ends. Exit receives `{ node: Konva.Group }`; enter also receives the live +`session`, so an app can build a formatting toolbar by running format commands on it. While editing, +`onShapeLabelFormatChange` receives `{ node, format }` whenever the selection format changes. +The `legacy` engine does not emit them. + == Type This node `nodeType` property value is `rectangle`. @@ -47,8 +69,13 @@ new WeaveRectangleNode(params?: WeaveRectangleNodeParams); ---- type WeaveNodeTransformerProperties = Konva.TransformerConfig; +type WeaveShapeLabelConfig = { + engine: "legacy" | "native"; // default "legacy" +}; + type WeaveRectangleProperties = { transform: WeaveNodeTransformerProperties; + label?: Partial; }; type WeaveRectangleNodeParams = { @@ -69,6 +96,7 @@ type WeaveShapeLabelProps = { labelLineHeight?: number; labelPaddingX?: number; labelPaddingY?: number; + labelBlocks?: WeaveTextBlock[]; // rich content written by the native engine }; ---- diff --git a/openspec/changes/shape-label-native-engine/.openspec.yaml b/openspec/changes/archive/2026-10-26-shape-label-native-engine/.openspec.yaml similarity index 100% rename from openspec/changes/shape-label-native-engine/.openspec.yaml rename to openspec/changes/archive/2026-10-26-shape-label-native-engine/.openspec.yaml diff --git a/openspec/changes/shape-label-native-engine/design.md b/openspec/changes/archive/2026-10-26-shape-label-native-engine/design.md similarity index 85% rename from openspec/changes/shape-label-native-engine/design.md rename to openspec/changes/archive/2026-10-26-shape-label-native-engine/design.md index bbb197e5b..9044b0d5d 100644 --- a/openspec/changes/shape-label-native-engine/design.md +++ b/openspec/changes/archive/2026-10-26-shape-label-native-engine/design.md @@ -9,13 +9,13 @@ - Reuse text-v2 layout and session unchanged. **Non-Goals:** -- Schema changes, `labelBlocks`, rich-text toolbar for labels, shape geometry changes, removing legacy. +- Rich-text toolbar UI for labels, shape geometry changes, removing legacy. ## Decisions 1. **Strategy seam.** Extract `ShapeLabelEngine` from the existing public methods; `WeaveShapeLabelEditor` implements it untouched (legacy). New `WeaveShapeLabelNativeEditor` implements it. Alternative (subclass or flag inside one class) rejected: mixes two lifecycles in 756 lines. 2. **Selection by node config**, default `legacy`. Mirrors node-level config used elsewhere (`config.transform`). Alternative persisted `labelEngine` prop rejected: schema leak and migration. -3. **Plain-text persistence.** Native wraps `labelText` in blocks for the session and flattens back on commit. Rich labels are a follow-up adding an additive field. +3. **Rich labels, additive persistence.** Native runs the session with rich text on. Commit writes `labelText` (plain mirror) and, only for formatted content, optional `labelBlocks`. Render uses `labelBlocks` only while it matches `labelText`, so a legacy client editing plain text cannot leave stale formatting visible. State updates clear `labelBlocks` explicitly when absent. 4. **Native target.** `getContentOffset` supplies padding and vertical align; `getAbsoluteTransform` supplies rotation; `setBlocks` re-lays out and calls `growCallback`. The convergence loop re-measures layout synchronously (grow -> re-layout -> compare), MAX 5 passes plus final corrective grow, same as legacy. 5. **Label node typing.** `shape-label.utils.ts` depends on a minimal `LabelNode` interface (text, height, clone, getClientRect) satisfied by both `Konva.Text` and `WeaveTextV2Shape`. 6. **Hide during edit.** Native hides the label node while a session runs unless the session paints content itself; verify against text-v2 behavior. diff --git a/openspec/changes/shape-label-native-engine/proposal.md b/openspec/changes/archive/2026-10-26-shape-label-native-engine/proposal.md similarity index 88% rename from openspec/changes/shape-label-native-engine/proposal.md rename to openspec/changes/archive/2026-10-26-shape-label-native-engine/proposal.md index 46defa9c8..cb2443a69 100644 --- a/openspec/changes/shape-label-native-engine/proposal.md +++ b/openspec/changes/archive/2026-10-26-shape-label-native-engine/proposal.md @@ -11,7 +11,7 @@ The shape label editor (`nodes/shared/shape-label-editor.ts`, used by rectangle, - Add a **native** engine: label rendered as `WeaveTextV2Shape`, edited via `TextEditSession`, no DOM overlay, rotation and vertical alignment from the session target, live shape growth through `setBlocks`/`onChange` with a bounded convergence loop. - Add an optional per-node-type config on rectangle, ellipse and polygon to select the engine (`legacy` default, `native` opt-in). - Make `shape-label.utils.ts` min-size helpers work against a shared label-node interface instead of `Konva.Text` only. -- No persisted schema change: both engines read/write `labelText` and the `label*` props. Native flattens to plain text on commit. +- Native labels have rich text enabled. Additive optional `labelBlocks` persists rich content; `labelText` stays the plain mirror and the legacy engine ignores `labelBlocks`. No other schema change. - No **BREAKING** changes. Legacy removal is deferred to a separate deprecation change. ## Capabilities @@ -25,6 +25,6 @@ The shape label editor (`nodes/shared/shape-label-editor.ts`, used by rectangle, - Code: `code/packages/sdk/src/nodes/shared/shape-label-editor.ts`, `shape-label.utils.ts`, `shape-label.constants.ts`, `shape-label.types.ts`; `nodes/rectangle`, `nodes/ellipse`, `nodes/polygon` (config + engine wiring); new native engine file(s). - Tests: existing `shape-label-editor.test.ts` and `shape-label.utils.test.ts` must pass unmodified on legacy; new native suite and shared contract test. -- Public API: additive config option and exported interface only. +- Public API: additive config option, optional `labelBlocks` prop and exported interface only. - Docs: node pages for rectangle, ellipse, polygon (config option). - Server-side export: labels must render in both engines (to verify for `WeaveTextV2Shape` in the node backend). diff --git a/openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md b/openspec/changes/archive/2026-10-26-shape-label-native-engine/specs/shape-label/engine/spec.md similarity index 65% rename from openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md rename to openspec/changes/archive/2026-10-26-shape-label-native-engine/specs/shape-label/engine/spec.md index 267563e6a..1c5182cee 100644 --- a/openspec/changes/shape-label-native-engine/specs/shape-label/engine/spec.md +++ b/openspec/changes/archive/2026-10-26-shape-label-native-engine/specs/shape-label/engine/spec.md @@ -11,8 +11,8 @@ Rectangle, ellipse and polygon nodes SHALL accept an optional config selecting t - **WHEN** the node config sets the engine to `native` - **THEN** its label renders with `WeaveTextV2Shape` and edits through `TextEditSession` -### Requirement: No schema or contract change -Both engines SHALL read and write the same `labelText` and `label*` props, keep the label child id `labelId(id)`, the `'label-edit'` mutex operation and `WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE`. No new persisted field SHALL be added. +### Requirement: Additive contract +Both engines SHALL read and write the same `labelText` and `label*` props, keep the label child id `labelId(id)`, the `'label-edit'` mutex operation and `WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE`. The only new persisted field SHALL be the optional `labelBlocks`, ignored by the legacy engine. #### Scenario: Mixed engines - **WHEN** one client uses `legacy` and another `native` on the same document @@ -39,12 +39,20 @@ In `native`, the label SHALL stay inside the shape's text bounds with `labelPadd - **WHEN** typed text needs more height than the text bounds - **THEN** the shape grows to fit and shrinks back, down to its original height, when text is removed -### Requirement: Native commit flattens to plain text -On commit the native engine SHALL persist the plain-text content as `labelText`. +### Requirement: Native labels support rich text +In `native`, the edit session SHALL run with rich text enabled (marks, shortcuts, lists, alignment). On commit `labelText` SHALL be the plain mirror of the content, and `labelBlocks` SHALL be persisted only when the content has marks, lists or alignment; otherwise it SHALL be cleared. A stored `labelBlocks` SHALL be rendered only while its plain text equals `labelText`. -#### Scenario: Commit -- **WHEN** edit ends with changed content -- **THEN** `labelText` equals the plain text of the edited blocks +#### Scenario: Commit rich content +- **WHEN** the user bolds a word and commits +- **THEN** `labelText` is the plain text and `labelBlocks` holds the bold run + +#### Scenario: Commit plain content +- **WHEN** the user commits content with no formatting +- **THEN** `labelText` is set and `labelBlocks` is cleared + +#### Scenario: Stale blocks +- **WHEN** a client edits `labelText` without updating `labelBlocks` +- **THEN** the label renders the plain `labelText` ### Requirement: Label min-size helpers are engine-agnostic The min-size helpers SHALL work with labels from either engine. @@ -59,3 +67,10 @@ Export SHALL render labels in both engines. #### Scenario: Export with native label - **WHEN** a shape with a native label is exported server-side - **THEN** the label text appears in the output + +### Requirement: Native label edit events +The native engine SHALL emit `onEnterShapeLabelEditMode` (with the shape node and the live edit session) when a session starts, `onShapeLabelFormatChange` (node and selection format) while the selection format changes, and `onExitShapeLabelEditMode` (node) when it ends. The legacy engine SHALL NOT emit them. + +#### Scenario: Toolbar hook +- **WHEN** a native label edit starts and the user selects bold text +- **THEN** enter fires with the session, and format change fires with the bold format diff --git a/openspec/changes/archive/2026-10-26-shape-label-native-engine/tasks.md b/openspec/changes/archive/2026-10-26-shape-label-native-engine/tasks.md new file mode 100644 index 000000000..4a78ad34e --- /dev/null +++ b/openspec/changes/archive/2026-10-26-shape-label-native-engine/tasks.md @@ -0,0 +1,41 @@ +## 1. Spikes + +- [x] 1.1 Verify `WeaveTextV2Shape` paints in the node backend export path and supports `height(undefined)`, `clone`, `getClientRect` like `Konva.Text` +- [x] 1.2 Audit label-related events and public exports of the label editor + +## 2. Seam + +- [x] 2.1 Define `ShapeLabelEngine` and `LabelNode` interfaces in `shape-label.types.ts` +- [x] 2.2 Make `WeaveShapeLabelEditor` implement the interface with no behavior change +- [x] 2.3 Type `shape-label.utils.ts` helpers against `LabelNode` +- [x] 2.4 Confirm existing `shape-label-editor.test.ts` and `shape-label.utils.test.ts` pass unmodified + +## 3. Native engine + +- [x] 3.1 Implement `WeaveShapeLabelNativeEditor` render/update using `WeaveTextV2Shape` with `labelId(id)` and `label*` props +- [x] 3.2 Build the `TextEditTarget` (blocks from `labelText`, content offset, transform, `setBlocks` with grow callback) +- [x] 3.3 Wire `TextEditSession` host: mutex `'label-edit'`, stage mode, selection plugin disable, Escape/outside exit, re-select on exit +- [x] 3.4 Implement bounded grow/shrink convergence loop with original-height floor +- [x] 3.5 Commit writes `labelText` plain mirror; `repositionTextArea` tracks bounds + +## 3b. Rich text + +- [x] 3.6 Enable rich text in the native session +- [x] 3.7 Persist optional `labelBlocks` (schema, state spread, clear on absent) and render only when it matches `labelText` + +- [x] 3.8 Emit enter, format-change and exit events from the native engine, typed and documented + +## 4. Wiring + +- [x] 4.1 Add optional engine config (default `legacy`) to rectangle, ellipse, polygon and select the engine in each +- [x] 4.2 Export new types additively + +## 5. Tests + +- [x] 5.1 Shared contract test run against both engines +- [x] 5.2 Native suite: no DOM overlay, rotation, vertical align, growth/shrink, commit, mixed-engine `labelText` +- [ ] 5.3 Server-side export test for both engines + +## 6. Docs + +- [x] 6.1 Document the engine option on rectangle, ellipse and polygon pages diff --git a/openspec/changes/shape-label-native-engine/tasks.md b/openspec/changes/shape-label-native-engine/tasks.md deleted file mode 100644 index e4cdae111..000000000 --- a/openspec/changes/shape-label-native-engine/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -## 1. Spikes - -- [ ] 1.1 Verify `WeaveTextV2Shape` paints in the node backend export path and supports `height(undefined)`, `clone`, `getClientRect` like `Konva.Text` -- [ ] 1.2 Audit label-related events and public exports of the label editor - -## 2. Seam - -- [ ] 2.1 Define `ShapeLabelEngine` and `LabelNode` interfaces in `shape-label.types.ts` -- [ ] 2.2 Make `WeaveShapeLabelEditor` implement the interface with no behavior change -- [ ] 2.3 Type `shape-label.utils.ts` helpers against `LabelNode` -- [ ] 2.4 Confirm existing `shape-label-editor.test.ts` and `shape-label.utils.test.ts` pass unmodified - -## 3. Native engine - -- [ ] 3.1 Implement `WeaveShapeLabelNativeEditor` render/update using `WeaveTextV2Shape` with `labelId(id)` and `label*` props -- [ ] 3.2 Build the `TextEditTarget` (blocks from `labelText`, content offset, transform, `setBlocks` with grow callback) -- [ ] 3.3 Wire `TextEditSession` host: mutex `'label-edit'`, stage mode, selection plugin disable, Escape/outside exit, re-select on exit -- [ ] 3.4 Implement bounded grow/shrink convergence loop with original-height floor -- [ ] 3.5 Commit flattens blocks to `labelText`; `repositionTextArea` no-op - -## 4. Wiring - -- [ ] 4.1 Add optional engine config (default `legacy`) to rectangle, ellipse, polygon and select the engine in each -- [ ] 4.2 Export new types additively - -## 5. Tests - -- [ ] 5.1 Shared contract test run against both engines -- [ ] 5.2 Native suite: no DOM overlay, rotation, vertical align, growth/shrink, commit, mixed-engine `labelText` -- [ ] 5.3 Server-side export test for both engines - -## 6. Docs - -- [ ] 6.1 Document the engine option on rectangle, ellipse and polygon pages diff --git a/openspec/specs/shape-label/engine/spec.md b/openspec/specs/shape-label/engine/spec.md new file mode 100644 index 000000000..ec225aeaf --- /dev/null +++ b/openspec/specs/shape-label/engine/spec.md @@ -0,0 +1,80 @@ +## Purpose + +Defines how rectangle, ellipse and polygon shape labels are rendered and edited: a selectable label engine (`legacy` default, `native` opt-in on the on-canvas text engine), the shared edit lifecycle, native rich text with additive persistence, growth of the shape while typing, and server-side export. + +## Requirements + +### Requirement: Selectable label engine +Rectangle, ellipse and polygon nodes SHALL accept an optional config selecting the label engine, `legacy` or `native`. When unset, the engine SHALL be `legacy`. + +#### Scenario: Default is legacy +- **WHEN** a shape node is created without a label engine config +- **THEN** its label renders as `Konva.Text` and edits through the DOM textarea overlay exactly as before + +#### Scenario: Opt in to native +- **WHEN** the node config sets the engine to `native` +- **THEN** its label renders with `WeaveTextV2Shape` and edits through `TextEditSession` + +### Requirement: Additive contract +Both engines SHALL read and write the same `labelText` and `label*` props, keep the label child id `labelId(id)`, the `'label-edit'` mutex operation and `WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE`. The only new persisted field SHALL be the optional `labelBlocks`, ignored by the legacy engine. + +#### Scenario: Mixed engines +- **WHEN** one client uses `legacy` and another `native` on the same document +- **THEN** both display and edit the same `labelText` + +### Requirement: Shared edit lifecycle +Both engines SHALL acquire the mutex, set the label edition stage mode, disable the nodes selection plugin while editing, and on exit release the lock, restore the default mode and re-select the shape. + +#### Scenario: Exit re-selects +- **WHEN** the user commits via Escape or outside click +- **THEN** the lock is released, `labelText` is updated through `updateNode`, and the shape is selected + +### Requirement: Native engine has no DOM overlay +In `native`, the editor SHALL NOT create a DOM element mimicking the label text; caret and selection SHALL be drawn on the utility layer and the proxy input SHALL be the only DOM element. + +#### Scenario: Rotated shape +- **WHEN** a rotated shape is edited +- **THEN** caret, selection and text align with the rendered label with no CSS transform + +### Requirement: Native label bounds and growth +In `native`, the label SHALL stay inside the shape's text bounds with `labelPadding*` and `labelVerticalAlign` applied. While typing, the shape SHALL grow via the existing growth callback and SHALL NOT shrink below its original size; growth SHALL converge in a bounded number of passes. + +#### Scenario: Typing overflows +- **WHEN** typed text needs more height than the text bounds +- **THEN** the shape grows to fit and shrinks back, down to its original height, when text is removed + +### Requirement: Native labels support rich text +In `native`, the edit session SHALL run with rich text enabled (marks, shortcuts, lists, alignment). On commit `labelText` SHALL be the plain mirror of the content, and `labelBlocks` SHALL be persisted only when the content has marks, lists or alignment; otherwise it SHALL be cleared. A stored `labelBlocks` SHALL be rendered only while its plain text equals `labelText`. + +#### Scenario: Commit rich content +- **WHEN** the user bolds a word and commits +- **THEN** `labelText` is the plain text and `labelBlocks` holds the bold run + +#### Scenario: Commit plain content +- **WHEN** the user commits content with no formatting +- **THEN** `labelText` is set and `labelBlocks` is cleared + +#### Scenario: Stale blocks +- **WHEN** a client edits `labelText` without updating `labelBlocks` +- **THEN** the label renders the plain `labelText` + +### Requirement: Label min-size helpers are engine-agnostic +The min-size helpers SHALL work with labels from either engine. + +#### Scenario: Native label min size +- **WHEN** the min size is computed for a shape with a native label +- **THEN** the result matches the legacy result for the same plain text and typography + +### Requirement: Server-side export renders labels +Export SHALL render labels in both engines. + +#### Scenario: Export with native label +- **WHEN** a shape with a native label is exported server-side +- **THEN** the label text appears in the output + +### Requirement: Native label edit events +The native engine SHALL emit `onEnterShapeLabelEditMode` (with the shape node and the live edit session) when a session starts, `onShapeLabelFormatChange` (node and selection format) while the selection format changes, and `onExitShapeLabelEditMode` (node) when it ends. The legacy engine SHALL NOT emit them. + +#### Scenario: Toolbar hook +- **WHEN** a native label edit starts and the user selects bold text +- **THEN** enter fires with the session, and format change fires with the bold format From 8a0d46728b019b3f8ed814086de2408a2a69dba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Mon, 26 Oct 2026 15:44:52 +0100 Subject: [PATCH 4/5] docs(openspec): archive text-v2-rich-text and sync text-v2 specs Move the completed text-v2-rich-text change to the archive and update the text-v2 editing and layout specs and the new rich-text-api spec. --- .../.openspec.yaml | 0 .../2026-10-26-text-v2-rich-text}/design.md | 0 .../2026-10-26-text-v2-rich-text}/proposal.md | 0 .../specs/text-v2/editing/spec.md | 0 .../specs/text-v2/layout/spec.md | 0 .../specs/text-v2/model/spec.md | 0 .../specs/text-v2/rich-text-api/spec.md | 0 .../2026-10-26-text-v2-rich-text}/tasks.md | 0 openspec/specs/text-v2/editing/spec.md | 35 ++++++++++ openspec/specs/text-v2/layout/spec.md | 65 +++++++++++++++++- openspec/specs/text-v2/model/spec.md | 48 +++++++++++++- openspec/specs/text-v2/rich-text-api/spec.md | 66 +++++++++++++++++++ 12 files changed, 211 insertions(+), 3 deletions(-) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/.openspec.yaml (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/design.md (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/proposal.md (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/specs/text-v2/editing/spec.md (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/specs/text-v2/layout/spec.md (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/specs/text-v2/model/spec.md (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/specs/text-v2/rich-text-api/spec.md (100%) rename openspec/changes/{text-v2-rich-text => archive/2026-10-26-text-v2-rich-text}/tasks.md (100%) create mode 100644 openspec/specs/text-v2/rich-text-api/spec.md diff --git a/openspec/changes/text-v2-rich-text/.openspec.yaml b/openspec/changes/archive/2026-10-26-text-v2-rich-text/.openspec.yaml similarity index 100% rename from openspec/changes/text-v2-rich-text/.openspec.yaml rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/.openspec.yaml diff --git a/openspec/changes/text-v2-rich-text/design.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/design.md similarity index 100% rename from openspec/changes/text-v2-rich-text/design.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/design.md diff --git a/openspec/changes/text-v2-rich-text/proposal.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/proposal.md similarity index 100% rename from openspec/changes/text-v2-rich-text/proposal.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/proposal.md diff --git a/openspec/changes/text-v2-rich-text/specs/text-v2/editing/spec.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/editing/spec.md similarity index 100% rename from openspec/changes/text-v2-rich-text/specs/text-v2/editing/spec.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/editing/spec.md diff --git a/openspec/changes/text-v2-rich-text/specs/text-v2/layout/spec.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/layout/spec.md similarity index 100% rename from openspec/changes/text-v2-rich-text/specs/text-v2/layout/spec.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/layout/spec.md diff --git a/openspec/changes/text-v2-rich-text/specs/text-v2/model/spec.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/model/spec.md similarity index 100% rename from openspec/changes/text-v2-rich-text/specs/text-v2/model/spec.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/model/spec.md diff --git a/openspec/changes/text-v2-rich-text/specs/text-v2/rich-text-api/spec.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/rich-text-api/spec.md similarity index 100% rename from openspec/changes/text-v2-rich-text/specs/text-v2/rich-text-api/spec.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/specs/text-v2/rich-text-api/spec.md diff --git a/openspec/changes/text-v2-rich-text/tasks.md b/openspec/changes/archive/2026-10-26-text-v2-rich-text/tasks.md similarity index 100% rename from openspec/changes/text-v2-rich-text/tasks.md rename to openspec/changes/archive/2026-10-26-text-v2-rich-text/tasks.md diff --git a/openspec/specs/text-v2/editing/spec.md b/openspec/specs/text-v2/editing/spec.md index 9c0bcedd9..954231a21 100644 --- a/openspec/specs/text-v2/editing/spec.md +++ b/openspec/specs/text-v2/editing/spec.md @@ -88,3 +88,38 @@ Entering edit mode on an existing node SHALL select all its content; a newly cre #### Scenario: Edit existing text - **WHEN** the user enters edit mode on a node with text - **THEN** all text is selected and typing replaces it + +### Requirement: Format shortcuts and commands in session +When rich text is enabled for the node, Ctrl/Cmd+B, I and U SHALL toggle marks on the selection or stored marks, and each format command SHALL be exactly one session-undo entry. The proxy textarea SHALL keep stopping keydown propagation so app-level undo does not fire. + +#### Scenario: Undo format +- **WHEN** a format command is applied then undone +- **THEN** content and selection are restored + +### Requirement: List key rules +When rich text is enabled for the node, Enter SHALL continue a list item, Enter on an empty item SHALL exit the list, Backspace at item start SHALL outdent or remove the marker, and Tab / Shift+Tab SHALL change depth. + +#### Scenario: Empty item +- **WHEN** Enter is pressed in an empty list item +- **THEN** the block becomes a plain paragraph + +### Requirement: Enter keeps structure when disabled +With rich text disabled for the node, Enter in a list item SHALL create a block copying `kind` and `depth`; no format commands or shortcuts SHALL be active. + +#### Scenario: Flag off +- **WHEN** Enter is pressed in a list item with the flag off +- **THEN** the new block has the same `kind` and `depth` + +### Requirement: Plain paste inherits marks +Paste SHALL insert plain text with the marks at the caret. + +#### Scenario: Paste in bold text +- **WHEN** text is pasted inside bold text +- **THEN** it is bold + +### Requirement: One Weave undo step +Commit on exit SHALL remain a single `updateNode`, skipped when content is unchanged. + +#### Scenario: Many formats +- **WHEN** several format commands run in one session +- **THEN** exit produces one Weave undo step diff --git a/openspec/specs/text-v2/layout/spec.md b/openspec/specs/text-v2/layout/spec.md index b1ee640c5..d083f1940 100644 --- a/openspec/specs/text-v2/layout/spec.md +++ b/openspec/specs/text-v2/layout/spec.md @@ -26,11 +26,11 @@ Layout SHALL be computed lazily and cached; size accessors SHALL force layout; e - **THEN** layout runs and correct size is returned ### Requirement: Multi-run capable -The engine SHALL lay out paragraphs with multiple runs, using a `(font, chunk)` measure cache and `Intl.Segmenter`. +The engine SHALL lay out paragraphs with runs of different font, size and family, measuring each run with its own font via a `(font, chunk)` cache, and SHALL size each line by its largest run with mixed baselines aligned. #### Scenario: Two runs in one paragraph - **WHEN** a paragraph has runs with different fonts -- **THEN** wrapping accounts for each run's measured width +- **THEN** wrapping accounts for each run's measured width and the line height follows the tallest run ### Requirement: Paint-time culling Painting SHALL skip lines outside the visible viewport. @@ -52,3 +52,64 @@ The layout engine SHALL depend on a bounds provider and target, not on `WeaveTex #### Scenario: Alternate target - **WHEN** the engine is used with a non-text-node target - **THEN** it lays out without importing the text node + +### Requirement: Mark painting +The painter SHALL group fragments by font and paint per-run color, underline and strikethrough. + +#### Scenario: Mixed styles +- **WHEN** a line has bold, colored and underlined runs +- **THEN** each fragment is drawn with its own font, color and decoration + +### Requirement: List layout +List blocks SHALL be laid out with an indent of `depth * indentStep` (`indentStep` is a multiple of base `fontSize`) clamped so at least 4 x base `fontSize` remains for text, and a marker in the gutter cycling by depth (disc/circle/square; 1./a./i.). Markers SHALL NOT be part of `text` or offsets, and numbering SHALL NOT invalidate other paragraphs' cached layouts. + +#### Scenario: Deep nesting in a fixed-width box +- **WHEN** depth would leave less than the minimum text width +- **THEN** indent stops growing (0 if the box is smaller than the minimum), marker style keeps cycling, and auto-width layouts are not clamped + +#### Scenario: Typing in an item +- **WHEN** text in one list item changes +- **THEN** only that paragraph is relaid out + +### Requirement: Paragraph alignment layout +Each line SHALL be aligned by its paragraph `align`, falling back to the node `align`; the indent of list items SHALL stay on the left. Changing `align` SHALL NOT relay out wrapping. + +#### Scenario: Mixed alignment +- **WHEN** two paragraphs have different `align` +- **THEN** their lines are positioned accordingly and cached paragraph layouts are reused + +### Requirement: Per-run line height +A line's height SHALL be the maximum over its runs of `(run.lineHeight ?? node lineHeight) * run font size`, with one shared baseline. Culling and the fixed-height cut-off SHALL use real line heights. Vertical alignment SHALL remain a node-wide setting based on the total content height. + +#### Scenario: Mixed line heights on one line +- **WHEN** a line contains runs with line heights 1.0 and 1.5 +- **THEN** the line box uses the larger resulting height + +#### Scenario: Lower value on part of a line +- **WHEN** a smaller line height is set on a run sharing a line with a taller run +- **THEN** the line height does not shrink + +### Requirement: Link runs +Runs with a safe `link` mark SHALL render with the link colour and an underline, and the hovered link run SHALL use the hover colour; the legacy node-level `link` SHALL take precedence when set. Links SHALL render regardless of `richText.enabled`. + +#### Scenario: Hover +- **WHEN** the pointer is over a link run +- **THEN** only that run uses the hover colour and the cursor is a pointer + +#### Scenario: Unsafe URL +- **WHEN** a run carries a non-http(s) `link` +- **THEN** it renders as plain text and cannot be opened + +### Requirement: Lists render on all clients +List markers and indent SHALL render whenever blocks carry `kind`, independent of `richText.enabled`. + +#### Scenario: Flag off +- **WHEN** a document with list blocks loads with `richText` disabled +- **THEN** markers and indentation render identically + +### Requirement: Export parity +Image export, including server-side, SHALL render rich runs and lists; per-run font families SHALL be available to the server measurer. + +#### Scenario: Export +- **WHEN** a rich node is exported +- **THEN** the image shows the formatting diff --git a/openspec/specs/text-v2/model/spec.md b/openspec/specs/text-v2/model/spec.md index a9a52befc..ef3c313b7 100644 --- a/openspec/specs/text-v2/model/spec.md +++ b/openspec/specs/text-v2/model/spec.md @@ -26,15 +26,61 @@ An existing whole-text `link` attribute SHALL be treated as a full-range `link` - **THEN** synthesized runs carry a `link` mark over the full range ### Requirement: Marks preserved -Documents already containing marks SHALL render them read-only and SHALL preserve them across edits. +Documents containing marks or list blocks SHALL render them, and SHALL preserve marks and block `kind`, `depth` and `start` across edits, regardless of `richText.enabled`. #### Scenario: Edit marked text - **WHEN** a user edits text in a node with marks - **THEN** marks on untouched text are retained in the committed `blocks` +#### Scenario: Plain edit on marked text +- **WHEN** a user edits text containing marks with `richText` disabled +- **THEN** untouched runs keep their marks and list blocks keep `kind`, `depth` and `start` + ### Requirement: Drop-in replacement `WeaveTextV2Node` SHALL use `nodeType` `'text'` so existing documents load and render unchanged, and SHALL NOT modify the existing `WeaveTextNode` class, which keeps working when registered instead. #### Scenario: Swap registration - **WHEN** an app registers `WeaveTextV2Node` instead of `WeaveTextNode` - **THEN** existing `type: 'text'` documents render and edit with the new node + +### Requirement: Typed marks +Runs SHALL support the marks `bold`, `italic`, `underline`, `strike`, `color`, `size` and `family`. `size` SHALL be stored as a positive ratio of the node base `fontSize`, rounded to 4 decimals; a ratio of 1 SHALL be omitted. Invalid ratios SHALL be ignored on read. + +#### Scenario: Plain document unchanged +- **WHEN** no mark is applied +- **THEN** serialized `blocks` are identical to those before this change + +### Requirement: List blocks +Blocks SHALL support optional `kind` (`ul` | `ol`), `depth` (unbounded) and `start`. A block without `kind` SHALL be a plain paragraph. + +#### Scenario: Ordered numbering +- **WHEN** consecutive `ol` items share a depth +- **THEN** numbering is continuous, restarting after a non-list block or a shallower item, and `start` overrides the first number + +### Requirement: Range operations +The model SHALL provide pure operations to apply or toggle a mark over a range, query a mark over a range as `all` | `none` | `some`, split a block carrying `kind` and `depth`, and change depth. + +#### Scenario: Partial selection +- **WHEN** bold is applied to half a run +- **THEN** the run is split and normalized with adjacent equal runs merged + +### Requirement: Paragraph alignment +Blocks SHALL support an optional `align` (`left` | `center` | `right`). A block without it SHALL use the node `align`. Splitting or editing a block SHALL preserve it. + +#### Scenario: Enter keeps alignment +- **WHEN** Enter is pressed in a centered paragraph +- **THEN** the new block is centered + +### Requirement: Line height mark +Runs SHALL support a `lineHeight` mark, a positive multiplier of the run font size. Invalid values SHALL be ignored on read and new values clamped to 0.5 to 4. + +#### Scenario: Invalid value +- **WHEN** a run carries `lineHeight` 0 or NaN +- **THEN** the node line height is used + +### Requirement: Stored marks +A collapsed caret SHALL be able to hold stored marks that override inheritance for the next inserted text until the selection moves. + +#### Scenario: Typing after toggle +- **WHEN** bold is toggled at a collapsed caret and the user types +- **THEN** the inserted text is bold diff --git a/openspec/specs/text-v2/rich-text-api/spec.md b/openspec/specs/text-v2/rich-text-api/spec.md new file mode 100644 index 000000000..db94ebc6a --- /dev/null +++ b/openspec/specs/text-v2/rich-text-api/spec.md @@ -0,0 +1,66 @@ +## Purpose + +Defines the opt-in rich text API of `WeaveTextV2Node`: configuration, text tool, formatting commands, links, alignment, line height, selection query and events. + +## Requirements + +### Requirement: Opt-in config +Rich text SHALL be enabled per node by the boolean attribute `richText`; a node without it SHALL follow `config.richText.enabled` (default false). `WeaveTextV2Node` SHALL accept `config.richText` with `enabled` and `indentStep` (multiple of base `fontSize`, default 1.5; values <= 0 or NaN fall back to the default; must match across clients). When disabled, commands SHALL be no-ops and no formatting UI behavior is active. + +#### Scenario: Per-node attribute +- **WHEN** a node has `richText: true` and the config is off (or the reverse) +- **THEN** the attribute decides whether commands and shortcuts work + +#### Scenario: Disabled +- **WHEN** a command is called with `richText.enabled` false +- **THEN** nothing changes + +### Requirement: Text tool creates rich nodes +`WeaveTextToolAction` SHALL accept optional `name` and `props`, so two tool instances can create plain and rich text nodes (`props: { richText: true }`). Defaults SHALL be unchanged. + +#### Scenario: Second tool +- **WHEN** a tool is created with `{ name: 'richTextTool', props: { richText: true } }` +- **THEN** it has that name and the nodes it creates carry `richText: true` + +### Requirement: Commands +The node SHALL expose `applyMark`, `toggleMark`, `toggleList`, `indent`, `outdent` and `setBaseFontSize`, working in an active session and on a selected non-editing node (whole text or a given range). Outside a session each call SHALL be one `updateNode`. Font sizes SHALL be px at the API and converted to ratios internally. + +#### Scenario: Modal edit +- **WHEN** a command targets a non-editing node +- **THEN** the text updates with one undo step + +#### Scenario: Size round trip +- **WHEN** size 24 is applied on base 16 and queried +- **THEN** 24 is returned, rounded to 0.1 + +### Requirement: Run links +The node SHALL expose `setRunLink` and `removeRunLink` (and `applyMark(node, 'link', url)`), accepting only absolute http(s) URLs. Ctrl/Cmd+click on a link run, or a click on an already-selected node after the double-click window, SHALL open the URL in a new tab with `noopener,noreferrer`. + +#### Scenario: Unsafe URL rejected +- **WHEN** `setRunLink` receives `javascript:alert(1)` +- **THEN** it returns false and nothing changes + +#### Scenario: Open +- **WHEN** the user Ctrl/Cmd+clicks a link run +- **THEN** the URL opens in a new tab + +### Requirement: Alignment and line height commands +The node SHALL expose `setAlign(node, align, range?)` (every paragraph touched by the range) and accept `lineHeight` in `applyMark`. They SHALL follow the same rules as other commands: gated by rich text, one session-undo entry while editing, one `updateNode` otherwise. A whole-node `align` or `lineHeight` change SHALL NOT clear paragraph or run overrides. + +#### Scenario: Align a selection +- **WHEN** `setAlign(node, 'center', range)` covers two paragraphs +- **THEN** both are centered + +### Requirement: Selection format query +`getSelectionFormat` SHALL return per mark `all` | `none` | `some`, with resolved values (size in px) when uniform, `lineHeight`, paragraph `align` (`all` | `none` | `some` with the value when uniform), and list kind and depth. + +#### Scenario: Mixed +- **WHEN** only part of the selection is bold +- **THEN** bold is `some` + +### Requirement: Events +The node SHALL emit `onTextSelectionChange` (throttled) and `onTextFormatChange` after commands, undo and redo, with `reason: 'base-size'` for base size changes. + +#### Scenario: Toolbar sync +- **WHEN** the selection moves in a session +- **THEN** `onTextSelectionChange` fires with the format From b618c9f79e157634cd5c5b4c363a13fd5da11610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Mon, 26 Oct 2026 15:53:14 +0100 Subject: [PATCH 5/5] refactor(sdk): share label edit helpers to cut duplication (GH-1226) Extract the mutex lock, selection suspend/resume, label height fit and label commit into shape-label.edit-shared, and the stage coordinate mapping into text-v2/edit/stage-coordinates, so the legacy and native label editors and the text-v2 node stop duplicating them (SonarCloud new-code duplication gate). --- .../packages/sdk/src/nodes/ellipse/ellipse.ts | 22 ++--- .../packages/sdk/src/nodes/polygon/polygon.ts | 24 +++-- .../sdk/src/nodes/rectangle/rectangle.ts | 22 ++--- .../src/nodes/shared/shape-label-editor.ts | 22 ++--- .../nodes/shared/shape-label-native-editor.ts | 60 +++--------- .../nodes/shared/shape-label.edit-shared.ts | 96 +++++++++++++++++++ .../nodes/text-v2/edit/stage-coordinates.ts | 28 ++++++ .../packages/sdk/src/nodes/text-v2/text-v2.ts | 15 +-- 8 files changed, 173 insertions(+), 116 deletions(-) create mode 100644 code/packages/sdk/src/nodes/shared/shape-label.edit-shared.ts create mode 100644 code/packages/sdk/src/nodes/text-v2/edit/stage-coordinates.ts diff --git a/code/packages/sdk/src/nodes/ellipse/ellipse.ts b/code/packages/sdk/src/nodes/ellipse/ellipse.ts index f748f0c1d..76598f1f9 100644 --- a/code/packages/sdk/src/nodes/ellipse/ellipse.ts +++ b/code/packages/sdk/src/nodes/ellipse/ellipse.ts @@ -14,6 +14,7 @@ import { WEAVE_ELLIPSE_NODE_TYPE } from './constants'; import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import type { WeaveEllipseNodeParams, WeaveEllipseProperties } from './types'; import { mergeExceptArrays } from '@/utils/utils'; +import { commitShapeLabel } from '@/nodes/shared/shape-label.edit-shared'; import { createShapeLabelEngine } from '@/nodes/shared/shape-label.engine'; import type { WeaveShapeLabelEngine } from '@/nodes/shared/shape-label.types'; import { @@ -193,19 +194,14 @@ export class WeaveEllipseNode extends WeaveNode { const onCommit = ( labelText: string, labelBlocks?: WeaveElementAttributes['labelBlocks'] - ) => { - const updatedGroup = this.instance - .getStage() - .findOne(`#${props.id}`); - if (!updatedGroup) { - return; - } - updatedGroup.setAttr('labelBlocks', labelBlocks); - const serialized = this.serialize(updatedGroup); - serialized.props.labelText = labelText; - serialized.props.labelBlocks = labelBlocks; - this.instance.updateNode(serialized); - }; + ) => + commitShapeLabel( + this.instance, + (g) => this.serialize(g), + `${props.id}`, + labelText, + labelBlocks + ); // Re-derive bounds from live attrs so a resized ellipse uses the // correct dimensions when the edit overlay is shown. diff --git a/code/packages/sdk/src/nodes/polygon/polygon.ts b/code/packages/sdk/src/nodes/polygon/polygon.ts index 2aeb396e0..4e1c42082 100644 --- a/code/packages/sdk/src/nodes/polygon/polygon.ts +++ b/code/packages/sdk/src/nodes/polygon/polygon.ts @@ -18,6 +18,7 @@ import { applyLabelBlocksState, getShapeLabelSchemaFields, } from '@/nodes/shared/shape-label.utils'; +import { commitShapeLabel } from '@/nodes/shared/shape-label.edit-shared'; import { createShapeLabelEngine } from '@/nodes/shared/shape-label.engine'; import type { WeaveShapeLabelEngine } from '@/nodes/shared/shape-label.types'; import { @@ -255,19 +256,16 @@ export class WeavePolygonNode extends WeaveNode { props: WeaveElementAttributes ): void { const onCommit = ( - labelText: string, - labelBlocks?: WeaveElementAttributes['labelBlocks'] - ) => { - const updatedGroup = this.instance - .getStage() - .findOne(`#${props.id}`); - if (!updatedGroup) return; - updatedGroup.setAttr('labelBlocks', labelBlocks); - const serialized = this.serialize(updatedGroup); - serialized.props.labelText = labelText; - serialized.props.labelBlocks = labelBlocks; - this.instance.updateNode(serialized); - }; + labelText: string, + labelBlocks?: WeaveElementAttributes['labelBlocks'] + ) => + commitShapeLabel( + this.instance, + (g) => this.serialize(g), + `${props.id}`, + labelText, + labelBlocks + ); const currentLabelTextBounds = this.getLabelTextBounds(polygon); diff --git a/code/packages/sdk/src/nodes/rectangle/rectangle.ts b/code/packages/sdk/src/nodes/rectangle/rectangle.ts index 776e29224..ef1aa9474 100644 --- a/code/packages/sdk/src/nodes/rectangle/rectangle.ts +++ b/code/packages/sdk/src/nodes/rectangle/rectangle.ts @@ -17,6 +17,7 @@ import type { WeaveRectangleProperties, } from './types'; import { mergeExceptArrays } from '@/index'; +import { commitShapeLabel } from '@/nodes/shared/shape-label.edit-shared'; import { createShapeLabelEngine } from '@/nodes/shared/shape-label.engine'; import type { WeaveShapeLabelEngine } from '@/nodes/shared/shape-label.types'; import { @@ -147,19 +148,14 @@ export class WeaveRectangleNode extends WeaveNode { const onCommit = ( labelText: string, labelBlocks?: WeaveElementAttributes['labelBlocks'] - ) => { - const updatedGroup = this.instance - .getStage() - .findOne(`#${props.id}`); - if (!updatedGroup) { - return; - } - updatedGroup.setAttr('labelBlocks', labelBlocks); - const serialized = this.serialize(updatedGroup); - serialized.props.labelText = labelText; - serialized.props.labelBlocks = labelBlocks; - this.instance.updateNode(serialized); - }; + ) => + commitShapeLabel( + this.instance, + (g) => this.serialize(g), + `${props.id}`, + labelText, + labelBlocks + ); // Re-derive bounds from live attrs so a resized rectangle uses the // correct dimensions when the edit overlay is shown. diff --git a/code/packages/sdk/src/nodes/shared/shape-label-editor.ts b/code/packages/sdk/src/nodes/shared/shape-label-editor.ts index 26daffbe3..51f03f28c 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label-editor.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label-editor.ts @@ -8,6 +8,10 @@ import type { WeaveElementAttributes } from '@inditextech/weave-types'; import { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import { getTopmostShadowHost, isInShadowDOM } from '@/utils/utils'; import { WEAVE_STAGE_DEFAULT_MODE } from '@/nodes/stage/constants'; +import { + acquireLabelEditLock, + fitLabelHeight, +} from './shape-label.edit-shared'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS, @@ -172,16 +176,7 @@ export class WeaveShapeLabelEditor implements WeaveShapeLabelEngine { // enable auto-height. Only `attrs.height === undefined` triggers the auto // computation: `fontSize * lineCount * lineHeight + padding*2`. if (labelText !== '') { - labelNode.setAttr('height', undefined); - const measuredHeight = labelNode.height(); - // Restore to at least textBounds.height (needed for verticalAlign: 'middle') - labelNode.height(Math.max(textBounds.height, measuredHeight)); - - if (growCallback && measuredHeight > textBounds.height) { - const paddingY = - nextProps.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; - growCallback(measuredHeight + paddingY * 2); - } + fitLabelHeight(labelNode, nextProps, textBounds, growCallback); } } @@ -207,12 +202,7 @@ export class WeaveShapeLabelEditor implements WeaveShapeLabelEngine { return; } - const lockAcquired = this.instance.setMutexLock({ - nodeIds: [group.id()], - operation: 'label-edit', - }); - - if (!lockAcquired) { + if (!acquireLabelEditLock(this.instance, group)) { return; } diff --git a/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts b/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts index 3aa08c7ba..2205ef25f 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label-native-editor.ts @@ -2,10 +2,10 @@ // // SPDX-License-Identifier: Apache-2.0 +import { createStageCoordinateMapper } from '@/nodes/text-v2/edit/stage-coordinates'; import Konva from 'konva'; import type { Weave } from '@/weave'; import type { WeaveElementAttributes } from '@inditextech/weave-types'; -import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; import { WEAVE_STAGE_DEFAULT_MODE } from '@/nodes/stage/constants'; import { WeaveTextV2Shape } from '@/nodes/text-v2/shape'; import { blocksToText, hasMarks, type WeaveTextBlock } from '@/nodes/text-v2/model'; @@ -16,6 +16,12 @@ import { type TextEditHost, type TextEditTarget, } from '@/nodes/text-v2/edit/session'; +import { + acquireLabelEditLock, + fitLabelHeight, + resumeNodesSelection, + suspendNodesSelection, +} from './shape-label.edit-shared'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS, @@ -133,15 +139,7 @@ export class WeaveShapeLabelNativeEditor implements WeaveShapeLabelEngine { if (blocks) labelNode.setBlocks(blocks); if (labelText !== '') { - labelNode.setAttr('height', undefined); - const measuredHeight = labelNode.height(); - labelNode.height(Math.max(textBounds.height, measuredHeight)); - - if (growCallback && measuredHeight > textBounds.height) { - const paddingY = - nextProps.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; - growCallback(measuredHeight + paddingY * 2); - } + fitLabelHeight(labelNode, nextProps, textBounds, growCallback); } } @@ -175,11 +173,7 @@ export class WeaveShapeLabelNativeEditor implements WeaveShapeLabelEngine { const shape = group.findOne(`#${labelId(group.id())}`); if (!shape) return; - const lockAcquired = this.instance.setMutexLock({ - nodeIds: [group.id()], - operation: 'label-edit', - }); - if (!lockAcquired) return; + if (!acquireLabelEditLock(this.instance, group)) return; this.editing = true; this.editingGroup = group; @@ -190,33 +184,15 @@ export class WeaveShapeLabelNativeEditor implements WeaveShapeLabelEngine { shape.visible(true); - const selectionPlugin = - this.instance.getPlugin('nodesSelection'); - if (selectionPlugin) { - const tr = selectionPlugin.getTransformer(); - this.instance.disablePlugin('nodesSelection'); - tr.hide(); - } + suspendNodesSelection(this.instance); const stage = this.instance.getStage(); const container = stage.container(); - const rect = () => container.getBoundingClientRect(); const host: TextEditHost = { utilityLayer: this.instance.getUtilityLayer(), container, - clientToStage: (cx, cy) => { - const r = rect(); - const sx = r.width ? stage.width() / r.width : 1; - const sy = r.height ? stage.height() / r.height : 1; - return { x: (cx - r.left) * sx, y: (cy - r.top) * sy }; - }, - stageToContainer: (p) => { - const r = rect(); - const sx = r.width ? r.width / stage.width() : 1; - const sy = r.height ? r.height / stage.height() : 1; - return { x: p.x * sx, y: p.y * sy }; - }, + ...createStageCoordinateMapper(stage), caretColor: '#000000', selectionColor: WEAVE_TEXT_V2_SELECTION_COLOR, selectAllOnStart: true, @@ -364,18 +340,6 @@ export class WeaveShapeLabelNativeEditor implements WeaveShapeLabelEngine { ); } - const selectionPlugin = - this.instance.getPlugin('nodesSelection'); - if (selectionPlugin) { - this.instance.enablePlugin('nodesSelection'); - if (editedGroupId) { - requestAnimationFrame(() => { - const liveGroup = this.instance - .getStage() - .findOne(`#${editedGroupId}`); - if (liveGroup) selectionPlugin.setSelectedNodes([liveGroup]); - }); - } - } + resumeNodesSelection(this.instance, editedGroupId); } } diff --git a/code/packages/sdk/src/nodes/shared/shape-label.edit-shared.ts b/code/packages/sdk/src/nodes/shared/shape-label.edit-shared.ts new file mode 100644 index 000000000..b80bd84d9 --- /dev/null +++ b/code/packages/sdk/src/nodes/shared/shape-label.edit-shared.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type Konva from 'konva'; +import type { Weave } from '@/weave'; +import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; +import { WEAVE_SHAPE_LABEL_DEFAULTS } from './shape-label.constants'; +import type { + WeaveShapeLabelNode, + WeaveShapeLabelTextBounds, +} from './shape-label.types'; + +/** Takes the `label-edit` mutex lock for the shape; false when refused. */ +export function acquireLabelEditLock( + instance: Weave, + group: Konva.Group +): boolean { + return instance.setMutexLock({ + nodeIds: [group.id()], + operation: 'label-edit', + }); +} + +/** Disables the nodes selection plugin and hides its transformer. */ +export function suspendNodesSelection(instance: Weave): void { + const selectionPlugin = + instance.getPlugin('nodesSelection'); + if (!selectionPlugin) return; + const tr = selectionPlugin.getTransformer(); + instance.disablePlugin('nodesSelection'); + tr.hide(); +} + +/** Re-enables the selection plugin and re-selects the edited shape. */ +export function resumeNodesSelection( + instance: Weave, + editedGroupId: string | null +): void { + const selectionPlugin = + instance.getPlugin('nodesSelection'); + if (!selectionPlugin) return; + instance.enablePlugin('nodesSelection'); + if (!editedGroupId) return; + requestAnimationFrame(() => { + const liveGroup = instance + .getStage() + .findOne(`#${editedGroupId}`); + if (liveGroup) selectionPlugin.setSelectedNodes([liveGroup]); + }); +} + +/** + * Sizes the label to its content (auto-height), keeps at least the bounds + * height (needed for vertical alignment) and asks the shape to grow when the + * content overflows the bounds. + */ +export function fitLabelHeight( + labelNode: WeaveShapeLabelNode, + nextProps: WeaveElementAttributes, + textBounds: WeaveShapeLabelTextBounds, + growCallback?: (neededShapeHeight: number) => void +): void { + // Only `attrs.height === undefined` triggers the auto height computation. + labelNode.setAttr('height', undefined); + const measuredHeight = labelNode.height(); + labelNode.height(Math.max(textBounds.height, measuredHeight)); + + if (growCallback && measuredHeight > textBounds.height) { + const paddingY = + nextProps.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + growCallback(measuredHeight + paddingY * 2); + } +} + +/** Persists an edited label (plain mirror plus optional rich blocks). */ +export function commitShapeLabel( + instance: Weave, + serialize: (node: Konva.Group) => { + props: WeaveElementAttributes; + } & Record, + nodeId: string, + labelText: string, + labelBlocks?: WeaveElementAttributes['labelBlocks'] +): void { + const updatedGroup = instance + .getStage() + .findOne(`#${nodeId}`); + if (!updatedGroup) return; + updatedGroup.setAttr('labelBlocks', labelBlocks); + const serialized = serialize(updatedGroup); + serialized.props.labelText = labelText; + serialized.props.labelBlocks = labelBlocks; + instance.updateNode(serialized as never); +} diff --git a/code/packages/sdk/src/nodes/text-v2/edit/stage-coordinates.ts b/code/packages/sdk/src/nodes/text-v2/edit/stage-coordinates.ts new file mode 100644 index 000000000..7a0d9bd1c --- /dev/null +++ b/code/packages/sdk/src/nodes/text-v2/edit/stage-coordinates.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type Konva from 'konva'; +import type { Point } from './geometry'; + +/** Client <-> stage (container px) mapping used by edit-session hosts. */ +export function createStageCoordinateMapper(stage: Konva.Stage): { + clientToStage(clientX: number, clientY: number): Point; + stageToContainer(point: Point): Point; +} { + const rect = () => stage.container().getBoundingClientRect(); + return { + clientToStage: (cx, cy) => { + const r = rect(); + const sx = r.width ? stage.width() / r.width : 1; + const sy = r.height ? stage.height() / r.height : 1; + return { x: (cx - r.left) * sx, y: (cy - r.top) * sy }; + }, + stageToContainer: (p) => { + const r = rect(); + const sx = r.width ? r.width / stage.width() : 1; + const sy = r.height ? r.height / stage.height() : 1; + return { x: p.x * sx, y: p.y * sy }; + }, + }; +} diff --git a/code/packages/sdk/src/nodes/text-v2/text-v2.ts b/code/packages/sdk/src/nodes/text-v2/text-v2.ts index 701c6741f..78198090f 100644 --- a/code/packages/sdk/src/nodes/text-v2/text-v2.ts +++ b/code/packages/sdk/src/nodes/text-v2/text-v2.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 +import { createStageCoordinateMapper } from './edit/stage-coordinates'; import Konva from 'konva'; import merge from 'lodash/merge'; import { z } from 'zod'; @@ -381,25 +382,13 @@ export class WeaveTextV2Node extends WeaveNode { const stage = this.instance.getStage(); const container = stage.container(); - const rect = () => container.getBoundingClientRect(); this.session = new TextEditSession( this.editTarget(shape), { utilityLayer: this.instance.getUtilityLayer(), container, - clientToStage: (cx, cy) => { - const r = rect(); - const sx = r.width ? stage.width() / r.width : 1; - const sy = r.height ? stage.height() / r.height : 1; - return { x: (cx - r.left) * sx, y: (cy - r.top) * sy }; - }, - stageToContainer: (p) => { - const r = rect(); - const sx = r.width ? r.width / stage.width() : 1; - const sy = r.height ? r.height / stage.height() : 1; - return { x: p.x * sx, y: p.y * sy }; - }, + ...createStageCoordinateMapper(stage), revealCaret: (p) => { const box = container; const w = box.clientWidth;