From f69186855bf7c8911db7cc10dacadf990b595d43 Mon Sep 17 00:00:00 2001 From: jmcaamanog Date: Thu, 22 Oct 2026 14:31:29 +0200 Subject: [PATCH 01/10] feat(sdk): add sticky note node and placement tool action (#1232) - Add WeaveStickyNoteNode with 3D dog-ear folded corner and pastel palette - Integrate shape-label engine (#1226) for inline editing and label* props - Implement live resize with scaleReset and onUpdate to avoid distortions - Add WeaveStickyNoteToolAction with click-to-place and drag-to-size - Export node, action, and computeStickyNoteLabelMinSize in index.common.ts - Expand unit test suites to 41 automated tests (100% passing) - Update code/CHANGELOG.md under [Unreleased] referencing #1232 - Rebase cleanly on origin/main (5.7.1) and ensure REUSE 3.3 compliance Signed-off-by: jmcaamanog --- code/CHANGELOG.md | 4 + .../__tests__/sticky-note-tool.test.ts | 453 ++++++++++++++ .../src/actions/sticky-note-tool/constants.ts | 12 + .../sticky-note-tool/sticky-note-tool.ts | 388 ++++++++++++ .../sdk/src/actions/sticky-note-tool/types.ts | 13 + code/packages/sdk/src/index.common.ts | 6 + .../__tests__/shape-label.utils.test.ts | 59 +- .../sdk/src/nodes/shared/shape-label.utils.ts | 142 ++++- .../sticky-note/__tests__/sticky-note.test.ts | 294 +++++++++ .../sdk/src/nodes/sticky-note/constants.ts | 28 + .../sdk/src/nodes/sticky-note/sticky-note.ts | 560 ++++++++++++++++++ .../sdk/src/nodes/sticky-note/types.ts | 31 + 12 files changed, 1954 insertions(+), 36 deletions(-) create mode 100644 code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts create mode 100644 code/packages/sdk/src/actions/sticky-note-tool/constants.ts create mode 100644 code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts create mode 100644 code/packages/sdk/src/actions/sticky-note-tool/types.ts create mode 100644 code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts create mode 100644 code/packages/sdk/src/nodes/sticky-note/constants.ts create mode 100644 code/packages/sdk/src/nodes/sticky-note/sticky-note.ts create mode 100644 code/packages/sdk/src/nodes/sticky-note/types.ts diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md index 9bb6b6ca2..c4077bf53 100644 --- a/code/CHANGELOG.md +++ b/code/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- [#1232](https://github.com/InditexTech/weavejs/issues/1232) Add sticky note canvas primitive and interactive placement tool + ## [5.7.1] - 2026-10-27 ### Fixed diff --git a/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts b/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts new file mode 100644 index 000000000..d4fecb020 --- /dev/null +++ b/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts @@ -0,0 +1,453 @@ +// 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, beforeEach, afterEach } from 'vitest'; + +// Break circular dependency +vi.mock('@/weave', () => ({ Weave: class Weave {} })); +vi.mock('@/plugins/nodes-selection/nodes-selection', () => ({ + WeaveNodesSelectionPlugin: class WeaveNodesSelectionPlugin {}, +})); + +vi.mock('uuid', () => ({ v4: vi.fn().mockReturnValue('test-note-uuid') })); + +// Hoist Konva.Rect mock +const { MockRect } = vi.hoisted(() => { + const MockRect = vi + .fn() + .mockImplementation(function (attrs: Record) { + const inst = { + _attrs: { ...attrs }, + setAttrs: vi + .fn() + .mockImplementation(function ( + this: { _attrs: Record }, + a: Record + ) { + Object.assign(this._attrs, a); + }), + getAttrs: vi + .fn() + .mockImplementation(function (this: { + _attrs: Record; + }) { + return this._attrs; + }), + clone: vi.fn(), + destroy: vi.fn(), + }; + inst.clone.mockImplementation(() => ({ + _attrs: { ...inst._attrs }, + getAttrs: vi.fn().mockReturnValue({ ...inst._attrs }), + destroy: vi.fn(), + })); + return inst; + }); + return { MockRect }; +}); + +vi.mock('konva', () => ({ default: { Rect: MockRect } })); + +// In node environment alias window -> globalThis +if (typeof (globalThis as Record)['window'] === 'undefined') { + (globalThis as Record)['window'] = globalThis; +} + +import { + makePointerEvent, + type R, +} from '../../__tests__/shared/action.test-helpers'; +import { WeaveStickyNoteToolAction } from '../sticky-note-tool'; +import { + STICKY_NOTE_TOOL_ACTION_NAME, + STICKY_NOTE_TOOL_STATE, +} from '../constants'; +import { + WEAVE_STICKY_NOTE_NODE_TYPE, + DEFAULT_STICKY_NOTE_WIDTH, + DEFAULT_STICKY_NOTE_HEIGHT, + DEFAULT_STICKY_NOTE_COLOR, +} from '@/nodes/sticky-note/constants'; +import { SELECTION_TOOL_ACTION_NAME } from '../../selection-tool/constants'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeContainer(id = 'layer-id') { + return { + getAttrs: vi.fn().mockReturnValue({ id }), + add: vi.fn(), + }; +} + +function makeMockWeave() { + const stageContainer = { + tabIndex: 0, + focus: vi.fn(), + blur: vi.fn(), + click: vi.fn(), + style: { cursor: '' }, + findOne: vi.fn().mockReturnValue(null), + }; + + const stageHandlers: Record void> = {}; + const stage = { + container: vi.fn().mockReturnValue(stageContainer), + on: vi.fn((event: string, handler: (e?: unknown) => void) => { + if (stageHandlers[event]) { + const prev = stageHandlers[event]; + stageHandlers[event] = (e) => { + prev(e); + handler(e); + }; + } else { + stageHandlers[event] = handler; + } + }), + findOne: vi.fn().mockReturnValue(null), + }; + + const measureContainer = makeContainer('measure-layer'); + const defaultContainer = makeContainer('layer-id'); + + const selectionPlugin = { + setSelectedNodes: vi.fn(), + }; + + return { + getStage: vi.fn().mockReturnValue(stage), + getPlugin: vi.fn().mockReturnValue(selectionPlugin), + getMousePointer: vi.fn().mockReturnValue({ + mousePoint: { x: 50, y: 75 }, + container: defaultContainer, + measureContainer, + }), + getMousePointerRelativeToContainer: vi.fn().mockReturnValue({ + mousePoint: { x: 250, y: 275 }, + }), + getNodeHandler: vi.fn().mockReturnValue(undefined), + getEventsController: vi.fn().mockReturnValue(new AbortController()), + getActiveAction: vi.fn().mockReturnValue(STICKY_NOTE_TOOL_ACTION_NAME), + emitEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addNode: vi.fn(), + triggerAction: vi.fn(), + getChildLogger: vi.fn().mockReturnValue({ debug: vi.fn() }), + _stage: stage, + _stageContainer: stageContainer, + _stageHandlers: stageHandlers, + _selectionPlugin: selectionPlugin, + _measureContainer: measureContainer, + _defaultContainer: defaultContainer, + }; +} + +function makeNodeHandler() { + const mockNode = { + getAttrs: vi.fn().mockReturnValue({ id: 'test-note-uuid' }), + }; + return { + create: vi.fn().mockReturnValue(mockNode), + _mockNode: mockNode, + }; +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe('WeaveStickyNoteToolAction', () => { + let action: WeaveStickyNoteToolAction; + let mockWeave: ReturnType; + let windowHandlers: Record void>; + + beforeEach(() => { + windowHandlers = {}; + vi.stubGlobal( + 'addEventListener', + vi.fn((type: string, handler: (e: KeyboardEvent) => void) => { + windowHandlers[type] = handler; + }) + ); + + action = new WeaveStickyNoteToolAction(); + mockWeave = makeMockWeave(); + (action as unknown as R)['instance'] = mockWeave; + (action as unknown as R)['cancelAction'] = vi.fn(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + function triggerAction(cancelFn = vi.fn()) { + action.trigger(cancelFn); + return { cancelFn, handlers: mockWeave._stageHandlers }; + } + + describe('constructor / initialize', () => { + it('1.1 initialized=false, state=IDLE, noteId=null after construction', () => { + expect((action as unknown as R)['initialized']).toBe(false); + expect((action as unknown as R)['state']).toBe( + STICKY_NOTE_TOOL_STATE.IDLE + ); + expect((action as unknown as R)['noteId']).toBeNull(); + }); + + it('1.2 initProps returns default sticky note dimensions and pastel color', () => { + const props = action.initProps(); + expect(props.width).toBe(DEFAULT_STICKY_NOTE_WIDTH); + expect(props.height).toBe(DEFAULT_STICKY_NOTE_HEIGHT); + expect(props.color).toBe(DEFAULT_STICKY_NOTE_COLOR); + }); + }); + + describe('getName', () => { + it('2.1 returns STICKY_NOTE_TOOL_ACTION_NAME', () => { + expect(action.getName()).toBe(STICKY_NOTE_TOOL_ACTION_NAME); + }); + }); + + describe('trigger', () => { + it('3.1 throws if instance is not set', () => { + const unassignedAction = new WeaveStickyNoteToolAction(); + expect(() => unassignedAction.trigger(vi.fn())).toThrow( + 'Instance not defined' + ); + }); + + it('3.2 sets crosshair cursor, deselects nodes, emits onAddingStickyNote and transitions to ADDING', () => { + const cancelFn = vi.fn(); + action.trigger(cancelFn); + + expect(mockWeave._stageContainer.style.cursor).toBe('crosshair'); + expect(mockWeave._selectionPlugin.setSelectedNodes).toHaveBeenCalledWith( + [] + ); + expect(mockWeave.emitEvent).toHaveBeenCalledWith('onAddingStickyNote'); + expect((action as unknown as R)['state']).toBe( + STICKY_NOTE_TOOL_STATE.ADDING + ); + }); + }); + + describe('pointerdown / handleAdding', () => { + it('4.1 creates preview rect on measureContainer and transitions to DEFINING_SIZE', () => { + const { handlers } = triggerAction(); + + const pointerdownHandler = handlers['pointerdown']; + expect(pointerdownHandler).toBeDefined(); + + pointerdownHandler( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + + expect((action as unknown as R)['state']).toBe( + STICKY_NOTE_TOOL_STATE.DEFINING_SIZE + ); + expect(mockWeave._measureContainer.add).toHaveBeenCalled(); + expect((action as unknown as R)['tempPreviewNode']).not.toBeNull(); + }); + }); + + describe('pointerup / handleSettingSize & node creation', () => { + it('5.1 creates WeaveStickyNoteNode via nodeHandler, adds to container, emits onAddedStickyNote and cancels action', () => { + const mockHandler = makeNodeHandler(); + mockWeave.getNodeHandler.mockReturnValue(mockHandler); + + const cancelFn = vi.fn(); + const { handlers } = triggerAction(cancelFn); + + // 1. Pointer down -> adding preview + handlers['pointerdown']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + expect((action as unknown as R)['state']).toBe( + STICKY_NOTE_TOOL_STATE.DEFINING_SIZE + ); + + // 2. Pointer up -> finish placement + handlers['pointerup']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + + expect(mockWeave.getNodeHandler).toHaveBeenCalledWith( + WEAVE_STICKY_NOTE_NODE_TYPE + ); + expect(mockHandler.create).toHaveBeenCalledWith( + 'test-note-uuid', + expect.objectContaining({ + width: DEFAULT_STICKY_NOTE_WIDTH, + height: DEFAULT_STICKY_NOTE_HEIGHT, + color: DEFAULT_STICKY_NOTE_COLOR, + }) + ); + expect(mockWeave.addNode).toHaveBeenCalled(); + expect(mockWeave.emitEvent).toHaveBeenCalledWith('onAddedStickyNote'); + expect(cancelFn).toHaveBeenCalled(); + }); + + it('5.2 falls back to default 200x200 dimensions on micro-drag below 20px threshold', () => { + const mockHandler = makeNodeHandler(); + mockWeave.getNodeHandler.mockReturnValue(mockHandler); + mockWeave.getMousePointerRelativeToContainer.mockReturnValue({ + mousePoint: { x: 58, y: 83 }, + }); + + const cancelFn = vi.fn(); + const { handlers } = triggerAction(cancelFn); + + handlers['pointerdown']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + handlers['pointermove']( + makePointerEvent({ pointerId: 1, clientX: 58, clientY: 83 }) + ); + handlers['pointerup']( + makePointerEvent({ pointerId: 1, clientX: 58, clientY: 83 }) + ); + + expect(mockHandler.create).toHaveBeenCalledWith( + 'test-note-uuid', + expect.objectContaining({ + width: DEFAULT_STICKY_NOTE_WIDTH, + height: DEFAULT_STICKY_NOTE_HEIGHT, + }) + ); + }); + + it('5.3 clamps to MIN_STICKY_NOTE_SIZE when dragged beyond 20px threshold but smaller than min size', () => { + const mockHandler = makeNodeHandler(); + mockWeave.getNodeHandler.mockReturnValue(mockHandler); + mockWeave.getMousePointerRelativeToContainer.mockReturnValue({ + mousePoint: { x: 100, y: 125 }, // 50px drag + }); + + const cancelFn = vi.fn(); + const { handlers } = triggerAction(cancelFn); + + handlers['pointerdown']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + handlers['pointermove']( + makePointerEvent({ pointerId: 1, clientX: 100, clientY: 125 }) + ); + handlers['pointerup']( + makePointerEvent({ pointerId: 1, clientX: 100, clientY: 125 }) + ); + + expect(mockHandler.create).toHaveBeenCalledWith( + 'test-note-uuid', + expect.objectContaining({ + width: 120, // MIN_STICKY_NOTE_SIZE + height: 120, // MIN_STICKY_NOTE_SIZE + }) + ); + }); + + it('5.4 creates custom sized note when dragged larger than MIN_STICKY_NOTE_SIZE', () => { + const mockHandler = makeNodeHandler(); + mockWeave.getNodeHandler.mockReturnValue(mockHandler); + mockWeave.getMousePointerRelativeToContainer.mockReturnValue({ + mousePoint: { x: 350, y: 375 }, // 300x300 drag + }); + + const cancelFn = vi.fn(); + const { handlers } = triggerAction(cancelFn); + + handlers['pointerdown']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + handlers['pointermove']( + makePointerEvent({ pointerId: 1, clientX: 350, clientY: 375 }) + ); + handlers['pointerup']( + makePointerEvent({ pointerId: 1, clientX: 350, clientY: 375 }) + ); + + expect(mockHandler.create).toHaveBeenCalledWith( + 'test-note-uuid', + expect.objectContaining({ + width: 300, + height: 300, + }) + ); + }); + + it('5.5 supports Shift constraint during drag sizing', () => { + const mockHandler = makeNodeHandler(); + mockWeave.getNodeHandler.mockReturnValue(mockHandler); + mockWeave.getMousePointerRelativeToContainer.mockReturnValue({ + mousePoint: { x: 350, y: 200 }, // dx=300, dy=125 + }); + + const cancelFn = vi.fn(); + const { handlers } = triggerAction(cancelFn); + + handlers['pointerdown']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + handlers['pointermove']( + makePointerEvent({ + pointerId: 1, + clientX: 350, + clientY: 200, + shiftKey: true, + }) + ); + handlers['pointerup']( + makePointerEvent({ + pointerId: 1, + clientX: 350, + clientY: 200, + shiftKey: true, + }) + ); + + const callArgs = mockHandler.create.mock.calls[0][1]; + // Constrained drag ensures square aspect ratio + expect(callArgs.width).toBe(callArgs.height); + }); + + it('5.6 cancels action and cleans up preview when cancelAction is called', () => { + const cancelFn = vi.fn(); + const { handlers } = triggerAction(cancelFn); + + handlers['pointerdown']( + makePointerEvent({ pointerId: 1, clientX: 50, clientY: 75 }) + ); + expect((action as unknown as R)['state']).toBe( + STICKY_NOTE_TOOL_STATE.DEFINING_SIZE + ); + + (action as unknown as R)['cancelAction'](); + expect(cancelFn).toHaveBeenCalled(); + }); + }); + + describe('cleanup', () => { + it('6.1 restores cursor, selects created node, triggers SELECTION_TOOL_ACTION_NAME, and resets state', () => { + const mockNode = { id: 'test-note-uuid' }; + mockWeave._stage.findOne.mockReturnValue(mockNode); + + triggerAction(); + (action as unknown as R)['noteId'] = 'test-note-uuid'; + + action.cleanup(); + + expect(mockWeave._stageContainer.style.cursor).toBe('default'); + expect(mockWeave._selectionPlugin.setSelectedNodes).toHaveBeenCalledWith([ + mockNode, + ]); + expect(mockWeave.triggerAction).toHaveBeenCalledWith( + SELECTION_TOOL_ACTION_NAME + ); + expect((action as unknown as R)['state']).toBe( + STICKY_NOTE_TOOL_STATE.IDLE + ); + expect((action as unknown as R)['noteId']).toBeNull(); + }); + }); +}); diff --git a/code/packages/sdk/src/actions/sticky-note-tool/constants.ts b/code/packages/sdk/src/actions/sticky-note-tool/constants.ts new file mode 100644 index 000000000..1cb9fa7c7 --- /dev/null +++ b/code/packages/sdk/src/actions/sticky-note-tool/constants.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const STICKY_NOTE_TOOL_ACTION_NAME = 'stickyNoteTool'; + +export const STICKY_NOTE_TOOL_STATE = { + ['IDLE']: 'idle', + ['ADDING']: 'adding', + ['DEFINING_SIZE']: 'definingSize', + ['ADDED']: 'added', +} as const; diff --git a/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts b/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts new file mode 100644 index 000000000..b94cfa445 --- /dev/null +++ b/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { v4 as uuidv4 } from 'uuid'; +import Konva from 'konva'; +import { WeaveAction } from '@/actions/action'; +import type { WeaveElementInstance } from '@inditextech/weave-types'; +import { + type WeaveStickyNoteToolActionOnAddingEvent, + type WeaveStickyNoteToolActionOnAddedEvent, + type WeaveStickyNoteToolActionState, +} from './types'; +import { + STICKY_NOTE_TOOL_ACTION_NAME, + STICKY_NOTE_TOOL_STATE, +} from './constants'; +import { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; +import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; +import type { WeaveStickyNoteNode } from '@/nodes/sticky-note/sticky-note'; +import { + WEAVE_STICKY_NOTE_NODE_TYPE, + DEFAULT_STICKY_NOTE_WIDTH, + DEFAULT_STICKY_NOTE_HEIGHT, + MIN_STICKY_NOTE_SIZE, + DEFAULT_STICKY_NOTE_COLOR, + DEFAULT_DOG_EAR_SIZE, +} from '@/nodes/sticky-note/constants'; +import { WEAVE_SHAPE_LABEL_DEFAULTS } from '@/nodes/shared/shape-label.constants'; +import { + getShapeDragBounds, + type ShapeDragModifiers, +} from '../shared/shape-geometry'; +import { bindShapeDragKeyboardHandlers } from '../shared/shape-keyboard'; + +export class WeaveStickyNoteToolAction extends WeaveAction { + protected initialized: boolean = false; + protected state!: WeaveStickyNoteToolActionState; + protected noteId!: string | null; + protected moved!: boolean; + protected tempPreviewNode!: Konva.Rect | null; + protected pointers!: Map; + protected clickPoint!: Konva.Vector2d | null; + protected container!: Konva.Layer | Konva.Node | undefined; + protected measureContainer: Konva.Layer | Konva.Group | undefined; + protected cancelAction!: () => void; + onPropsChange = undefined; + onInit = undefined; + + constructor() { + super(); + + this.initialize(); + } + + initialize(): void { + this.pointers = new Map(); + this.initialized = false; + this.state = STICKY_NOTE_TOOL_STATE.IDLE; + this.noteId = null; + this.tempPreviewNode = null; + this.moved = false; + this.container = undefined; + this.measureContainer = undefined; + this.clickPoint = null; + this.props = this.initProps(); + } + + getName(): string { + return STICKY_NOTE_TOOL_ACTION_NAME; + } + + initProps() { + return { + width: DEFAULT_STICKY_NOTE_WIDTH, + height: DEFAULT_STICKY_NOTE_HEIGHT, + color: DEFAULT_STICKY_NOTE_COLOR, + dogEarSize: DEFAULT_DOG_EAR_SIZE, + ...WEAVE_SHAPE_LABEL_DEFAULTS, + }; + } + + private setupEvents() { + const stage = this.instance.getStage(); + + bindShapeDragKeyboardHandlers( + this.instance.getEventsController().signal, + () => this.instance.getActiveAction() === STICKY_NOTE_TOOL_ACTION_NAME, + () => this.state === STICKY_NOTE_TOOL_STATE.DEFINING_SIZE, + () => this.state === STICKY_NOTE_TOOL_STATE.DEFINING_SIZE && this.moved, + () => this.cancelAction(), + (modifiers) => this.handleMovement(modifiers) + ); + + stage.on('pointerdown', (e) => { + this.setTapStart(e); + + this.pointers.set(e.evt.pointerId, { + x: e.evt.clientX, + y: e.evt.clientY, + }); + + if ( + this.pointers.size === 2 && + this.instance.getActiveAction() === STICKY_NOTE_TOOL_ACTION_NAME + ) { + this.state = STICKY_NOTE_TOOL_STATE.ADDING; + return; + } + + if (this.state === STICKY_NOTE_TOOL_STATE.ADDING) { + this.handleAdding(); + } + }); + + stage.on('pointermove', (e) => { + if (this.state === STICKY_NOTE_TOOL_STATE.IDLE) return; + + this.setCursor(); + + if (!this.isPressed(e)) return; + + if (!this.pointers.has(e.evt.pointerId)) return; + + if ( + this.pointers.size === 2 && + this.instance.getActiveAction() === STICKY_NOTE_TOOL_ACTION_NAME + ) { + this.state = STICKY_NOTE_TOOL_STATE.ADDING; + return; + } + + if (this.state === STICKY_NOTE_TOOL_STATE.DEFINING_SIZE) { + this.handleMovement(e.evt); + } + }); + + stage.on('pointerup', (e) => { + this.pointers.delete(e.evt.pointerId); + + const isTap = this.isTap(e); + + if (isTap) { + this.moved = false; + } + + if (this.state === STICKY_NOTE_TOOL_STATE.DEFINING_SIZE) { + this.handleSettingSize(e.evt); + } + }); + + this.initialized = true; + } + + private setState(state: WeaveStickyNoteToolActionState) { + this.state = state; + } + + private addStickyNote() { + this.setCursor(); + this.setFocusStage(); + + this.instance.emitEvent( + 'onAddingStickyNote' + ); + + this.clickPoint = null; + this.setState(STICKY_NOTE_TOOL_STATE.ADDING); + } + + private handleAdding() { + const { mousePoint, container, measureContainer } = + this.instance.getMousePointer(); + + this.clickPoint = mousePoint; + this.container = container; + this.measureContainer = measureContainer; + + this.noteId = uuidv4(); + + if (!this.tempPreviewNode) { + this.tempPreviewNode = new Konva.Rect({ + id: `${this.noteId}-preview`, + strokeScaleEnabled: true, + x: this.clickPoint?.x ?? 0, + y: this.clickPoint?.y ?? 0, + width: 0, + height: 0, + fill: this.props.color ?? DEFAULT_STICKY_NOTE_COLOR, + stroke: '#00000022', + strokeWidth: 1, + cornerRadius: 2, + opacity: 0.85, + }); + this.measureContainer?.add(this.tempPreviewNode); + } + + this.setState(STICKY_NOTE_TOOL_STATE.DEFINING_SIZE); + } + + private handleSettingSize(modifiers: ShapeDragModifiers = {}) { + if ( + this.noteId && + this.tempPreviewNode && + this.clickPoint && + this.container + ) { + const { mousePoint } = this.instance.getMousePointerRelativeToContainer( + this.container + ); + + const notePos: Konva.Vector2d = { + x: this.clickPoint.x, + y: this.clickPoint.y, + }; + const DRAG_THRESHOLD = 20; + let noteWidth = this.props.width; + let noteHeight = this.props.height; + if (this.moved) { + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); + if (bounds.width < DRAG_THRESHOLD && bounds.height < DRAG_THRESHOLD) { + notePos.x = this.clickPoint.x; + notePos.y = this.clickPoint.y; + noteWidth = this.props.width; + noteHeight = this.props.height; + } else { + notePos.x = bounds.x; + notePos.y = bounds.y; + noteWidth = Math.max(bounds.width, MIN_STICKY_NOTE_SIZE); + noteHeight = Math.max(bounds.height, MIN_STICKY_NOTE_SIZE); + } + } + + this.tempPreviewNode.setAttrs({ + x: notePos.x, + y: notePos.y, + width: noteWidth, + height: noteHeight, + }); + + const nodeHandler = this.instance.getNodeHandler( + WEAVE_STICKY_NOTE_NODE_TYPE + ); + + if (nodeHandler) { + const noteIdToWatch = this.noteId; + const tempPreviewToDestroy = this.tempPreviewNode; + this.tempPreviewNode = null; + + const node = nodeHandler.create(this.noteId, { + ...this.props, + x: notePos.x, + y: notePos.y, + width: noteWidth, + height: noteHeight, + }); + + const destroyOnRender = (addedNode: WeaveElementInstance) => { + if (addedNode.getAttrs().id === noteIdToWatch) { + tempPreviewToDestroy.destroy(); + this.instance.removeEventListener( + 'onNodeRenderedAdded', + destroyOnRender + ); + } + }; + this.instance.addEventListener('onNodeRenderedAdded', destroyOnRender); + + this.instance.addNode(node, this.container?.getAttrs().id); + } + + this.instance.emitEvent( + 'onAddedStickyNote' + ); + } + + this.cancelAction(); + } + + private handleMovement(modifiers: ShapeDragModifiers = {}) { + if (this.state !== STICKY_NOTE_TOOL_STATE.DEFINING_SIZE) { + return; + } + + if ( + this.noteId && + this.tempPreviewNode && + this.measureContainer && + this.clickPoint + ) { + this.moved = true; + + const { mousePoint } = this.instance.getMousePointerRelativeToContainer( + this.measureContainer + ); + + const DRAG_THRESHOLD = 20; + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); + + if (bounds.width < DRAG_THRESHOLD && bounds.height < DRAG_THRESHOLD) { + this.tempPreviewNode.setAttrs({ + x: this.clickPoint.x, + y: this.clickPoint.y, + width: this.props.width, + height: this.props.height, + }); + } else { + this.tempPreviewNode.setAttrs({ + x: bounds.x, + y: bounds.y, + width: Math.max(bounds.width, MIN_STICKY_NOTE_SIZE), + height: Math.max(bounds.height, MIN_STICKY_NOTE_SIZE), + }); + } + } + } + + trigger(cancelAction: () => void): void { + if (!this.instance) { + throw new Error('Instance not defined'); + } + + if (!this.initialized) { + this.setupEvents(); + } + const stage = this.instance.getStage(); + + stage.container().tabIndex = 1; + stage.container().focus(); + + this.cancelAction = cancelAction; + + const selectionPlugin = + this.instance.getPlugin('nodesSelection'); + if (selectionPlugin) { + selectionPlugin.setSelectedNodes([]); + } + + this.props = this.initProps(); + this.addStickyNote(); + } + + cleanup(): void { + const stage = this.instance.getStage(); + + stage.container().style.cursor = 'default'; + + const selectionPlugin = + this.instance.getPlugin('nodesSelection'); + if (selectionPlugin) { + const node = stage.findOne(`#${this.noteId}`); + if (node) { + selectionPlugin.setSelectedNodes([node]); + } + this.instance.triggerAction(SELECTION_TOOL_ACTION_NAME); + } + + if (this.tempPreviewNode) { + this.tempPreviewNode.destroy(); + } + + this.noteId = null; + this.tempPreviewNode = null; + this.moved = false; + this.container = undefined; + this.measureContainer = undefined; + this.clickPoint = null; + this.setState(STICKY_NOTE_TOOL_STATE.IDLE); + } + + private setCursor() { + const stage = this.instance.getStage(); + stage.container().style.cursor = 'crosshair'; + } + + private setFocusStage() { + const stage = this.instance.getStage(); + stage.container().tabIndex = 1; + stage.container().blur(); + stage.container().focus(); + } +} diff --git a/code/packages/sdk/src/actions/sticky-note-tool/types.ts b/code/packages/sdk/src/actions/sticky-note-tool/types.ts new file mode 100644 index 000000000..cc16e4c93 --- /dev/null +++ b/code/packages/sdk/src/actions/sticky-note-tool/types.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { STICKY_NOTE_TOOL_STATE } from './constants'; + +export type WeaveStickyNoteToolActionStateKeys = + keyof typeof STICKY_NOTE_TOOL_STATE; +export type WeaveStickyNoteToolActionState = + (typeof STICKY_NOTE_TOOL_STATE)[WeaveStickyNoteToolActionStateKeys]; + +export type WeaveStickyNoteToolActionOnAddingEvent = undefined; +export type WeaveStickyNoteToolActionOnAddedEvent = undefined; diff --git a/code/packages/sdk/src/index.common.ts b/code/packages/sdk/src/index.common.ts index 330ec4869..650bfffcc 100644 --- a/code/packages/sdk/src/index.common.ts +++ b/code/packages/sdk/src/index.common.ts @@ -98,6 +98,9 @@ export * from './nodes/measure/constants'; export { WeaveConnectorNode } from './nodes/connector/connector'; export * from './nodes/connector/constants'; export * from './nodes/connector/types'; +export { WeaveStickyNoteNode } from './nodes/sticky-note/sticky-note'; +export * from './nodes/sticky-note/constants'; +export * from './nodes/sticky-note/types'; // Shared shape label utilities export * from './nodes/shared/shape-label.types'; @@ -186,6 +189,9 @@ export * from './actions/connector-tool/types'; export { WeaveGuideToolAction } from './actions/guide-tool/guide-tool'; export * from './actions/guide-tool/constants'; export * from './actions/guide-tool/types'; +export { WeaveStickyNoteToolAction } from './actions/sticky-note-tool/sticky-note-tool'; +export * from './actions/sticky-note-tool/constants'; +export * from './actions/sticky-note-tool/types'; // Provided Plugins export { WeaveStageGridPlugin } from './plugins/stage-grid/stage-grid'; diff --git a/code/packages/sdk/src/nodes/shared/__tests__/shape-label.utils.test.ts b/code/packages/sdk/src/nodes/shared/__tests__/shape-label.utils.test.ts index 5c0cdbcac..298ed9c05 100644 --- a/code/packages/sdk/src/nodes/shared/__tests__/shape-label.utils.test.ts +++ b/code/packages/sdk/src/nodes/shared/__tests__/shape-label.utils.test.ts @@ -9,17 +9,18 @@ import Konva from 'konva'; import { computeRectangleLabelMinSize, computeEllipseLabelMinSize, + computeStickyNoteLabelMinSize, } from '../shape-label.utils'; -import { - labelId, - WEAVE_SHAPE_LABEL_DEFAULTS, -} from '../shape-label.constants'; +import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS } from '../shape-label.constants'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const mockStage = { scaleX: () => 1, scaleY: () => 1 } as unknown as Konva.Stage; +const mockStage = { + scaleX: () => 1, + scaleY: () => 1, +} as unknown as Konva.Stage; function makeLabelNode( groupId: string, @@ -155,7 +156,11 @@ describe('computeEllipseLabelMinSize', () => { }); it('returns { 0, 0 } when labelText attr is absent', () => { - const group = new Konva.Group({ id: 'e-no-text', radiusX: 100, radiusY: 60 }); + const group = new Konva.Group({ + id: 'e-no-text', + radiusX: 100, + radiusY: 60, + }); const result = computeEllipseLabelMinSize(mockStage, group); expect(result).toEqual({ width: 0, height: 0 }); }); @@ -192,3 +197,45 @@ describe('computeEllipseLabelMinSize', () => { expect(label!.height()).toBe(heightBefore); }); }); + +describe('computeStickyNoteLabelMinSize', () => { + function makeStickyGroup( + id: string, + w: number, + h: number, + text: string, + author?: string + ) { + const group = new Konva.Group({ + id, + width: w, + height: h, + labelText: text, + dogEarSize: 28, + author, + }); + group.add(makeLabelNode(id, text, w - 20, h - 20)); + return group; + } + + it('returns { 0, 0 } when labelText is empty', () => { + const group = makeStickyGroup('s-empty', 200, 200, ''); + const result = computeStickyNoteLabelMinSize(mockStage, group); + expect(result).toEqual({ width: 0, height: 0 }); + }); + + it('returns minWidth at least paddingX*2 + fontSize', () => { + const group = makeStickyGroup('s-1', 200, 200, 'Note'); + const { width } = computeStickyNoteLabelMinSize(mockStage, group); + expect(width).toBeGreaterThanOrEqual( + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX * 2 + + WEAVE_SHAPE_LABEL_DEFAULTS.labelFontSize + ); + }); + + it('includes dog-ear fold and author margin in minimum height calculation', () => { + const group = makeStickyGroup('s-2', 200, 200, 'Note text', 'jmcaamanog'); + const { height } = computeStickyNoteLabelMinSize(mockStage, group); + expect(height).toBeGreaterThanOrEqual(28 + 22); + }); +}); 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 a0b6d7f6e..93c813224 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label.utils.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label.utils.ts @@ -35,10 +35,9 @@ const labelBlocksSchema = z.array( * `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 { +export function applyLabelBlocksState< + T extends { props: Record } +>(state: T, nextProps: WeaveElementAttributes): T { if (nextProps.labelBlocks) state.props.labelBlocks = nextProps.labelBlocks; else delete state.props.labelBlocks; return state; @@ -53,20 +52,40 @@ export function resolveLabelBlocks( return blocksToText(blocks) === (props.labelText ?? '') ? blocks : undefined; } -export function spreadLabelProps(props: WeaveElementAttributes): Partial { +export function spreadLabelProps( + props: WeaveElementAttributes +): Partial { return { ...(props.labelText !== undefined && { labelText: props.labelText }), - ...(props.labelFontFamily !== undefined && { labelFontFamily: props.labelFontFamily }), - ...(props.labelFontSize !== undefined && { labelFontSize: props.labelFontSize }), - ...(props.labelFontStyle !== undefined && { labelFontStyle: props.labelFontStyle }), - ...(props.labelFontVariant !== undefined && { labelFontVariant: props.labelFontVariant }), + ...(props.labelFontFamily !== undefined && { + labelFontFamily: props.labelFontFamily, + }), + ...(props.labelFontSize !== undefined && { + labelFontSize: props.labelFontSize, + }), + ...(props.labelFontStyle !== undefined && { + labelFontStyle: props.labelFontStyle, + }), + ...(props.labelFontVariant !== undefined && { + labelFontVariant: props.labelFontVariant, + }), ...(props.labelFill !== undefined && { labelFill: props.labelFill }), ...(props.labelAlign !== undefined && { labelAlign: props.labelAlign }), - ...(props.labelVerticalAlign !== undefined && { labelVerticalAlign: props.labelVerticalAlign }), - ...(props.labelLetterSpacing !== undefined && { labelLetterSpacing: props.labelLetterSpacing }), - ...(props.labelLineHeight !== undefined && { labelLineHeight: props.labelLineHeight }), - ...(props.labelPaddingX !== undefined && { labelPaddingX: props.labelPaddingX }), - ...(props.labelPaddingY !== undefined && { labelPaddingY: props.labelPaddingY }), + ...(props.labelVerticalAlign !== undefined && { + labelVerticalAlign: props.labelVerticalAlign, + }), + ...(props.labelLetterSpacing !== undefined && { + labelLetterSpacing: props.labelLetterSpacing, + }), + ...(props.labelLineHeight !== undefined && { + labelLineHeight: props.labelLineHeight, + }), + ...(props.labelPaddingX !== undefined && { + labelPaddingX: props.labelPaddingX, + }), + ...(props.labelPaddingY !== undefined && { + labelPaddingY: props.labelPaddingY, + }), ...(props.labelBlocks !== undefined && { labelBlocks: props.labelBlocks }), }; } @@ -79,17 +98,30 @@ export function spreadLabelProps(props: WeaveElementAttributes): Partial(`#${labelId(group.id())}`); + const labelNode = group.findOne( + `#${labelId(group.id())}` + ); if (!labelNode) return null; const paddingX = - (attrs.labelPaddingX as number | undefined) ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; + (attrs.labelPaddingX as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; const paddingY = - (attrs.labelPaddingY as number | undefined) ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + (attrs.labelPaddingY as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; const fontSize = - (attrs.labelFontSize as number | undefined) ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelFontSize; + (attrs.labelFontSize as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelFontSize; const cloneLabel = labelNode.clone({ visible: false }); cloneLabel.height(undefined); @@ -226,7 +276,9 @@ export function computeEllipseLabelMinSize( // The label text area is inscribedH minus vertical padding. To fit naturalH: // radiusY * √2 - paddingY * 2 >= naturalH // radiusY >= (naturalH + paddingY * 2) / √2 - const minRadiusY = Math.ceil((naturalSize.height + paddingY * 2) / Math.SQRT2); + const minRadiusY = Math.ceil( + (naturalSize.height + paddingY * 2) / Math.SQRT2 + ); // Analogously for width: inscribedW - paddingX * 2 >= fontSize (one char minimum) const minRadiusX = Math.ceil((fontSize + paddingX * 2) / Math.SQRT2); @@ -268,3 +320,33 @@ export function computePolygonLabelMinSize( height: (naturalSize.height + paddingY * 2) * stage.scaleX(), }; } + +/** + * Returns the minimum bounding-box size `{ width, height }` (in Konva canvas + * units) that the sticky note node must have so its label text is fully visible, + * including padding, the dog-ear fold and author margin. + * + * Returns `{ width: 0, height: 0 }` when the label is empty. + * + * @param stage - The Konva stage. + * @param group - The sticky note `Konva.Group` returned by `onRender`. + */ +export function computeStickyNoteLabelMinSize( + stage: Konva.Stage, + group: Konva.Group +): { width: number; height: number } { + const ctx = extractLabelNodeContext(group); + if (!ctx) return { width: 0, height: 0 }; + + const { paddingX, paddingY, fontSize, naturalSize } = ctx; + const attrs = group.getAttrs() as WeaveElementAttributes; + const dogEarSize = (attrs.dogEarSize as number | undefined) ?? 28; + const authorMargin = attrs.author ? 22 : 0; + + return { + width: (paddingX * 2 + fontSize) * stage.scaleX(), + height: + (naturalSize.height + paddingY * 2 + dogEarSize + authorMargin) * + stage.scaleY(), + }; +} diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts new file mode 100644 index 000000000..200def20e --- /dev/null +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -0,0 +1,294 @@ +// 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, beforeEach } from 'vitest'; +import Konva from 'konva'; +import { WeaveStickyNoteNode } from '../sticky-note'; +import { + WEAVE_STICKY_NOTE_NODE_TYPE, + DEFAULT_STICKY_NOTE_WIDTH, + DEFAULT_STICKY_NOTE_HEIGHT, + DEFAULT_STICKY_NOTE_COLOR, + DEFAULT_DOG_EAR_SIZE, +} from '../constants'; +import { augmentKonvaNodeClass } from '../../node'; +import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import { createMockInstance } from '../../__tests__/shared/node.test-helpers'; +import { + labelId, + WEAVE_SHAPE_LABEL_DEFAULTS, +} from '@/nodes/shared/shape-label.constants'; +import { computeStickyNoteLabelMinSize } from '@/nodes/shared/shape-label.utils'; + +vi.mock('@/weave', () => ({ Weave: class MockWeave {} })); + +function makeNode(config?: { + transform?: object; + label?: { engine?: 'legacy' | 'native' }; +}): { + node: WeaveStickyNoteNode; + mock: ReturnType; +} { + const node = new WeaveStickyNoteNode({ config }); + const mock = createMockInstance(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (node as any).instance = mock; + return { node, mock }; +} + +function defaultProps( + overrides: Partial = {} +): WeaveElementAttributes { + return { + ...WEAVE_SHAPE_LABEL_DEFAULTS, + id: 'sticky-1', + nodeType: WEAVE_STICKY_NOTE_NODE_TYPE, + x: 50, + y: 50, + width: DEFAULT_STICKY_NOTE_WIDTH, + height: DEFAULT_STICKY_NOTE_HEIGHT, + color: DEFAULT_STICKY_NOTE_COLOR, + labelText: 'Hello Weave!', + author: 'jmcaamanog', + ...overrides, + }; +} + +describe('WeaveStickyNoteNode', () => { + beforeAll(() => { + augmentKonvaNodeClass(); + }); + + describe('Static State and Schema Methods', () => { + it('returns valid defaultState with correct node type, properties and shape label defaults', () => { + const state = WeaveStickyNoteNode.defaultState('sticky-test'); + expect(state.type).toBe(WEAVE_STICKY_NOTE_NODE_TYPE); + expect(state.props.nodeType).toBe(WEAVE_STICKY_NOTE_NODE_TYPE); + expect(state.props.width).toBe(DEFAULT_STICKY_NOTE_WIDTH); + expect(state.props.height).toBe(DEFAULT_STICKY_NOTE_HEIGHT); + expect(state.props.color).toBe(DEFAULT_STICKY_NOTE_COLOR); + expect(state.props.dogEarSize).toBe(DEFAULT_DOG_EAR_SIZE); + expect(state.props.labelText).toBe(WEAVE_SHAPE_LABEL_DEFAULTS.labelText); + expect(state.props.labelFontSize).toBe( + WEAVE_SHAPE_LABEL_DEFAULTS.labelFontSize + ); + }); + + it('adds node state preserving custom attributes and spread label props', () => { + const base = WeaveStickyNoteNode.defaultState('sticky-test'); + const added = WeaveStickyNoteNode.addNodeState(base, { + x: 100, + y: 200, + color: '#A7F3D0', + labelText: 'Custom pastel note', + labelFontSize: 18, + labelFill: '#334155', + author: 'designer', + }); + expect(added.props.x).toBe(100); + expect(added.props.y).toBe(200); + expect(added.props.color).toBe('#A7F3D0'); + expect(added.props.labelText).toBe('Custom pastel note'); + expect(added.props.labelFontSize).toBe(18); + expect(added.props.labelFill).toBe('#334155'); + expect(added.props.author).toBe('designer'); + }); + + it('updates node state with partial patches and rich text labelBlocks', () => { + const base = WeaveStickyNoteNode.defaultState('sticky-test'); + const blocks = [ + { + runs: [{ t: 'Rich note text', m: { b: true } }], + }, + ]; + const updated = WeaveStickyNoteNode.updateNodeState(base, { + labelText: 'Rich note text', + color: '#FECDD3', + labelBlocks: blocks, + }); + expect(updated.props.labelText).toBe('Rich note text'); + expect(updated.props.color).toBe('#FECDD3'); + expect(updated.props.labelBlocks).toEqual(blocks); + expect(updated.props.width).toBe(DEFAULT_STICKY_NOTE_WIDTH); + }); + + it('validates schema using zod', () => { + const schema = WeaveStickyNoteNode.getSchema(); + const valid = WeaveStickyNoteNode.defaultState('sticky-1'); + const parsed = schema.safeParse(valid); + expect(parsed.success).toBe(true); + }); + }); + + describe('Rendering and Updating', () => { + let node: WeaveStickyNoteNode; + + beforeEach(() => { + node = makeNode().node; + }); + + it('renders a Konva Group with main sheet, adhesive, fold flap, author and shape-label', () => { + const props = defaultProps(); + const group = node.onRender(props) as Konva.Group; + + expect(group).toBeInstanceOf(Konva.Group); + expect(group.id()).toBe('sticky-1'); + + const mainSheet = group.findOne('#sticky-1-main-sheet') as Konva.Line; + const adhesive = group.findOne('#sticky-1-adhesive-strip'); + const foldShadow = group.findOne('#sticky-1-fold-shadow'); + const foldFlap = group.findOne('#sticky-1-fold-flap') as Konva.Line; + const label = group.findOne(`#${labelId('sticky-1')}`) as Konva.Text; + const author = group.findOne('#sticky-1-author') as Konva.Text; + + expect(mainSheet).toBeDefined(); + expect(mainSheet.listening()).toBe(true); + expect(adhesive).toBeDefined(); + expect(foldShadow).toBeDefined(); + expect(foldFlap).toBeDefined(); + expect(foldFlap.listening()).toBe(true); + expect(label).toBeDefined(); + expect(label.text()).toBe('Hello Weave!'); + expect(author).toBeDefined(); + expect(author.text()).toBe('jmcaamanog'); + }); + + it('updates labelText, color and author dynamically on update', () => { + const props = defaultProps(); + const group = node.onRender(props) as Konva.Group; + + node.onUpdate(group, { + id: 'sticky-1', + labelText: 'Modified message', + color: '#BAE6FD', + author: 'bob', + }); + + const label = group.findOne(`#${labelId('sticky-1')}`) as Konva.Text; + const author = group.findOne('#sticky-1-author') as Konva.Text; + const mainSheet = group.findOne('#sticky-1-main-sheet') as Konva.Line; + + expect(label.text()).toBe('Modified message'); + expect(author.text()).toBe('bob'); + expect(mainSheet.fill()).toBe('#BAE6FD'); + }); + + it('configures transformer properties cleanly from config', () => { + const props = defaultProps(); + const group = node.onRender(props) as Konva.Group; + + const transformerProps = group.getTransformerProperties(); + expect(typeof transformerProps).toBe('object'); + }); + }); + + describe('Live Resize (Transform)', () => { + it('resets scale and triggers onUpdate during transform event with no leftover scale', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + + const onUpdateSpy = vi.spyOn(node, 'onUpdate'); + + group.fire('transformstart'); + group.scaleX(1.5); + group.scaleY(1.2); + + group.fire('transform'); + + expect(onUpdateSpy).toHaveBeenCalledWith(group, group.getAttrs()); + expect(group.scaleX()).toBe(1); + expect(group.scaleY()).toBe(1); + + group.fire('transformend'); + }); + }); + + describe('Inline Label Editing (dblClick & triggerEditMode)', () => { + it('does not trigger edit mode if not in selecting state or node is not selected', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editor = (node as any).shapeLabelEditor; + const triggerSpy = vi.spyOn(editor, 'triggerEditMode'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(node as any, 'isSelecting').mockReturnValue(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(node as any, 'isNodeSelected').mockReturnValue(true); + + group.dblClick(); + expect(triggerSpy).not.toHaveBeenCalled(); + }); + + it('triggers edit mode when selecting and node is selected, deriving fresh bounds', () => { + const { node } = makeNode(); + const group = node.onRender( + defaultProps({ width: 300, height: 250 }) + ) as Konva.Group; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editor = (node as any).shapeLabelEditor; + let capturedBounds: { width: number; height: number } | null = null; + vi.spyOn(editor, 'triggerEditMode').mockImplementation( + ( + _g: Konva.Group, + bounds: { x: number; y: number; width: number; height: number } + ) => { + capturedBounds = bounds; + } + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(node as any, 'isSelecting').mockReturnValue(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(node as any, 'isNodeSelected').mockReturnValue(true); + + group.dblClick(); + + expect(editor.triggerEditMode).toHaveBeenCalled(); + expect(capturedBounds).not.toBeNull(); + expect(capturedBounds!.width).toBe( + 300 - WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX * 2 + ); + }); + }); + + describe('Minimum Size (computeStickyNoteLabelMinSize)', () => { + it('returns 0 when label is empty and ctx is null', () => { + const mockStage = { scaleX: () => 1, scaleY: () => 1 } as Konva.Stage; + const group = new Konva.Group({ id: 'test' }); + const size = computeStickyNoteLabelMinSize(mockStage, group); + expect(size).toEqual({ width: 0, height: 0 }); + }); + + it('delegates getNodeMinSize to computeStickyNoteLabelMinSize', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + + expect(typeof group.getNodeMinSize).toBe('function'); + const minSize = group.getNodeMinSize(); + expect(minSize).toHaveProperty('width'); + expect(minSize).toHaveProperty('height'); + expect(minSize.width).toBeGreaterThan(0); + expect(minSize.height).toBeGreaterThan(0); + }); + }); + + describe('Engines support (legacy and native)', () => { + it('initializes with legacy engine by default', () => { + const { node } = makeNode({ label: { engine: 'legacy' } }); + const group = node.onRender(defaultProps()) as Konva.Group; + expect(group).toBeDefined(); + }); + + it('initializes with native engine when configured', () => { + const { node } = makeNode({ label: { engine: 'native' } }); + const group = node.onRender(defaultProps()) as Konva.Group; + expect(group).toBeDefined(); + }); + }); +}); diff --git a/code/packages/sdk/src/nodes/sticky-note/constants.ts b/code/packages/sdk/src/nodes/sticky-note/constants.ts new file mode 100644 index 000000000..c634b3eab --- /dev/null +++ b/code/packages/sdk/src/nodes/sticky-note/constants.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 + +export const WEAVE_STICKY_NOTE_NODE_TYPE = 'sticky-note'; + +export const DEFAULT_STICKY_NOTE_WIDTH = 200; +export const DEFAULT_STICKY_NOTE_HEIGHT = 200; +export const MIN_STICKY_NOTE_SIZE = 120; +export const DEFAULT_DOG_EAR_SIZE = 28; + +export const DEFAULT_STICKY_NOTE_COLOR = '#FEF08A'; // Pastel Yellow +export const DEFAULT_STICKY_NOTE_TEXT = ''; +export const DEFAULT_STICKY_NOTE_FONT_SIZE = 16; +export const DEFAULT_STICKY_NOTE_FONT_FAMILY = 'Inter, system-ui, sans-serif'; +export const DEFAULT_STICKY_NOTE_TEXT_COLOR = '#1F2937'; +export const DEFAULT_STICKY_NOTE_LINE_HEIGHT = 1.35; + +export const STICKY_NOTE_PASTEL_COLORS = [ + '#FEF08A', // Yellow + '#A7F3D0', // Mint + '#FECDD3', // Rose + '#DDD6FE', // Lavender + '#BAE6FD', // Sky + '#FDE68A', // Amber + '#FFEDD5', // Coral + '#F9FAFB', // White +] as const; diff --git a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts new file mode 100644 index 000000000..9e90aa8a1 --- /dev/null +++ b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts @@ -0,0 +1,560 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { z } from 'zod'; +import Konva from 'konva'; +import { + type WeaveElementAttributes, + type WeaveElementInstance, + type WeaveStateElement, +} from '@inditextech/weave-types'; +import { WeaveNode } from '../node'; +import { + WEAVE_STICKY_NOTE_NODE_TYPE, + DEFAULT_STICKY_NOTE_WIDTH, + DEFAULT_STICKY_NOTE_HEIGHT, + DEFAULT_STICKY_NOTE_COLOR, + DEFAULT_DOG_EAR_SIZE, +} from './constants'; +import type { + WeaveStickyNoteNodeParams, + WeaveStickyNoteProperties, +} 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, + WeaveShapeLabelTextBounds, +} from '@/nodes/shared/shape-label.types'; +import { + labelId, + WEAVE_SHAPE_LABEL_DEFAULTS, +} from '@/nodes/shared/shape-label.constants'; +import { + computeStickyNoteLabelMinSize, + spreadLabelProps, + getShapeLabelSchemaFields, + applyLabelBlocksState, +} from '@/nodes/shared/shape-label.utils'; +import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; + +export class WeaveStickyNoteNode extends WeaveNode { + private config: WeaveStickyNoteProperties; + protected nodeType: string = WEAVE_STICKY_NOTE_NODE_TYPE; + initialize = undefined; + private _shapeLabelEditor: WeaveShapeLabelEngine | undefined; + private _transforming = false; + + private get shapeLabelEditor(): WeaveShapeLabelEngine { + return (this._shapeLabelEditor ??= createShapeLabelEngine( + this.instance, + this.config.label + )); + } + + constructor(params?: WeaveStickyNoteNodeParams) { + super(); + + const { config } = params ?? {}; + this.config = { + transform: { + ...config?.transform, + }, + label: { ...config?.label }, + }; + } + + /** + * Generates a 5-point polygon outline for the main paper sheet, + * cutting off the bottom-right corner where the dog-ear fold sits. + */ + private getMainSheetPoints(w: number, h: number, fold: number): number[] { + return [ + 0, + 0, // Top-left + w, + 0, // Top-right + w, + Math.max(0, h - fold), // Right edge above fold + Math.max(0, w - fold), + h, // Bottom edge left of fold + 0, + h, // Bottom-left + ]; + } + + /** + * Generates a 3-point triangle polygon for the turned dog-ear fold flap. + */ + private getDogEarFlapPoints(w: number, h: number, fold: number): number[] { + return [ + Math.max(0, w - fold), + h, // Left of fold on bottom edge + w, + Math.max(0, h - fold), // Top of fold on right edge + Math.max(0, w - fold), + Math.max(0, h - fold), // Fold peak pointing inward + ]; + } + + /** + * Shared layout helper computing text bounds, fold geometry, and margins + * consistently for both onRender and onUpdate. + */ + private getLayout(props: WeaveElementAttributes) { + const width = Math.max(props.width ?? DEFAULT_STICKY_NOTE_WIDTH, 1); + const height = Math.max(props.height ?? DEFAULT_STICKY_NOTE_HEIGHT, 1); + const dogEarSize = props.dogEarSize ?? DEFAULT_DOG_EAR_SIZE; + const author = props.author; + const authorMargin = author ? 22 : 0; + const padX = + props.labelPaddingX ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; + const padY = + props.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + + const textBounds: WeaveShapeLabelTextBounds = { + x: padX, + y: padY, + width: Math.max(1, width - padX * 2), + height: Math.max(1, height - padY * 2 - dogEarSize - authorMargin), + }; + + const mainSheetPoints = this.getMainSheetPoints(width, height, dogEarSize); + const flapPoints = this.getDogEarFlapPoints(width, height, dogEarSize); + + return { + width, + height, + dogEarSize, + author, + authorMargin, + padX, + padY, + textBounds, + mainSheetPoints, + flapPoints, + }; + } + + /** + * Synchronizes internal canvas shapes to current dimensions and colors. + */ + private updateInternalShapes( + stickyGroup: Konva.Group, + props: WeaveElementAttributes + ) { + const { width, height, author, padX, mainSheetPoints, flapPoints } = + this.getLayout(props); + const noteColor = props.color ?? DEFAULT_STICKY_NOTE_COLOR; + + const mainSheet = stickyGroup.findOne( + `#${props.id}-main-sheet` + ); + if (mainSheet) { + mainSheet.setAttrs({ + points: mainSheetPoints, + fill: noteColor, + }); + mainSheet.moveToBottom(); + } + + const adhesiveStrip = stickyGroup.findOne( + `#${props.id}-adhesive-strip` + ); + if (adhesiveStrip) { + adhesiveStrip.setAttrs({ + width, + }); + } + + const foldShadow = stickyGroup.findOne( + `#${props.id}-fold-shadow` + ); + if (foldShadow) { + foldShadow.setAttrs({ + points: flapPoints, + }); + } + + const foldFlap = stickyGroup.findOne(`#${props.id}-fold-flap`); + if (foldFlap) { + foldFlap.setAttrs({ + points: flapPoints, + }); + } + + const authorText = stickyGroup.findOne(`#${props.id}-author`); + if (author) { + if (authorText) { + authorText.setAttrs({ + x: padX, + y: height - 20, + text: author, + }); + } else { + stickyGroup.add( + new Konva.Text({ + id: `${props.id}-author`, + groupId: props.id, + nodeId: props.id, + x: padX, + y: height - 20, + text: author, + fontSize: 11, + fontFamily: 'Inter, system-ui, sans-serif', + fill: 'rgba(31, 41, 55, 0.5)', + listening: false, + draggable: false, + }) + ); + } + } else if (authorText) { + authorText.destroy(); + } + } + + onRender(props: WeaveElementAttributes): WeaveElementInstance { + const id = props.id ?? 'sticky-note'; + const { + width, + height, + author, + padX, + textBounds, + mainSheetPoints, + flapPoints, + } = this.getLayout(props); + const noteColor = props.color ?? DEFAULT_STICKY_NOTE_COLOR; + + const stickyParams = { ...props }; + delete stickyParams.zIndex; + + const stickyGroup = new Konva.Group({ + ...stickyParams, + id: `${id}`, + width, + height, + name: 'node', + }); + + // 1. Main paper body with realistic physical drop shadow directly on shape + const mainSheet = new Konva.Line({ + id: `${id}-main-sheet`, + groupId: id, + nodeId: id, + points: mainSheetPoints, + fill: noteColor, + closed: true, + stroke: 'rgba(0, 0, 0, 0.08)', + strokeWidth: 1, + shadowColor: 'rgba(15, 23, 42, 0.16)', + shadowBlur: 10, + shadowOffset: { x: 2, y: 5 }, + shadowOpacity: 0.7, + listening: true, + draggable: false, + }); + stickyGroup.add(mainSheet); + + // 2. Faint adhesive top strip + const adhesiveStrip = new Konva.Rect({ + id: `${id}-adhesive-strip`, + groupId: id, + nodeId: id, + x: 0, + y: 0, + width, + height: 6, + fill: 'rgba(255, 255, 255, 0.35)', + listening: false, + draggable: false, + }); + stickyGroup.add(adhesiveStrip); + + // 3. Dog-ear fold shadow beneath flap + const foldShadow = new Konva.Line({ + id: `${id}-fold-shadow`, + groupId: id, + nodeId: id, + points: flapPoints, + fill: 'rgba(0, 0, 0, 0.08)', + closed: true, + listening: false, + draggable: false, + }); + stickyGroup.add(foldShadow); + + // 4. Dog-ear flap (listening: true so clicking the corner flap selects the note) + const foldFlap = new Konva.Line({ + id: `${id}-fold-flap`, + groupId: id, + nodeId: id, + points: flapPoints, + fill: 'rgba(0, 0, 0, 0.06)', + closed: true, + stroke: 'rgba(0, 0, 0, 0.10)', + strokeWidth: 1, + listening: true, + draggable: false, + }); + stickyGroup.add(foldFlap); + + // 5. Unified Shape Label Engine text + this.shapeLabelEditor.renderLabel(stickyGroup, props, textBounds); + + // 6. Optional author text stored as-is without forced formatting + if (author) { + const authorText = new Konva.Text({ + id: `${id}-author`, + groupId: id, + nodeId: id, + x: padX, + y: height - 20, + text: author, + fontSize: 11, + fontFamily: 'Inter, system-ui, sans-serif', + fill: 'rgba(31, 41, 55, 0.5)', + listening: false, + draggable: false, + }); + stickyGroup.add(authorText); + } + + foldFlap.moveToTop(); + mainSheet.moveToBottom(); + + this.setupDefaultNodeAugmentation(stickyGroup); + + const defaultTransformerProperties = this.defaultGetTransformerProperties( + this.config.transform + ); + + stickyGroup.getTransformerProperties = function () { + return defaultTransformerProperties; + }; + + this.setupDefaultNodeEvents(stickyGroup); + + // Live resize: scaleReset on every transform event and re-render without distortion + stickyGroup.on('transformstart', () => { + this._transforming = true; + }); + + stickyGroup.on('transform', () => { + this.scaleReset(stickyGroup); + this.onUpdate(stickyGroup, stickyGroup.getAttrs()); + }); + + stickyGroup.on('transformend', () => { + this._transforming = false; + }); + + // Double-click to enter inline label edit mode + stickyGroup.dblClick = () => { + if (this.shapeLabelEditor.isEditing()) { + return; + } + + if (!(this.isSelecting() && this.isNodeSelected(stickyGroup))) { + return; + } + + const onCommit = ( + labelText: string, + labelBlocks?: WeaveElementAttributes['labelBlocks'] + ) => + commitShapeLabel( + this.instance, + (g) => this.serialize(g), + `${props.id}`, + labelText, + labelBlocks + ); + + const currentAttrs = stickyGroup.getAttrs() as WeaveElementAttributes; + const { + textBounds: curBounds, + dogEarSize, + authorMargin, + } = this.getLayout(currentAttrs); + const originalHeight = + (currentAttrs.height as number) ?? DEFAULT_STICKY_NOTE_HEIGHT; + + this.shapeLabelEditor.triggerEditMode( + stickyGroup, + curBounds, + onCommit, + (neededShapeHeight) => { + // Never shrink below original; include fold and author margin + const finalHeight = Math.max( + neededShapeHeight + dogEarSize + authorMargin, + originalHeight + ); + stickyGroup.setAttrs({ height: finalHeight }); + this.updateInternalShapes(stickyGroup, { + ...currentAttrs, + height: finalHeight, + }); + stickyGroup.getLayer()?.batchDraw(); + } + ); + }; + + stickyGroup.getNodeMinSize = () => { + return computeStickyNoteLabelMinSize( + this.instance.getStage(), + stickyGroup + ); + }; + + return stickyGroup; + } + + onUpdate( + nodeInstance: WeaveElementInstance, + nextProps: WeaveElementAttributes + ): void { + nodeInstance.setAttrs({ + ...nextProps, + }); + + const stickyGroup = nodeInstance as Konva.Group; + this.updateInternalShapes(stickyGroup, nextProps); + + const { textBounds, dogEarSize, authorMargin } = this.getLayout(nextProps); + + this.shapeLabelEditor.updateLabel( + stickyGroup, + nextProps, + textBounds, + (neededShapeHeight) => { + const finalHeight = neededShapeHeight + dogEarSize + authorMargin; + nodeInstance.setAttrs({ height: finalHeight }); + this.updateInternalShapes(stickyGroup, { + ...nextProps, + height: finalHeight, + }); + + // Persist grown height to Yjs only when not actively transforming + if (!this._transforming) { + this.instance.updateNode(this.serialize(stickyGroup)); + } + } + ); + + // Keep label above sheet and fold on top + const labelNode = stickyGroup.findOne(`#${labelId(nextProps.id ?? '')}`); + if (labelNode) { + labelNode.moveToTop(); + const foldFlap = stickyGroup.findOne(`#${nextProps.id}-fold-flap`); + foldFlap?.moveToTop(); + } + + const nodesSelectionPlugin = + this.instance.getPlugin('nodesSelection'); + if (nodesSelectionPlugin) { + nodesSelectionPlugin.getTransformer().forceUpdate(); + } + } + + static defaultState(nodeId: string): WeaveStateElement { + return { + ...super.defaultState(nodeId), + type: WEAVE_STICKY_NOTE_NODE_TYPE, + props: { + ...super.defaultState(nodeId).props, + nodeType: WEAVE_STICKY_NOTE_NODE_TYPE, + x: 0, + y: 0, + width: DEFAULT_STICKY_NOTE_WIDTH, + height: DEFAULT_STICKY_NOTE_HEIGHT, + color: DEFAULT_STICKY_NOTE_COLOR, + dogEarSize: DEFAULT_DOG_EAR_SIZE, + author: '', + rotation: 0, + zIndex: 1, + children: [], + ...WEAVE_SHAPE_LABEL_DEFAULTS, + }, + }; + } + + static addNodeState( + defaultNodeState: WeaveStateElement, + props: WeaveElementAttributes + ): WeaveStateElement { + return mergeExceptArrays(defaultNodeState, { + props: { + x: props.x, + y: props.y, + width: props.width, + height: props.height, + rotation: props.rotation, + color: props.color, + dogEarSize: props.dogEarSize, + ...(props.author !== undefined && { author: props.author }), + ...spreadLabelProps(props), + }, + }); + } + + static updateNodeState( + prevNodeState: WeaveStateElement, + nextProps: WeaveElementAttributes + ): WeaveStateElement { + const next = mergeExceptArrays(prevNodeState, { + props: { + x: nextProps.x, + y: nextProps.y, + width: nextProps.width, + height: nextProps.height, + rotation: nextProps.rotation, + color: nextProps.color, + dogEarSize: nextProps.dogEarSize, + ...(nextProps.author !== undefined && { author: nextProps.author }), + ...spreadLabelProps(nextProps), + }, + }); + return applyLabelBlocksState(next, nextProps); + } + + static getSchema() { + const baseSchema = super.getSchema(); + + const nodeSchema = baseSchema.extend({ + type: z + .literal(WEAVE_STICKY_NOTE_NODE_TYPE) + .describe( + `Type of the node, for a sticky note node it will always be "${WEAVE_STICKY_NOTE_NODE_TYPE}"` + ), + props: baseSchema.shape.props.extend({ + nodeType: z + .literal(WEAVE_STICKY_NOTE_NODE_TYPE) + .describe( + `Type of the node, for a sticky note node it will always be "${WEAVE_STICKY_NOTE_NODE_TYPE}"` + ), + + width: z.number().describe('Width of the sticky note in pixels'), + height: z.number().describe('Height of the sticky note in pixels'), + color: z + .string() + .optional() + .describe('Background color of the sticky note in hex format'), + dogEarSize: z + .number() + .optional() + .describe('Size of the folded dog-ear corner in pixels'), + author: z + .string() + .optional() + .describe('Optional author name for the sticky note'), + + ...getShapeLabelSchemaFields(), + }), + }); + + return nodeSchema; + } +} diff --git a/code/packages/sdk/src/nodes/sticky-note/types.ts b/code/packages/sdk/src/nodes/sticky-note/types.ts new file mode 100644 index 000000000..b6a7e621a --- /dev/null +++ b/code/packages/sdk/src/nodes/sticky-note/types.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { WeaveShapeLabelConfig } from '@/nodes/shared/shape-label.types'; + +export interface StickyNoteAttributes extends WeaveElementAttributes { + id: string; + nodeType?: string; + x?: number; + y?: number; + width?: number; + height?: number; + color?: string; + dogEarSize?: number; + author?: string; + rotation?: number; + zIndex?: number; +} + +export type WeaveStickyNoteProperties = { + transform?: { + enabledAnchors?: string[]; + }; + label?: Partial; +}; + +export type WeaveStickyNoteNodeParams = { + config?: WeaveStickyNoteProperties; +}; From 04daf014fc2e86de584b0f7a4b76fbcf89ea9f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 10:42:24 +0100 Subject: [PATCH 02/10] feat(sdk): sticky note label defaults and shared layout constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default sticky note text to top-left aligned Inter 16px with 16px padding instead of the generic centered Arial 14px shape-label defaults, using the existing DEFAULT_STICKY_NOTE_* constants. Export STICKY_NOTE_LABEL_DEFAULTS, STICKY_NOTE_AUTHOR_MARGIN and DEFAULT_STICKY_NOTE_PADDING and reuse them in computeStickyNoteLabelMinSize so the minimum size matches the layout. Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- .../__tests__/sticky-note-tool.test.ts | 4 ++++ .../sticky-note-tool/sticky-note-tool.ts | 4 ++-- .../sdk/src/nodes/shared/shape-label.utils.ts | 9 ++++++-- .../sticky-note/__tests__/sticky-note.test.ts | 11 +++++++++- .../sdk/src/nodes/sticky-note/constants.ts | 21 +++++++++++++++++++ .../sdk/src/nodes/sticky-note/sticky-note.ts | 15 +++++++------ 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts b/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts index d4fecb020..61cd420b5 100644 --- a/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts +++ b/code/packages/sdk/src/actions/sticky-note-tool/__tests__/sticky-note-tool.test.ts @@ -201,6 +201,10 @@ describe('WeaveStickyNoteToolAction', () => { expect(props.width).toBe(DEFAULT_STICKY_NOTE_WIDTH); expect(props.height).toBe(DEFAULT_STICKY_NOTE_HEIGHT); expect(props.color).toBe(DEFAULT_STICKY_NOTE_COLOR); + expect(props.labelAlign).toBe('left'); + expect(props.labelVerticalAlign).toBe('top'); + expect(props.labelPaddingX).toBe(16); + expect(props.labelPaddingY).toBe(16); }); }); diff --git a/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts b/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts index b94cfa445..6d2faab11 100644 --- a/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts +++ b/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts @@ -25,8 +25,8 @@ import { MIN_STICKY_NOTE_SIZE, DEFAULT_STICKY_NOTE_COLOR, DEFAULT_DOG_EAR_SIZE, + STICKY_NOTE_LABEL_DEFAULTS, } from '@/nodes/sticky-note/constants'; -import { WEAVE_SHAPE_LABEL_DEFAULTS } from '@/nodes/shared/shape-label.constants'; import { getShapeDragBounds, type ShapeDragModifiers, @@ -76,7 +76,7 @@ export class WeaveStickyNoteToolAction extends WeaveAction { height: DEFAULT_STICKY_NOTE_HEIGHT, color: DEFAULT_STICKY_NOTE_COLOR, dogEarSize: DEFAULT_DOG_EAR_SIZE, - ...WEAVE_SHAPE_LABEL_DEFAULTS, + ...STICKY_NOTE_LABEL_DEFAULTS, }; } 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 93c813224..1d36e83c4 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label.utils.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label.utils.ts @@ -12,6 +12,10 @@ import type { 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'; +import { + DEFAULT_DOG_EAR_SIZE, + STICKY_NOTE_AUTHOR_MARGIN, +} from '@/nodes/sticky-note/constants'; /** * Returns a partial props object containing only the label-related fields that @@ -340,8 +344,9 @@ export function computeStickyNoteLabelMinSize( const { paddingX, paddingY, fontSize, naturalSize } = ctx; const attrs = group.getAttrs() as WeaveElementAttributes; - const dogEarSize = (attrs.dogEarSize as number | undefined) ?? 28; - const authorMargin = attrs.author ? 22 : 0; + const dogEarSize = + (attrs.dogEarSize as number | undefined) ?? DEFAULT_DOG_EAR_SIZE; + const authorMargin = attrs.author ? STICKY_NOTE_AUTHOR_MARGIN : 0; return { width: (paddingX * 2 + fontSize) * stage.scaleX(), diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts index 200def20e..32199251b 100644 --- a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -13,6 +13,8 @@ import { DEFAULT_STICKY_NOTE_HEIGHT, DEFAULT_STICKY_NOTE_COLOR, DEFAULT_DOG_EAR_SIZE, + DEFAULT_STICKY_NOTE_PADDING, + STICKY_NOTE_LABEL_DEFAULTS, } from '../constants'; import { augmentKonvaNodeClass } from '../../node'; import type { WeaveElementAttributes } from '@inditextech/weave-types'; @@ -73,8 +75,15 @@ describe('WeaveStickyNoteNode', () => { expect(state.props.dogEarSize).toBe(DEFAULT_DOG_EAR_SIZE); expect(state.props.labelText).toBe(WEAVE_SHAPE_LABEL_DEFAULTS.labelText); expect(state.props.labelFontSize).toBe( - WEAVE_SHAPE_LABEL_DEFAULTS.labelFontSize + STICKY_NOTE_LABEL_DEFAULTS.labelFontSize ); + expect(state.props.labelFontFamily).toBe( + STICKY_NOTE_LABEL_DEFAULTS.labelFontFamily + ); + expect(state.props.labelAlign).toBe('left'); + expect(state.props.labelVerticalAlign).toBe('top'); + expect(state.props.labelPaddingX).toBe(DEFAULT_STICKY_NOTE_PADDING); + expect(state.props.labelPaddingY).toBe(DEFAULT_STICKY_NOTE_PADDING); }); it('adds node state preserving custom attributes and spread label props', () => { diff --git a/code/packages/sdk/src/nodes/sticky-note/constants.ts b/code/packages/sdk/src/nodes/sticky-note/constants.ts index c634b3eab..9b095e6a0 100644 --- a/code/packages/sdk/src/nodes/sticky-note/constants.ts +++ b/code/packages/sdk/src/nodes/sticky-note/constants.ts @@ -2,12 +2,16 @@ // // SPDX-License-Identifier: Apache-2.0 +import { WEAVE_SHAPE_LABEL_DEFAULTS } from '../shared/shape-label.constants'; + export const WEAVE_STICKY_NOTE_NODE_TYPE = 'sticky-note'; export const DEFAULT_STICKY_NOTE_WIDTH = 200; export const DEFAULT_STICKY_NOTE_HEIGHT = 200; export const MIN_STICKY_NOTE_SIZE = 120; export const DEFAULT_DOG_EAR_SIZE = 28; +export const STICKY_NOTE_AUTHOR_MARGIN = 22; +export const DEFAULT_STICKY_NOTE_PADDING = 16; export const DEFAULT_STICKY_NOTE_COLOR = '#FEF08A'; // Pastel Yellow export const DEFAULT_STICKY_NOTE_TEXT = ''; @@ -26,3 +30,20 @@ export const STICKY_NOTE_PASTEL_COLORS = [ '#FFEDD5', // Coral '#F9FAFB', // White ] as const; + +/** + * Label defaults for sticky notes: top-left aligned text, Inter 16px with + * comfortable padding. Overrides the generic shape-label defaults + * (centered Arial 14px, 8px padding). + */ +export const STICKY_NOTE_LABEL_DEFAULTS = { + ...WEAVE_SHAPE_LABEL_DEFAULTS, + labelFontFamily: DEFAULT_STICKY_NOTE_FONT_FAMILY, + labelFontSize: DEFAULT_STICKY_NOTE_FONT_SIZE, + labelFill: DEFAULT_STICKY_NOTE_TEXT_COLOR, + labelLineHeight: DEFAULT_STICKY_NOTE_LINE_HEIGHT, + labelAlign: 'left', + labelVerticalAlign: 'top', + labelPaddingX: DEFAULT_STICKY_NOTE_PADDING, + labelPaddingY: DEFAULT_STICKY_NOTE_PADDING, +} as const; diff --git a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts index 9e90aa8a1..cbd0ebc35 100644 --- a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts +++ b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts @@ -16,6 +16,8 @@ import { DEFAULT_STICKY_NOTE_HEIGHT, DEFAULT_STICKY_NOTE_COLOR, DEFAULT_DOG_EAR_SIZE, + STICKY_NOTE_AUTHOR_MARGIN, + STICKY_NOTE_LABEL_DEFAULTS, } from './constants'; import type { WeaveStickyNoteNodeParams, @@ -28,10 +30,7 @@ import type { WeaveShapeLabelEngine, WeaveShapeLabelTextBounds, } from '@/nodes/shared/shape-label.types'; -import { - labelId, - WEAVE_SHAPE_LABEL_DEFAULTS, -} from '@/nodes/shared/shape-label.constants'; +import { labelId } from '@/nodes/shared/shape-label.constants'; import { computeStickyNoteLabelMinSize, spreadLabelProps, @@ -108,11 +107,11 @@ export class WeaveStickyNoteNode extends WeaveNode { const height = Math.max(props.height ?? DEFAULT_STICKY_NOTE_HEIGHT, 1); const dogEarSize = props.dogEarSize ?? DEFAULT_DOG_EAR_SIZE; const author = props.author; - const authorMargin = author ? 22 : 0; + const authorMargin = author ? STICKY_NOTE_AUTHOR_MARGIN : 0; const padX = - props.labelPaddingX ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; + props.labelPaddingX ?? STICKY_NOTE_LABEL_DEFAULTS.labelPaddingX; const padY = - props.labelPaddingY ?? WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + props.labelPaddingY ?? STICKY_NOTE_LABEL_DEFAULTS.labelPaddingY; const textBounds: WeaveShapeLabelTextBounds = { x: padX, @@ -476,7 +475,7 @@ export class WeaveStickyNoteNode extends WeaveNode { rotation: 0, zIndex: 1, children: [], - ...WEAVE_SHAPE_LABEL_DEFAULTS, + ...STICKY_NOTE_LABEL_DEFAULTS, }, }; } From 01f2116b552d130d3534622832eaa2ef94a2ace6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 11:08:52 +0100 Subject: [PATCH 03/10] fix(sdk): center sticky note text symmetrically with middle alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With labelVerticalAlign 'middle' the text box only excluded the dog-ear fold and author row at the bottom, so the text sat above the visual center of the sheet. Reserve the same space on top for middle alignment so the text is centered on the sheet while still never reaching the fold. Top and bottom alignment are unchanged. The grow callbacks and computeStickyNoteLabelMinSize use the same reserve. Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- .../sdk/src/nodes/shared/shape-label.utils.ts | 13 ++-- .../sticky-note/__tests__/sticky-note.test.ts | 62 +++++++++++++++++++ .../sdk/src/nodes/sticky-note/constants.ts | 16 +++++ .../sdk/src/nodes/sticky-note/sticky-note.ts | 25 +++++--- 4 files changed, 102 insertions(+), 14 deletions(-) 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 1d36e83c4..8202c34af 100644 --- a/code/packages/sdk/src/nodes/shared/shape-label.utils.ts +++ b/code/packages/sdk/src/nodes/shared/shape-label.utils.ts @@ -14,7 +14,8 @@ import type { WeaveTextBlock } from '@/nodes/text-v2/model'; import { labelId, WEAVE_SHAPE_LABEL_DEFAULTS } from './shape-label.constants'; import { DEFAULT_DOG_EAR_SIZE, - STICKY_NOTE_AUTHOR_MARGIN, + STICKY_NOTE_LABEL_DEFAULTS, + getStickyNoteVerticalReserve, } from '@/nodes/sticky-note/constants'; /** @@ -346,12 +347,16 @@ export function computeStickyNoteLabelMinSize( const attrs = group.getAttrs() as WeaveElementAttributes; const dogEarSize = (attrs.dogEarSize as number | undefined) ?? DEFAULT_DOG_EAR_SIZE; - const authorMargin = attrs.author ? STICKY_NOTE_AUTHOR_MARGIN : 0; + const reserve = getStickyNoteVerticalReserve( + dogEarSize, + !!attrs.author, + (attrs.labelVerticalAlign as string | undefined) ?? + STICKY_NOTE_LABEL_DEFAULTS.labelVerticalAlign + ); return { width: (paddingX * 2 + fontSize) * stage.scaleX(), height: - (naturalSize.height + paddingY * 2 + dogEarSize + authorMargin) * - stage.scaleY(), + (naturalSize.height + paddingY * 2 + reserve.total) * stage.scaleY(), }; } diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts index 32199251b..aad6382e3 100644 --- a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -15,6 +15,8 @@ import { DEFAULT_DOG_EAR_SIZE, DEFAULT_STICKY_NOTE_PADDING, STICKY_NOTE_LABEL_DEFAULTS, + STICKY_NOTE_AUTHOR_MARGIN, + getStickyNoteVerticalReserve, } from '../constants'; import { augmentKonvaNodeClass } from '../../node'; import type { WeaveElementAttributes } from '@inditextech/weave-types'; @@ -287,6 +289,66 @@ describe('WeaveStickyNoteNode', () => { }); }); + describe('Vertical text reserve (symmetric for middle alignment)', () => { + it('reserves fold and author space only at the bottom for top alignment', () => { + expect(getStickyNoteVerticalReserve(28, true, 'top')).toEqual({ + top: 0, + bottom: 28 + STICKY_NOTE_AUTHOR_MARGIN, + total: 28 + STICKY_NOTE_AUTHOR_MARGIN, + }); + expect(getStickyNoteVerticalReserve(28, false, 'bottom').top).toBe(0); + }); + + it('reserves the same space on top and bottom for middle alignment', () => { + const r = getStickyNoteVerticalReserve(28, true, 'middle'); + expect(r.top).toBe(r.bottom); + expect(r.total).toBe(r.top * 2); + }); + + function editBounds(overrides: Partial) { + const { node } = makeNode(); + const group = node.onRender( + defaultProps({ width: 300, height: 300, ...overrides }) + ) as Konva.Group; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editor = (node as any).shapeLabelEditor; + let bounds: { y: number; height: number } | null = null; + vi.spyOn(editor, 'triggerEditMode').mockImplementation( + (_g: Konva.Group, b: { y: number; height: number }) => { + bounds = b; + } + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(node as any, 'isSelecting').mockReturnValue(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(node as any, 'isNodeSelected').mockReturnValue(true); + group.dblClick(); + return bounds as unknown as { y: number; height: number }; + } + + it('centers the text box on the sheet when vertical align is middle', () => { + const padY = 16; + const b = editBounds({ + labelVerticalAlign: 'middle', + labelPaddingY: padY, + author: 'jmcaamanog', + }); + const reserve = 28 + STICKY_NOTE_AUTHOR_MARGIN; + expect(b.y).toBe(padY + reserve); + expect(b.y + b.height / 2).toBe(300 / 2); + }); + + it('keeps the text box at the top for top alignment', () => { + const padY = 16; + const b = editBounds({ + labelVerticalAlign: 'top', + labelPaddingY: padY, + author: 'jmcaamanog', + }); + expect(b.y).toBe(padY); + }); + }); + describe('Engines support (legacy and native)', () => { it('initializes with legacy engine by default', () => { const { node } = makeNode({ label: { engine: 'legacy' } }); diff --git a/code/packages/sdk/src/nodes/sticky-note/constants.ts b/code/packages/sdk/src/nodes/sticky-note/constants.ts index 9b095e6a0..8183fa1b3 100644 --- a/code/packages/sdk/src/nodes/sticky-note/constants.ts +++ b/code/packages/sdk/src/nodes/sticky-note/constants.ts @@ -47,3 +47,19 @@ export const STICKY_NOTE_LABEL_DEFAULTS = { labelPaddingX: DEFAULT_STICKY_NOTE_PADDING, labelPaddingY: DEFAULT_STICKY_NOTE_PADDING, } as const; + +/** + * Vertical space reserved inside a sticky note for the dog-ear fold and the + * author row. With `middle` vertical alignment the same amount is reserved on + * top so the text is visually centered on the sheet; otherwise it is only + * reserved at the bottom. + */ +export function getStickyNoteVerticalReserve( + dogEarSize: number, + hasAuthor: boolean, + verticalAlign: string +): { top: number; bottom: number; total: number } { + const bottom = dogEarSize + (hasAuthor ? STICKY_NOTE_AUTHOR_MARGIN : 0); + const top = verticalAlign === 'middle' ? bottom : 0; + return { top, bottom, total: top + bottom }; +} diff --git a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts index cbd0ebc35..7ed30d691 100644 --- a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts +++ b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts @@ -18,6 +18,7 @@ import { DEFAULT_DOG_EAR_SIZE, STICKY_NOTE_AUTHOR_MARGIN, STICKY_NOTE_LABEL_DEFAULTS, + getStickyNoteVerticalReserve, } from './constants'; import type { WeaveStickyNoteNodeParams, @@ -113,11 +114,17 @@ export class WeaveStickyNoteNode extends WeaveNode { const padY = props.labelPaddingY ?? STICKY_NOTE_LABEL_DEFAULTS.labelPaddingY; + const reserve = getStickyNoteVerticalReserve( + dogEarSize, + !!author, + props.labelVerticalAlign ?? STICKY_NOTE_LABEL_DEFAULTS.labelVerticalAlign + ); + const textBounds: WeaveShapeLabelTextBounds = { x: padX, - y: padY, + y: padY + reserve.top, width: Math.max(1, width - padX * 2), - height: Math.max(1, height - padY * 2 - dogEarSize - authorMargin), + height: Math.max(1, height - padY * 2 - reserve.total), }; const mainSheetPoints = this.getMainSheetPoints(width, height, dogEarSize); @@ -129,6 +136,7 @@ export class WeaveStickyNoteNode extends WeaveNode { dogEarSize, author, authorMargin, + verticalReserve: reserve.total, padX, padY, textBounds, @@ -373,11 +381,8 @@ export class WeaveStickyNoteNode extends WeaveNode { ); const currentAttrs = stickyGroup.getAttrs() as WeaveElementAttributes; - const { - textBounds: curBounds, - dogEarSize, - authorMargin, - } = this.getLayout(currentAttrs); + const { textBounds: curBounds, verticalReserve } = + this.getLayout(currentAttrs); const originalHeight = (currentAttrs.height as number) ?? DEFAULT_STICKY_NOTE_HEIGHT; @@ -388,7 +393,7 @@ export class WeaveStickyNoteNode extends WeaveNode { (neededShapeHeight) => { // Never shrink below original; include fold and author margin const finalHeight = Math.max( - neededShapeHeight + dogEarSize + authorMargin, + neededShapeHeight + verticalReserve, originalHeight ); stickyGroup.setAttrs({ height: finalHeight }); @@ -422,14 +427,14 @@ export class WeaveStickyNoteNode extends WeaveNode { const stickyGroup = nodeInstance as Konva.Group; this.updateInternalShapes(stickyGroup, nextProps); - const { textBounds, dogEarSize, authorMargin } = this.getLayout(nextProps); + const { textBounds, verticalReserve } = this.getLayout(nextProps); this.shapeLabelEditor.updateLabel( stickyGroup, nextProps, textBounds, (neededShapeHeight) => { - const finalHeight = neededShapeHeight + dogEarSize + authorMargin; + const finalHeight = neededShapeHeight + verticalReserve; nodeInstance.setAttrs({ height: finalHeight }); this.updateInternalShapes(stickyGroup, { ...nextProps, From 6c9792b5da3ecf80c96d3a4e28a3c39821ff4361 Mon Sep 17 00:00:00 2001 From: jmcaamanog Date: Thu, 12 Nov 2026 11:05:35 +0100 Subject: [PATCH 04/10] feat(sdk): share sticky note tool bounds, tighten types, and add docs (#1232) - Extract resolveNoteBounds helper to deduplicate handleMovement/handleSettingSize - Remove unused DEFAULT_STICKY_NOTE_TEXT constant from sticky-note/constants.ts - Type WeaveStickyNoteProperties.transform with WeaveNodeTransformerProperties - Test custom config.transform propagation through getTransformerProperties() - Add main & SDK API docs pages and nav entries for sticky-note node & tool Signed-off-by: jmcaamanog --- .../sticky-note-tool/sticky-note-tool.ts | 108 +++++++------- .../sticky-note/__tests__/sticky-note.test.ts | 20 ++- .../sdk/src/nodes/sticky-note/constants.ts | 1 - .../sdk/src/nodes/sticky-note/types.ts | 12 +- docs/src/modules/main/nav.adoc | 2 + .../main/pages/build/actions/index.adoc | 4 + .../pages/build/actions/sticky-note-tool.adoc | 77 ++++++++++ .../modules/main/pages/build/nodes/index.adoc | 4 + .../main/pages/build/nodes/sticky-note.adoc | 45 ++++++ docs/src/modules/sdk/nav.adoc | 2 + .../pages/api-reference/actions/index.adoc | 4 + .../actions/sticky-note-tool.adoc | 41 ++++++ .../sdk/pages/api-reference/nodes/index.adoc | 4 + .../api-reference/nodes/sticky-note.adoc | 138 ++++++++++++++++++ 14 files changed, 397 insertions(+), 65 deletions(-) create mode 100644 docs/src/modules/main/pages/build/actions/sticky-note-tool.adoc create mode 100644 docs/src/modules/main/pages/build/nodes/sticky-note.adoc create mode 100644 docs/src/modules/sdk/pages/api-reference/actions/sticky-note-tool.adoc create mode 100644 docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc diff --git a/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts b/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts index 6d2faab11..235f320a8 100644 --- a/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts +++ b/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts @@ -33,6 +33,8 @@ import { } from '../shared/shape-geometry'; import { bindShapeDragKeyboardHandlers } from '../shared/shape-keyboard'; +const STICKY_NOTE_DRAG_THRESHOLD = 20; + export class WeaveStickyNoteToolAction extends WeaveAction { protected initialized: boolean = false; protected state!: WeaveStickyNoteToolActionState; @@ -198,6 +200,44 @@ export class WeaveStickyNoteToolAction extends WeaveAction { this.setState(STICKY_NOTE_TOOL_STATE.DEFINING_SIZE); } + private resolveNoteBounds( + mousePoint: Konva.Vector2d, + modifiers: ShapeDragModifiers = {} + ): { x: number; y: number; width: number; height: number } { + if (!this.clickPoint) { + return { + x: 0, + y: 0, + width: this.props.width, + height: this.props.height, + }; + } + + const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { + centered: modifiers.altKey, + constrained: modifiers.shiftKey, + }); + + if ( + bounds.width < STICKY_NOTE_DRAG_THRESHOLD && + bounds.height < STICKY_NOTE_DRAG_THRESHOLD + ) { + return { + x: this.clickPoint.x, + y: this.clickPoint.y, + width: this.props.width, + height: this.props.height, + }; + } + + return { + x: bounds.x, + y: bounds.y, + width: Math.max(bounds.width, MIN_STICKY_NOTE_SIZE), + height: Math.max(bounds.height, MIN_STICKY_NOTE_SIZE), + }; + } + private handleSettingSize(modifiers: ShapeDragModifiers = {}) { if ( this.noteId && @@ -209,37 +249,16 @@ export class WeaveStickyNoteToolAction extends WeaveAction { this.container ); - const notePos: Konva.Vector2d = { - x: this.clickPoint.x, - y: this.clickPoint.y, - }; - const DRAG_THRESHOLD = 20; - let noteWidth = this.props.width; - let noteHeight = this.props.height; - if (this.moved) { - const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { - centered: modifiers.altKey, - constrained: modifiers.shiftKey, - }); - if (bounds.width < DRAG_THRESHOLD && bounds.height < DRAG_THRESHOLD) { - notePos.x = this.clickPoint.x; - notePos.y = this.clickPoint.y; - noteWidth = this.props.width; - noteHeight = this.props.height; - } else { - notePos.x = bounds.x; - notePos.y = bounds.y; - noteWidth = Math.max(bounds.width, MIN_STICKY_NOTE_SIZE); - noteHeight = Math.max(bounds.height, MIN_STICKY_NOTE_SIZE); - } - } + const noteBounds = this.moved + ? this.resolveNoteBounds(mousePoint, modifiers) + : { + x: this.clickPoint.x, + y: this.clickPoint.y, + width: this.props.width, + height: this.props.height, + }; - this.tempPreviewNode.setAttrs({ - x: notePos.x, - y: notePos.y, - width: noteWidth, - height: noteHeight, - }); + this.tempPreviewNode.setAttrs(noteBounds); const nodeHandler = this.instance.getNodeHandler( WEAVE_STICKY_NOTE_NODE_TYPE @@ -252,10 +271,7 @@ export class WeaveStickyNoteToolAction extends WeaveAction { const node = nodeHandler.create(this.noteId, { ...this.props, - x: notePos.x, - y: notePos.y, - width: noteWidth, - height: noteHeight, + ...noteBounds, }); const destroyOnRender = (addedNode: WeaveElementInstance) => { @@ -297,27 +313,9 @@ export class WeaveStickyNoteToolAction extends WeaveAction { this.measureContainer ); - const DRAG_THRESHOLD = 20; - const bounds = getShapeDragBounds(this.clickPoint, mousePoint, { - centered: modifiers.altKey, - constrained: modifiers.shiftKey, - }); - - if (bounds.width < DRAG_THRESHOLD && bounds.height < DRAG_THRESHOLD) { - this.tempPreviewNode.setAttrs({ - x: this.clickPoint.x, - y: this.clickPoint.y, - width: this.props.width, - height: this.props.height, - }); - } else { - this.tempPreviewNode.setAttrs({ - x: bounds.x, - y: bounds.y, - width: Math.max(bounds.width, MIN_STICKY_NOTE_SIZE), - height: Math.max(bounds.height, MIN_STICKY_NOTE_SIZE), - }); - } + this.tempPreviewNode.setAttrs( + this.resolveNoteBounds(mousePoint, modifiers) + ); } } diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts index aad6382e3..490afc55a 100644 --- a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -19,7 +19,10 @@ import { getStickyNoteVerticalReserve, } from '../constants'; import { augmentKonvaNodeClass } from '../../node'; -import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { + WeaveElementAttributes, + WeaveNodeTransformerProperties, +} from '@inditextech/weave-types'; import { createMockInstance } from '../../__tests__/shared/node.test-helpers'; import { labelId, @@ -30,7 +33,7 @@ import { computeStickyNoteLabelMinSize } from '@/nodes/shared/shape-label.utils' vi.mock('@/weave', () => ({ Weave: class MockWeave {} })); function makeNode(config?: { - transform?: object; + transform?: Partial; label?: { engine?: 'legacy' | 'native' }; }): { node: WeaveStickyNoteNode; @@ -188,11 +191,20 @@ describe('WeaveStickyNoteNode', () => { }); it('configures transformer properties cleanly from config', () => { + const { node: configured } = makeNode({ + transform: { + rotateEnabled: false, + enabledAnchors: ['top-left', 'bottom-right'], + }, + }); const props = defaultProps(); - const group = node.onRender(props) as Konva.Group; + const group = configured.onRender(props) as Konva.Group; const transformerProps = group.getTransformerProperties(); - expect(typeof transformerProps).toBe('object'); + expect(transformerProps).toMatchObject({ + rotateEnabled: false, + enabledAnchors: ['top-left', 'bottom-right'], + }); }); }); diff --git a/code/packages/sdk/src/nodes/sticky-note/constants.ts b/code/packages/sdk/src/nodes/sticky-note/constants.ts index 8183fa1b3..b7d172232 100644 --- a/code/packages/sdk/src/nodes/sticky-note/constants.ts +++ b/code/packages/sdk/src/nodes/sticky-note/constants.ts @@ -14,7 +14,6 @@ export const STICKY_NOTE_AUTHOR_MARGIN = 22; export const DEFAULT_STICKY_NOTE_PADDING = 16; export const DEFAULT_STICKY_NOTE_COLOR = '#FEF08A'; // Pastel Yellow -export const DEFAULT_STICKY_NOTE_TEXT = ''; export const DEFAULT_STICKY_NOTE_FONT_SIZE = 16; export const DEFAULT_STICKY_NOTE_FONT_FAMILY = 'Inter, system-ui, sans-serif'; export const DEFAULT_STICKY_NOTE_TEXT_COLOR = '#1F2937'; diff --git a/code/packages/sdk/src/nodes/sticky-note/types.ts b/code/packages/sdk/src/nodes/sticky-note/types.ts index b6a7e621a..46a32f0d8 100644 --- a/code/packages/sdk/src/nodes/sticky-note/types.ts +++ b/code/packages/sdk/src/nodes/sticky-note/types.ts @@ -2,7 +2,10 @@ // // SPDX-License-Identifier: Apache-2.0 -import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { + WeaveElementAttributes, + WeaveNodeTransformerProperties, +} from '@inditextech/weave-types'; import type { WeaveShapeLabelConfig } from '@/nodes/shared/shape-label.types'; export interface StickyNoteAttributes extends WeaveElementAttributes { @@ -20,12 +23,11 @@ export interface StickyNoteAttributes extends WeaveElementAttributes { } export type WeaveStickyNoteProperties = { - transform?: { - enabledAnchors?: string[]; - }; + transform: Partial; + /** Label engine; defaults to `legacy`. */ label?: Partial; }; export type WeaveStickyNoteNodeParams = { - config?: WeaveStickyNoteProperties; + config?: Partial; }; diff --git a/docs/src/modules/main/nav.adoc b/docs/src/modules/main/nav.adoc index 4256dd7e4..1424a8761 100644 --- a/docs/src/modules/main/nav.adoc +++ b/docs/src/modules/main/nav.adoc @@ -26,6 +26,7 @@ ** xref:main:build/nodes/rectangle.adoc[Rectangle] ** xref:main:build/nodes/stage.adoc[Stage] ** xref:main:build/nodes/star.adoc[Star] +** xref:main:build/nodes/sticky-note.adoc[Sticky Note] ** xref:main:build/nodes/stroke.adoc[Stroke] ** xref:main:build/nodes/stroke-single.adoc[Stroke Single] ** xref:main:build/nodes/text.adoc[Text] @@ -71,6 +72,7 @@ ** xref:main:build/actions/rectangle-tool.adoc[Rectangle Tool] ** xref:main:build/actions/selection-tool.adoc[Selection Tool] ** xref:main:build/actions/star-tool.adoc[Star Tool] +** xref:main:build/actions/sticky-note-tool.adoc[Sticky Note Tool] ** xref:main:build/actions/stroke-tool.adoc[Stroke Tool] ** xref:main:build/actions/text-tool.adoc[Text Tool] ** xref:main:build/actions/video-tool.adoc[Video Tool] diff --git a/docs/src/modules/main/pages/build/actions/index.adoc b/docs/src/modules/main/pages/build/actions/index.adoc index c51211d08..6b244413d 100644 --- a/docs/src/modules/main/pages/build/actions/index.adoc +++ b/docs/src/modules/main/pages/build/actions/index.adoc @@ -116,6 +116,10 @@ Enables users to place a polygon node on the canvas using a preset shape (triang .xref:main:build/actions/star-tool.adoc[Star Tool] Enables users to add new star nodes to the canvas by clicking or touching on it. +[card,subheader="Nodes",labels="new"] +.xref:main:build/actions/sticky-note-tool.adoc[Sticky Note Tool] +Enables users to create new sticky note nodes on the canvas by clicking or dragging. + [card,subheader="Nodes"] .xref:main:build/actions/stroke-tool.adoc[Stroke Tool] Enables users to create a single-stroke line on the canvas. With the possibility to define the stoke tips format. diff --git a/docs/src/modules/main/pages/build/actions/sticky-note-tool.adoc b/docs/src/modules/main/pages/build/actions/sticky-note-tool.adoc new file mode 100644 index 000000000..e49b1d139 --- /dev/null +++ b/docs/src/modules/main/pages/build/actions/sticky-note-tool.adoc @@ -0,0 +1,77 @@ += Sticky Note Tool +:description: Add a sticky note to the canvas + +== Introduction + +This action enables users to create new sticky note nodes on the canvas by clicking, touching, or dragging. + +== Dependencies + +This action needs registered on the Weave instance the following element: + +* xref:main:build/nodes/sticky-note.adoc[Sticky Note] node + +== Usage + +=== Import the Action +Start by importing the action: + +[source,ts] +---- +import { WeaveStickyNoteToolAction } from "@inditextech/weave-sdk"; +---- + +=== Register the Action +Then register the action on the xref:sdk:api-reference/weave/index.adoc[Weave] class instance. + +[source,ts] +---- +const instance = new Weave({ + ... + actions: [ + ..., + new WeaveStickyNoteToolAction(), // <.> + ] +}) +---- +<.> Added in this step. + +=== Setup the action trigger +Setup on a button or any element on the UI the user can interact with on the action event: + +[source,ts] +---- +instance.triggerAction("stickyNoteTool"); +---- + +For example on a button on React: + +[source,tsx] +---- +import React from "react"; +import { useWeave } from "@inditextech/weave-react"; + +const MyStickyNoteToolTriggerComponent = () => { + const instance = useWeave((state) => state.instance); + + const triggerTool = React.useCallback(() => { + instance.triggerAction("stickyNoteTool"); + }, [instance]); + + return ; +}; +---- + +=== Trigger the action +Finally a user triggers the UI element that launches the action. + +When active the user can: + +* *Click* or *Touch* on the canvas (or drag less than 20 px) to place a sticky note at its default size (`200x200` px). +* *Click & drag* to define a custom sticky note size, clamped to a minimum of `120x120` px (`MIN_STICKY_NOTE_SIZE`). +* Hold *Shift* while dragging to constrain the note to a square (`1:1`). +* Hold *Alt/Option* while dragging to draw outward from the initial click as the center. +* Hold *Shift + Alt/Option* to draw a centered square sticky note. +* Press *Escape* while dragging to cancel placement before releasing the pointer. + +Once placed, the sticky note is added to the canvas and automatically selected so users can move, resize, or double-click to edit its text right away. diff --git a/docs/src/modules/main/pages/build/nodes/index.adoc b/docs/src/modules/main/pages/build/nodes/index.adoc index b05f6107f..0f98c3344 100644 --- a/docs/src/modules/main/pages/build/nodes/index.adoc +++ b/docs/src/modules/main/pages/build/nodes/index.adoc @@ -61,6 +61,10 @@ The Rectangle node is a simple, versatile primitive shape used to render rectang .xref:main:build/nodes/polygon.adoc[Polygon] The Polygon node is a versatile primitive shape used to render polygon elements on the canvas using a preset shape. It supports customizable properties such as position, fill color, stroke, and an optional inline text label. +[card,subheader="Drawing",labels="new"] +.xref:main:build/nodes/sticky-note.adoc[Sticky Note] +The Sticky Note node renders a paper-like note with a dog-ear folded corner, curated pastel colors, optional author attribution, and an inline editable label. + [card,subheader="Drawing"] .xref:main:build/nodes/stroke.adoc[Stroke] The Stroke node represents a customizable free line on the canvas. It supports features like stroke color, width and dash patterns. diff --git a/docs/src/modules/main/pages/build/nodes/sticky-note.adoc b/docs/src/modules/main/pages/build/nodes/sticky-note.adoc new file mode 100644 index 000000000..c7981ebc3 --- /dev/null +++ b/docs/src/modules/main/pages/build/nodes/sticky-note.adoc @@ -0,0 +1,45 @@ += Sticky Note +:description: Add interactive sticky notes to the canvas + +The Sticky Note node renders a paper-like note on the canvas with a drop shadow, top adhesive strip, bottom-right dog-ear fold flap, an optional author attribution row, and an inline editable label. + +Built with Konva shapes and wired into Weave.js's shared shape-label engine, sticky notes support dragging, live resizing without distorting the fold corner or tape strip, rotation, selection via both the sheet and the fold flap, and double-click text editing (using either the `legacy` DOM overlay or the `native` on-canvas rich-text editor). + +== Features + +* **Curated pastel palette**: Exports `STICKY_NOTE_PASTEL_COLORS` with 8 colors (Yellow `#FEF08A`, Mint `#A7F3D0`, Rose `#FECDD3`, Lavender `#DDD6FE`, Sky `#BAE6FD`, Amber `#FDE68A`, Coral `#FFEDD5`, White `#F9FAFB`). +* **3D paper geometry**: Main sheet with single ambient drop shadow, top adhesive tape accent, and bottom-right dog-ear folded flap. +* **Shape-label engine**: Supports `label*` styling props, rich-text `labelBlocks`, auto-growing height when text exceeds bounds, and minimum size enforcement via `getNodeMinSize`. +* **Optional author attribution**: Displays an optional `author` footer preserved as provided without forced casing or prefixes. + +== Usage + +=== Import the Node +Start by importing the node: + +[source,ts] +---- +import { WeaveStickyNoteNode } from "@inditextech/weave-sdk"; +---- + +=== Register the Node +Then register the node on the xref:sdk:api-reference/weave/index.adoc[Weave] class instance. + +[source,ts] +---- +const instance = new Weave({ + ... + nodes: [ + ..., + new WeaveStickyNoteNode(), // <.> + ] +}) +---- +<.> Added in this step. + +=== Use the node +Once the node is registered you can use it on xref:main:build/actions/index.adoc[Actions] or +xref:main:build/plugins/index.adoc[Plugins] or even other xref:main:build/nodes/index.adoc[Nodes]. + +We provide an action named xref:main:build/actions/sticky-note-tool.adoc[Sticky Note Tool] that +allows users to add Sticky Note nodes to the canvas. diff --git a/docs/src/modules/sdk/nav.adoc b/docs/src/modules/sdk/nav.adoc index aa4281d50..9b974cdfb 100644 --- a/docs/src/modules/sdk/nav.adoc +++ b/docs/src/modules/sdk/nav.adoc @@ -18,6 +18,7 @@ ** xref:sdk:api-reference/nodes/rectangle.adoc[WeaveRectangleNode] ** xref:sdk:api-reference/nodes/stage.adoc[WeaveStageNode] ** xref:sdk:api-reference/nodes/star.adoc[WeaveStarNode] +** xref:sdk:api-reference/nodes/sticky-note.adoc[WeaveStickyNoteNode] ** xref:sdk:api-reference/nodes/stroke.adoc[WeaveStrokeNode] ** xref:sdk:api-reference/nodes/stroke-single.adoc[WeaveStrokeSingleNode] ** xref:sdk:api-reference/nodes/text.adoc[WeaveTextNode] @@ -70,6 +71,7 @@ ** xref:sdk:api-reference/actions/rectangle-tool.adoc[WeaveRectangleToolAction] ** xref:sdk:api-reference/actions/selection-tool.adoc[WeaveSelectionToolAction] ** xref:sdk:api-reference/actions/star-tool.adoc[WeaveStarToolAction] +** xref:sdk:api-reference/actions/sticky-note-tool.adoc[WeaveStickyNoteToolAction] ** xref:sdk:api-reference/actions/stroke-tool.adoc[WeaveStrokeToolAction] ** xref:sdk:api-reference/actions/text-tool.adoc[WeaveTextToolAction] ** xref:sdk:api-reference/actions/video-tool.adoc[WeaveVideoToolAction] diff --git a/docs/src/modules/sdk/pages/api-reference/actions/index.adoc b/docs/src/modules/sdk/pages/api-reference/actions/index.adoc index 9c8b962e8..4aca1d829 100644 --- a/docs/src/modules/sdk/pages/api-reference/actions/index.adoc +++ b/docs/src/modules/sdk/pages/api-reference/actions/index.adoc @@ -76,6 +76,10 @@ Enables users to place a polygon node on the canvas using a preset shape (triang .xref:sdk:api-reference/actions/star-tool.adoc[WeaveStarToolAction] Enables users to add new star nodes to the canvas by clicking or touching on it. +[card,subheader="Nodes",labels="new"] +.xref:sdk:api-reference/actions/sticky-note-tool.adoc[WeaveStickyNoteToolAction] +Enables users to create new sticky note nodes on the canvas by clicking or dragging. + [card,subheader="Nodes"] .xref:sdk:api-reference/actions/stroke-tool.adoc[WeaveStrokeToolAction] Enables users to create a single-stroke line on the canvas. diff --git a/docs/src/modules/sdk/pages/api-reference/actions/sticky-note-tool.adoc b/docs/src/modules/sdk/pages/api-reference/actions/sticky-note-tool.adoc new file mode 100644 index 000000000..2c0fd84de --- /dev/null +++ b/docs/src/modules/sdk/pages/api-reference/actions/sticky-note-tool.adoc @@ -0,0 +1,41 @@ += WeaveStickyNoteToolAction +:description: Add a sticky note to the canvas + +== Overview + +The https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/actions/sticky-note-tool/sticky-note-tool.ts[WeaveStickyNoteToolAction] +class allows users to place sticky note nodes on the canvas by clicking/touching or by dragging to size. + +A click or micro-drag below `20px` places a default `200x200` sticky note; larger drags display a live preview clamped to at least `120x120` px (`MIN_STICKY_NOTE_SIZE`). Each interaction creates a xref:sdk:api-reference/nodes/sticky-note.adoc[WeaveStickyNoteNode], synchronizes it to shared state, and transitions back to the selection tool with the new note selected. + +While dragging, hold `Shift` to constrain proportions to `1:1`, `Alt`/`Option` to draw from the initial point as the center, or both to combine the behaviors. Press `Escape` to cancel before pointer release. + +The class extends the xref:sdk:api-reference/actions/action.adoc[WeaveAction] class. + +== Name + +This action `name` property value is `stickyNoteTool`. + +== Import + +[source,shell] +---- +import { WeaveStickyNoteToolAction } from "@inditextech/weave-sdk"; +---- + +== Instantiation + +[source,ts] +---- +new WeaveStickyNoteToolAction(); +---- + +== Events + +[cols="3*"] +|=== +| Event | Payload | Description + +| `onAddingStickyNote` | `undefined` | Fired when the action enters the adding state (user is about to place a sticky note). +| `onAddedStickyNote` | `undefined` | Fired once after pointer release when the sticky note has been added to state. Not fired if cancelled with `Escape` before release. +|=== diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/index.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/index.adoc index 100c78f46..d23285c7f 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/index.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/index.adoc @@ -69,6 +69,10 @@ The WeavePolygonNode node renders a polygon shape on the canvas using explicit v .xref:sdk:api-reference/nodes/star.adoc[WeaveStarNode] The WeaveStarNode node is a simple, versatile primitive shape used to render star elements on the canvas. It supports customizable properties such as position, inner and outer radius, fill color, stroke and more. +[card,subheader="Drawing",labels="new"] +.xref:sdk:api-reference/nodes/sticky-note.adoc[WeaveStickyNoteNode] +The WeaveStickyNoteNode node renders an interactive sticky note primitive with a dog-ear folded corner, pastel color palette, optional author attribution, and an inline editable label. + [card,subheader="Drawing"] .xref:sdk:api-reference/nodes/stroke.adoc[WeaveStrokeNode] The WeaveStrokeNode node represents the recommended way to render free-hand lines on the canvas. It supports features like stroke color, width, dash patterns. diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc new file mode 100644 index 000000000..f6a2c3ffb --- /dev/null +++ b/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc @@ -0,0 +1,138 @@ += WeaveStickyNoteNode +:description: Sticky Note node API Reference + +== Overview + +The https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts[WeaveStickyNoteNode] +class represents a sticky note primitive within the Weave.js canvas, designed for collaborative brainstorming, workshops, annotations, and visual boards. + +Rendered as a Konva `Group` containing a faceted paper polygon, subtle top tape highlight, and bottom-right dog-ear fold flap, it supports interactive dragging, live resize without corner distortion, rotation, and inline text editing through Weave.js's shared shape-label engine. + +The class extends the xref:sdk:api-reference/nodes/node.adoc[WeaveNode] class. + +== Label engine + +Sticky note text is managed by the shared shape-label engine. By default the label uses Konva text and a DOM textarea overlay (`legacy`). +Set `config.label.engine` to `native` to render with the on-canvas text engine and edit with the native editing session instead (including rich-text formatting stored in `labelBlocks`): + +[source,ts] +---- +new WeaveStickyNoteNode({ config: { label: { engine: "native" } } }); +---- + +Sticky notes override the generic shape-label defaults with `STICKY_NOTE_LABEL_DEFAULTS`: top-left alignment (`labelAlign: "left"`, `labelVerticalAlign: "top"`), `Inter, system-ui, sans-serif` at `16px`, `#1F2937` fill, `1.35` line height, and `16px` padding (`labelPaddingX` / `labelPaddingY`). Text bounds automatically exclude the padding, the dog-ear fold corner, and the optional author footer margin. + +== 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`. 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 `sticky-note`. + +== Import + +[source,shell] +---- +import { WeaveStickyNoteNode } from "@inditextech/weave-sdk"; +---- + +== Instantiation + +[source,ts] +---- +new WeaveStickyNoteNode(params?: WeaveStickyNoteNodeParams); +---- + +== TypeScript types + +[source,ts] +---- +type WeaveNodeTransformerProperties = Konva.TransformerConfig; + +type WeaveShapeLabelConfig = { + engine: "legacy" | "native"; // default "legacy" +}; + +type WeaveStickyNoteProperties = { + transform: Partial; + label?: Partial; +}; + +type WeaveStickyNoteNodeParams = { + config?: Partial; +}; + +interface StickyNoteAttributes extends WeaveElementAttributes { + id: string; + nodeType?: string; + x?: number; + y?: number; + width?: number; + height?: number; + color?: string; + dogEarSize?: number; + author?: string; + rotation?: number; + zIndex?: number; +} +---- + +== Parameters + +For `WeaveStickyNoteNodeParams`: + +[cols="4*"] +|=== +| Property | Type | Required | Description + +| mono:[config] | mono:[Partial] | | Config parameters for the node. +|=== + +For `WeaveStickyNoteProperties`: + +[cols="5*"] +|=== +| Property | Type | Required | Default | Description + +| mono:[transform] | mono:[Partial] | | mono:[check default values] | Setup the transform properties for the sticky note (anchors, rotation, resize behavior, etc.). +| mono:[label] | mono:[Partial] | | mono:[{ engine: "legacy" }] | Shape-label rendering and editing engine configuration. +|=== + +== Node & Label properties + +[cols="5*"] +|=== +| Property | Type | Required | Default | Description + +| mono:[width] | mono:[number] | label:green[Required] | mono:[200] | Width of the sticky note in pixels. +| mono:[height] | mono:[number] | label:green[Required] | mono:[200] | Height of the sticky note in pixels. +| mono:[color] | mono:[string] | | mono:["#FEF08A"] | Background color of the sticky note in hex format. +| mono:[dogEarSize] | mono:[number] | | mono:[28] | Size of the bottom-right folded corner in pixels. +| mono:[author] | mono:[string] | | mono:[undefined] | Optional author text shown in the bottom-left footer row (rendered as-is). +| mono:[labelText] | mono:[string] | | mono:[""] | Plain text content of the note label. +| mono:[labelFontFamily] | mono:[string] | | mono:["Inter, system-ui, sans-serif"] | CSS font-family string for the note label. +| mono:[labelFontSize] | mono:[number] | | mono:[16] | Font size of the note label in pixels. +| mono:[labelFill] | mono:[string] | | mono:["#1F2937"] | Color of the note label text in hex format. +| mono:[labelAlign] | mono:[string] | | mono:["left"] | Horizontal alignment (`"left"`, `"center"`, `"right"`). +| mono:[labelVerticalAlign] | mono:[string] | | mono:["top"] | Vertical alignment (`"top"`, `"middle"`, `"bottom"`). +| mono:[labelLineHeight] | mono:[number] | | mono:[1.35] | Line-height multiplier for the note label. +| mono:[labelPaddingX] | mono:[number] | | mono:[16] | Horizontal inset padding in pixels. +| mono:[labelPaddingY] | mono:[number] | | mono:[16] | Vertical inset padding in pixels. +| mono:[labelBlocks] | mono:[WeaveTextBlock[]] | | mono:[undefined] | Rich-text blocks written by the `native` engine. +|=== + +== Default values & Constants + +The SDK exports the following constants alongside `WeaveStickyNoteNode`: + +* `WEAVE_STICKY_NOTE_NODE_TYPE` (`"sticky-note"`) +* `DEFAULT_STICKY_NOTE_WIDTH` (`200`), `DEFAULT_STICKY_NOTE_HEIGHT` (`200`), `MIN_STICKY_NOTE_SIZE` (`120`) +* `DEFAULT_DOG_EAR_SIZE` (`28`), `STICKY_NOTE_AUTHOR_MARGIN` (`22`), `DEFAULT_STICKY_NOTE_PADDING` (`16`) +* `DEFAULT_STICKY_NOTE_COLOR` (`"#FEF08A"`) +* `STICKY_NOTE_PASTEL_COLORS` (8 curated pastel swatches) +* `STICKY_NOTE_LABEL_DEFAULTS` (`WeaveShapeLabelProps` merged with sticky note typography and top-left padding) +* `getStickyNoteVerticalReserve(dogEarSize, hasAuthor, verticalAlign?)` (computes symmetric vertical reserves when `verticalAlign === "middle"`, or bottom-only reserve otherwise) From 620e524674e09f0cf89d5397b0cf40b653cdd135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 11:21:00 +0100 Subject: [PATCH 05/10] fix(sdk): center middle-aligned sticky note text within the padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Middle vertical alignment now centers the text within the padding only, ignoring the dog-ear fold and author row, so it is visually centered on the sheet. Top and bottom alignment keep the text clear of the fold and author. Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- .../sticky-note/__tests__/sticky-note.test.ts | 18 ++++++++++-------- .../sdk/src/nodes/sticky-note/constants.ts | 13 ++++++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts index 490afc55a..2d571bcb9 100644 --- a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -301,7 +301,7 @@ describe('WeaveStickyNoteNode', () => { }); }); - describe('Vertical text reserve (symmetric for middle alignment)', () => { + describe('Vertical text reserve (middle alignment ignores fold and author)', () => { it('reserves fold and author space only at the bottom for top alignment', () => { expect(getStickyNoteVerticalReserve(28, true, 'top')).toEqual({ top: 0, @@ -311,10 +311,12 @@ describe('WeaveStickyNoteNode', () => { expect(getStickyNoteVerticalReserve(28, false, 'bottom').top).toBe(0); }); - it('reserves the same space on top and bottom for middle alignment', () => { - const r = getStickyNoteVerticalReserve(28, true, 'middle'); - expect(r.top).toBe(r.bottom); - expect(r.total).toBe(r.top * 2); + it('reserves nothing for middle alignment', () => { + expect(getStickyNoteVerticalReserve(28, true, 'middle')).toEqual({ + top: 0, + bottom: 0, + total: 0, + }); }); function editBounds(overrides: Partial) { @@ -338,15 +340,15 @@ describe('WeaveStickyNoteNode', () => { return bounds as unknown as { y: number; height: number }; } - it('centers the text box on the sheet when vertical align is middle', () => { + it('centers the text box on the sheet (padding only) when vertical align is middle', () => { const padY = 16; const b = editBounds({ labelVerticalAlign: 'middle', labelPaddingY: padY, author: 'jmcaamanog', }); - const reserve = 28 + STICKY_NOTE_AUTHOR_MARGIN; - expect(b.y).toBe(padY + reserve); + expect(b.y).toBe(padY); + expect(b.height).toBe(300 - padY * 2); expect(b.y + b.height / 2).toBe(300 / 2); }); diff --git a/code/packages/sdk/src/nodes/sticky-note/constants.ts b/code/packages/sdk/src/nodes/sticky-note/constants.ts index b7d172232..07d35e988 100644 --- a/code/packages/sdk/src/nodes/sticky-note/constants.ts +++ b/code/packages/sdk/src/nodes/sticky-note/constants.ts @@ -49,16 +49,19 @@ export const STICKY_NOTE_LABEL_DEFAULTS = { /** * Vertical space reserved inside a sticky note for the dog-ear fold and the - * author row. With `middle` vertical alignment the same amount is reserved on - * top so the text is visually centered on the sheet; otherwise it is only - * reserved at the bottom. + * author row. It is only reserved at the bottom for `top` and `bottom` + * alignment. With `middle` alignment nothing is reserved so the text is + * centered on the sheet (padding only); a long text may then run over the + * fold or the author row. */ export function getStickyNoteVerticalReserve( dogEarSize: number, hasAuthor: boolean, verticalAlign: string ): { top: number; bottom: number; total: number } { + if (verticalAlign === 'middle') { + return { top: 0, bottom: 0, total: 0 }; + } const bottom = dogEarSize + (hasAuthor ? STICKY_NOTE_AUTHOR_MARGIN : 0); - const top = verticalAlign === 'middle' ? bottom : 0; - return { top, bottom, total: top + bottom }; + return { top: 0, bottom, total: bottom }; } From 3d78269ef7dc49ed21ea13a04cd7dc46893d4cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 11:21:01 +0100 Subject: [PATCH 06/10] docs: describe middle vertical alignment on sticky notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- .../modules/sdk/pages/api-reference/nodes/sticky-note.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc index f6a2c3ffb..c440c21ec 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc @@ -20,7 +20,7 @@ Set `config.label.engine` to `native` to render with the on-canvas text engine a new WeaveStickyNoteNode({ config: { label: { engine: "native" } } }); ---- -Sticky notes override the generic shape-label defaults with `STICKY_NOTE_LABEL_DEFAULTS`: top-left alignment (`labelAlign: "left"`, `labelVerticalAlign: "top"`), `Inter, system-ui, sans-serif` at `16px`, `#1F2937` fill, `1.35` line height, and `16px` padding (`labelPaddingX` / `labelPaddingY`). Text bounds automatically exclude the padding, the dog-ear fold corner, and the optional author footer margin. +Sticky notes override the generic shape-label defaults with `STICKY_NOTE_LABEL_DEFAULTS`: top-left alignment (`labelAlign: "left"`, `labelVerticalAlign: "top"`), `Inter, system-ui, sans-serif` at `16px`, `#1F2937` fill, `1.35` line height, and `16px` padding (`labelPaddingX` / `labelPaddingY`). With `top` and `bottom` vertical alignment the text bounds exclude the padding, the dog-ear fold corner and the optional author footer margin. With `middle` only the padding is excluded, so the text is centered on the sheet and a long text may run over the fold or the author line. == Label edit events @@ -135,4 +135,4 @@ The SDK exports the following constants alongside `WeaveStickyNoteNode`: * `DEFAULT_STICKY_NOTE_COLOR` (`"#FEF08A"`) * `STICKY_NOTE_PASTEL_COLORS` (8 curated pastel swatches) * `STICKY_NOTE_LABEL_DEFAULTS` (`WeaveShapeLabelProps` merged with sticky note typography and top-left padding) -* `getStickyNoteVerticalReserve(dogEarSize, hasAuthor, verticalAlign?)` (computes symmetric vertical reserves when `verticalAlign === "middle"`, or bottom-only reserve otherwise) +* `getStickyNoteVerticalReserve(dogEarSize, hasAuthor, verticalAlign?)` (returns the space reserved for the fold and the author line: nothing when `verticalAlign === "middle"`, a bottom-only reserve otherwise) From c6217be1a188d1ea05bd7914ac00df49b17a9f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 11:21:01 +0100 Subject: [PATCH 07/10] docs: register sticky note pages in the documentation state ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0a4ed9db7..f0c0cbb5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,3 +139,7 @@ npx skills@latest add InditexTech/docouture --all | main:build/nodes/frame.adoc (edge selection) | `code/packages/sdk/src/nodes/frame/{frame,constants,types,hit-region}.ts` | released in 5.8.0 | | sdk:api-reference/nodes/frame.adoc (`edgeHitWidth`) | `code/packages/sdk/src/nodes/frame/{frame,constants,types,hit-region}.ts` | released in 5.8.0 | | sdk:api-reference/nodes/text-v2.adoc (list marker alignment) | `code/packages/sdk/src/nodes/text-v2/layout/engine.ts` | released in 5.8.0 | +| main:build/nodes/sticky-note.adoc | `code/packages/sdk/src/nodes/sticky-note/*` | unreleased | +| main:build/actions/sticky-note-tool.adoc | `code/packages/sdk/src/actions/sticky-note-tool/*` | unreleased | +| sdk:api-reference/nodes/sticky-note.adoc | `code/packages/sdk/src/nodes/sticky-note/*`, `code/packages/sdk/src/nodes/shared/shape-label.utils.ts` | unreleased | +| sdk:api-reference/actions/sticky-note-tool.adoc | `code/packages/sdk/src/actions/sticky-note-tool/*` | unreleased | From 863eed2254ba66cbb36d031451df880af69b6c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 11:28:39 +0100 Subject: [PATCH 08/10] fix(sdk): center middle-aligned sticky note text above the author row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Middle vertical alignment only reserves the author row below the text, so the text is centered in the visible free space (and on the sheet when there is no author). The dog-ear fold is a corner triangle that left-aligned text rarely reaches, so it is not reserved. Top and bottom alignment still keep the text clear of the fold and the author row. Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- .../sticky-note/__tests__/sticky-note.test.ts | 23 ++++++++++++++----- .../sdk/src/nodes/sticky-note/constants.ts | 17 +++++++------- .../api-reference/nodes/sticky-note.adoc | 4 ++-- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts index 2d571bcb9..4deeef5d9 100644 --- a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -301,7 +301,7 @@ describe('WeaveStickyNoteNode', () => { }); }); - describe('Vertical text reserve (middle alignment ignores fold and author)', () => { + describe('Vertical text reserve (middle alignment only reserves the author row)', () => { it('reserves fold and author space only at the bottom for top alignment', () => { expect(getStickyNoteVerticalReserve(28, true, 'top')).toEqual({ top: 0, @@ -311,12 +311,13 @@ describe('WeaveStickyNoteNode', () => { expect(getStickyNoteVerticalReserve(28, false, 'bottom').top).toBe(0); }); - it('reserves nothing for middle alignment', () => { + it('only reserves the author row for middle alignment', () => { expect(getStickyNoteVerticalReserve(28, true, 'middle')).toEqual({ top: 0, - bottom: 0, - total: 0, + bottom: STICKY_NOTE_AUTHOR_MARGIN, + total: STICKY_NOTE_AUTHOR_MARGIN, }); + expect(getStickyNoteVerticalReserve(28, false, 'middle').total).toBe(0); }); function editBounds(overrides: Partial) { @@ -340,7 +341,7 @@ describe('WeaveStickyNoteNode', () => { return bounds as unknown as { y: number; height: number }; } - it('centers the text box on the sheet (padding only) when vertical align is middle', () => { + it('centers the text box above the author row when vertical align is middle', () => { const padY = 16; const b = editBounds({ labelVerticalAlign: 'middle', @@ -348,7 +349,17 @@ describe('WeaveStickyNoteNode', () => { author: 'jmcaamanog', }); expect(b.y).toBe(padY); - expect(b.height).toBe(300 - padY * 2); + expect(b.height).toBe(300 - padY * 2 - STICKY_NOTE_AUTHOR_MARGIN); + }); + + it('centers the text box on the sheet with middle alignment and no author', () => { + const padY = 16; + const b = editBounds({ + labelVerticalAlign: 'middle', + labelPaddingY: padY, + author: '', + }); + expect(b.y).toBe(padY); expect(b.y + b.height / 2).toBe(300 / 2); }); diff --git a/code/packages/sdk/src/nodes/sticky-note/constants.ts b/code/packages/sdk/src/nodes/sticky-note/constants.ts index 07d35e988..3d15ac13e 100644 --- a/code/packages/sdk/src/nodes/sticky-note/constants.ts +++ b/code/packages/sdk/src/nodes/sticky-note/constants.ts @@ -48,20 +48,19 @@ export const STICKY_NOTE_LABEL_DEFAULTS = { } as const; /** - * Vertical space reserved inside a sticky note for the dog-ear fold and the - * author row. It is only reserved at the bottom for `top` and `bottom` - * alignment. With `middle` alignment nothing is reserved so the text is - * centered on the sheet (padding only); a long text may then run over the - * fold or the author row. + * Vertical space reserved inside a sticky note below the text. `top` and + * `bottom` alignment keep the text clear of the dog-ear fold and the author + * row. `middle` alignment only reserves the author row, so the text is + * centered in the space above it (and on the sheet when there is no author); + * the fold is a corner triangle that left-aligned text rarely reaches. */ export function getStickyNoteVerticalReserve( dogEarSize: number, hasAuthor: boolean, verticalAlign: string ): { top: number; bottom: number; total: number } { - if (verticalAlign === 'middle') { - return { top: 0, bottom: 0, total: 0 }; - } - const bottom = dogEarSize + (hasAuthor ? STICKY_NOTE_AUTHOR_MARGIN : 0); + const authorMargin = hasAuthor ? STICKY_NOTE_AUTHOR_MARGIN : 0; + const bottom = + verticalAlign === 'middle' ? authorMargin : dogEarSize + authorMargin; return { top: 0, bottom, total: bottom }; } diff --git a/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc b/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc index c440c21ec..3e1fb8274 100644 --- a/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc +++ b/docs/src/modules/sdk/pages/api-reference/nodes/sticky-note.adoc @@ -20,7 +20,7 @@ Set `config.label.engine` to `native` to render with the on-canvas text engine a new WeaveStickyNoteNode({ config: { label: { engine: "native" } } }); ---- -Sticky notes override the generic shape-label defaults with `STICKY_NOTE_LABEL_DEFAULTS`: top-left alignment (`labelAlign: "left"`, `labelVerticalAlign: "top"`), `Inter, system-ui, sans-serif` at `16px`, `#1F2937` fill, `1.35` line height, and `16px` padding (`labelPaddingX` / `labelPaddingY`). With `top` and `bottom` vertical alignment the text bounds exclude the padding, the dog-ear fold corner and the optional author footer margin. With `middle` only the padding is excluded, so the text is centered on the sheet and a long text may run over the fold or the author line. +Sticky notes override the generic shape-label defaults with `STICKY_NOTE_LABEL_DEFAULTS`: top-left alignment (`labelAlign: "left"`, `labelVerticalAlign: "top"`), `Inter, system-ui, sans-serif` at `16px`, `#1F2937` fill, `1.35` line height, and `16px` padding (`labelPaddingX` / `labelPaddingY`). With `top` and `bottom` vertical alignment the text bounds exclude the padding, the dog-ear fold corner and the optional author footer margin. With `middle` only the padding and the author line are excluded, so the text is centered in the space above the author line (on the sheet when there is no author) and a long text may run over the fold. == Label edit events @@ -135,4 +135,4 @@ The SDK exports the following constants alongside `WeaveStickyNoteNode`: * `DEFAULT_STICKY_NOTE_COLOR` (`"#FEF08A"`) * `STICKY_NOTE_PASTEL_COLORS` (8 curated pastel swatches) * `STICKY_NOTE_LABEL_DEFAULTS` (`WeaveShapeLabelProps` merged with sticky note typography and top-left padding) -* `getStickyNoteVerticalReserve(dogEarSize, hasAuthor, verticalAlign?)` (returns the space reserved for the fold and the author line: nothing when `verticalAlign === "middle"`, a bottom-only reserve otherwise) +* `getStickyNoteVerticalReserve(dogEarSize, hasAuthor, verticalAlign?)` (returns the space reserved below the text: the fold and the author line, or only the author line when `verticalAlign === "middle"`) From b95ad0b79fd1dab6439bd8a001053c7eeda41789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 11:39:32 +0100 Subject: [PATCH 09/10] fix(sdk): avoid the Konva buffer canvas on the sticky note sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filled, stroked and shadowed shape is drawn through an intermediate buffer canvas by Konva. On the skia server backend this left the sheet blank (white, no shadow) in server exports of larger notes, while the text and fold still rendered. Disable perfectDrawEnabled and shadowForStrokeEnabled on the sheet: the shadow of the 1px stroke is imperceptible and it also avoids extra offscreen canvases on large boards. Refs #1232 Signed-off-by: Jesus Manuel Piñeiro Cid --- .../src/nodes/sticky-note/__tests__/sticky-note.test.ts | 7 +++++++ code/packages/sdk/src/nodes/sticky-note/sticky-note.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts index 4deeef5d9..0b0e6b0e6 100644 --- a/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts +++ b/code/packages/sdk/src/nodes/sticky-note/__tests__/sticky-note.test.ts @@ -170,6 +170,13 @@ describe('WeaveStickyNoteNode', () => { expect(author.text()).toBe('jmcaamanog'); }); + it('draws the sheet without a Konva buffer canvas so server exports are not blank', () => { + const group = node.onRender(defaultProps()) as Konva.Group; + const mainSheet = group.findOne('#sticky-1-main-sheet') as Konva.Line; + expect(mainSheet.perfectDrawEnabled()).toBe(false); + expect(mainSheet.shadowForStrokeEnabled()).toBe(false); + }); + it('updates labelText, color and author dynamically on update', () => { const props = defaultProps(); const group = node.onRender(props) as Konva.Group; diff --git a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts index 7ed30d691..f1689d85d 100644 --- a/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts +++ b/code/packages/sdk/src/nodes/sticky-note/sticky-note.ts @@ -260,6 +260,12 @@ export class WeaveStickyNoteNode extends WeaveNode { shadowBlur: 10, shadowOffset: { x: 2, y: 5 }, shadowOpacity: 0.7, + // A filled + stroked + shadowed shape makes Konva draw through an + // intermediate buffer canvas, and on the skia server backend that left + // the sheet blank in exports. The shadow of the 1px stroke is + // imperceptible, so skip the buffer. + perfectDrawEnabled: false, + shadowForStrokeEnabled: false, listening: true, draggable: false, }); From d5a4c926f90b7d01c2992607d7cf2eccd7789a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Thu, 12 Nov 2026 13:09:48 +0100 Subject: [PATCH 10/10] fix(deps): add nested minimatch 10.2.5 required by nx to lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesus Manuel Piñeiro Cid --- code/package-lock.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/code/package-lock.json b/code/package-lock.json index 1243a2498..53f2d5f51 100644 --- a/code/package-lock.json +++ b/code/package-lock.json @@ -3355,6 +3355,22 @@ "nx": ">= 22 <= 24 || ^23.0.0-0" } }, + "node_modules/@nx/devkit/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@nx/js": { "version": "23.2.1", "resolved": "https://registry.npmjs.org/@nx/js/-/js-23.2.1.tgz", @@ -12791,6 +12807,22 @@ "node": ">=12" } }, + "node_modules/nx/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",