diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md index d3793a758..ef4854ccd 100644 --- a/code/CHANGELOG.md +++ b/code/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#1080](https://github.com/InditexTech/weavejs/issues/1080) [alpha] API to manipulate document state natively on server-side - [#1101](https://github.com/InditexTech/weavejs/issues/1101) Add inline text labels to shape nodes (Rectangle and Ellipse) +- [#1104](https://github.com/InditexTech/weavejs/issues/1104) Add Polygon Node and Polygon Drawing Tool with Label Support ### Changed diff --git a/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts b/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts new file mode 100644 index 000000000..d28de28d8 --- /dev/null +++ b/code/packages/sdk/src/actions/polygon-tool/__tests__/polygon-tool.test.ts @@ -0,0 +1,309 @@ +// 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'; + +vi.mock('@/weave', () => ({ Weave: class Weave {} })); +vi.mock('@/plugins/nodes-selection/nodes-selection', () => ({ + WeaveNodesSelectionPlugin: class WeaveNodesSelectionPlugin {}, +})); +vi.mock('konva', () => ({ default: {} })); +vi.mock('uuid', () => ({ v4: vi.fn().mockReturnValue('test-uuid') })); + +if (typeof (globalThis as Record)['window'] === 'undefined') { + (globalThis as Record)['window'] = globalThis; +} + +import { + makeContainer, + type R, +} from '../../__tests__/shared/action.test-helpers'; +import { WeavePolygonToolAction } from '../polygon-tool'; +import { POLYGON_TOOL_ACTION_NAME, POLYGON_TOOL_STATE } from '../constants'; +import { SELECTION_TOOL_ACTION_NAME } from '../../selection-tool/constants'; +import { + WEAVE_POLYGON_PRESETS, + instantiatePreset, +} from '../../../nodes/polygon/presets'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockWeave() { + const stageContainer = { + tabIndex: 0, + focus: vi.fn(), + blur: vi.fn(), + style: { cursor: '' }, + }; + + const stageHandlers: Record void> = {}; + const mockNode = { getAttrs: vi.fn().mockReturnValue({ id: 'test-uuid' }) }; + + const stage = { + container: vi.fn().mockReturnValue(stageContainer), + on: vi.fn((event: string, handler: (e?: unknown) => void) => { + stageHandlers[event] = handler; + }), + findOne: vi.fn().mockReturnValue(mockNode), + }; + + const defaultContainer = makeContainer('layer-id'); + const selectionPlugin = { + setSelectedNodes: vi.fn(), + getSelectedNodes: vi.fn().mockReturnValue([]), + }; + + const nodeHandlerMock = { + create: vi.fn().mockReturnValue(mockNode), + serialize: vi.fn().mockReturnValue({ id: 'test-uuid', type: 'polygon', props: {} }), + }; + + return { + getStage: vi.fn().mockReturnValue(stage), + getPlugin: vi.fn().mockReturnValue(selectionPlugin), + getMousePointer: vi.fn().mockReturnValue({ + mousePoint: { x: 50, y: 75 }, + container: defaultContainer, + }), + getNodeHandler: vi.fn().mockReturnValue(nodeHandlerMock), + getEventsController: vi.fn().mockReturnValue(new AbortController()), + getActiveAction: vi.fn().mockReturnValue(POLYGON_TOOL_ACTION_NAME), + emitEvent: vi.fn(), + addNode: vi.fn(), + updateNode: vi.fn(), + triggerAction: vi.fn(), + getChildLogger: vi.fn().mockReturnValue({ debug: vi.fn() }), + _stage: stage, + _stageContainer: stageContainer, + _stageHandlers: stageHandlers, + _selectionPlugin: selectionPlugin, + _nodeHandler: nodeHandlerMock, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WeavePolygonToolAction', () => { + let action: WeavePolygonToolAction; + 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 WeavePolygonToolAction(); + mockWeave = makeMockWeave(); + (action as unknown as R)['instance'] = mockWeave; + (action as unknown as R)['cancelAction'] = vi.fn(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + // ── Suite 1: constructor / initialize ───────────────────────────────────── + + describe('constructor / initialize', () => { + it('1.1 initialized=false, state=IDLE, polygonId=null', () => { + expect((action as unknown as R)['initialized']).toBe(false); + expect((action as unknown as R)['state']).toBe(POLYGON_TOOL_STATE.IDLE); + expect((action as unknown as R)['polygonId']).toBeNull(); + }); + + it('1.2 default preset is pentagon', () => { + expect((action as unknown as R)['preset']).toBe('pentagon'); + }); + + it('1.3 accepts a preset in constructor', () => { + const hexAction = new WeavePolygonToolAction('hexagon'); + expect((hexAction as unknown as R)['preset']).toBe('hexagon'); + }); + + it('1.4 onPropsChange and onInit are undefined', () => { + expect(action.onPropsChange).toBeUndefined(); + expect(action.onInit).toBeUndefined(); + }); + }); + + // ── Suite 2: getName ─────────────────────────────────────────────────────── + + describe('getName', () => { + it('2.1 returns POLYGON_TOOL_ACTION_NAME', () => { + expect(action.getName()).toBe(POLYGON_TOOL_ACTION_NAME); + }); + }); + + // ── Suite 3: initProps ───────────────────────────────────────────────────── + + describe('initProps', () => { + it('3.1 returns expected defaults', () => { + const props = (action as unknown as R)['initProps']() as Record; + expect(props.opacity).toBe(1); + expect(props.fill).toBe('#ffffffff'); + expect(props.stroke).toBe('#000000ff'); + expect(props.strokeWidth).toBe(1); + }); + }); + + // ── Suite 4: trigger ────────────────────────────────────────────────────── + + describe('trigger', () => { + it('4.1 throws when instance not defined', () => { + const bareAction = new WeavePolygonToolAction(); + expect(() => bareAction.trigger(vi.fn())).toThrow('Instance not defined'); + }); + + it('4.2 sets state to ADDING after trigger', () => { + const cancelFn = vi.fn(); + action.trigger(cancelFn); + expect((action as unknown as R)['state']).toBe(POLYGON_TOOL_STATE.ADDING); + }); + + it('4.3 sets cursor to crosshair', () => { + action.trigger(vi.fn()); + expect(mockWeave._stageContainer.style.cursor).toBe('crosshair'); + }); + + it('4.4 registers pointerdown on stage', () => { + action.trigger(vi.fn()); + expect(mockWeave._stage.on).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function) + ); + }); + + it('4.5 clears selection on trigger', () => { + action.trigger(vi.fn()); + expect(mockWeave._selectionPlugin.setSelectedNodes).toHaveBeenCalledWith( + [] + ); + }); + }); + + // ── Suite 5: pointerdown creates node ───────────────────────────────────── + + describe('pointerdown creates polygon', () => { + it('5.1 calls addNode with the created node', () => { + const cancelFn = vi.fn(); + action.trigger(cancelFn); + + const handlers = mockWeave._stageHandlers; + expect(handlers['pointerdown']).toBeDefined(); + + handlers['pointerdown']?.({ + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 0 }, + }); + + expect(mockWeave.addNode).toHaveBeenCalled(); + }); + + it('5.2 emits onAddingPolygon and onAddedPolygon', () => { + action.trigger(vi.fn()); + const handlers = mockWeave._stageHandlers; + + handlers['pointerdown']?.({ + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 0 }, + }); + + expect(mockWeave.emitEvent).toHaveBeenCalledWith('onAddingPolygon'); + expect(mockWeave.emitEvent).toHaveBeenCalledWith('onAddedPolygon'); + }); + + it('5.3 uses scaleFactor from updateProps when set', () => { + action.trigger(vi.fn()); + action.updateProps({ scaleFactor: 2 }); + + const handlers = mockWeave._stageHandlers; + handlers['pointerdown']?.({ + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 0 }, + }); + + const createCall = mockWeave._nodeHandler.create.mock.calls[0]?.[1] as Record; + const expected = instantiatePreset( + WEAVE_POLYGON_PRESETS.pentagon, + WEAVE_POLYGON_PRESETS.pentagon.defaultWidth * 2, + WEAVE_POLYGON_PRESETS.pentagon.defaultHeight * 2 + ); + expect(createCall?.width).toBe(expected.width); + expect(createCall?.height).toBe(expected.height); + }); + + it('5.4 uses preset defaults (scaleFactor=1) when not set', () => { + action.trigger(vi.fn()); + + const handlers = mockWeave._stageHandlers; + handlers['pointerdown']?.({ + evt: { pointerId: 1, clientX: 50, clientY: 75, buttons: 0 }, + }); + + const createCall = mockWeave._nodeHandler.create.mock.calls[0]?.[1] as Record; + const expected = instantiatePreset( + WEAVE_POLYGON_PRESETS.pentagon, + WEAVE_POLYGON_PRESETS.pentagon.defaultWidth, + WEAVE_POLYGON_PRESETS.pentagon.defaultHeight + ); + expect(createCall?.width).toBe(expected.width); + expect(createCall?.height).toBe(expected.height); + }); + }); + + // ── Suite 6: keyboard cancel ─────────────────────────────────────────────── + + describe('keyboard cancel', () => { + it('6.1 Escape cancels the action', () => { + const cancelFn = vi.fn(); + action.trigger(cancelFn); + + windowHandlers['keydown']?.({ code: 'Escape' } as KeyboardEvent); + + expect(cancelFn).toHaveBeenCalled(); + }); + + it('6.2 Enter also cancels the action', () => { + const cancelFn = vi.fn(); + action.trigger(cancelFn); + + windowHandlers['keydown']?.({ code: 'Enter' } as KeyboardEvent); + + expect(cancelFn).toHaveBeenCalled(); + }); + }); + + // ── Suite 7: cleanup ────────────────────────────────────────────────────── + + describe('cleanup', () => { + it('7.1 resets state to IDLE', () => { + action.trigger(vi.fn()); + action.cleanup(); + expect((action as unknown as R)['state']).toBe(POLYGON_TOOL_STATE.IDLE); + }); + + it('7.2 sets cursor to default', () => { + action.trigger(vi.fn()); + action.cleanup(); + expect(mockWeave._stageContainer.style.cursor).toBe('default'); + }); + + it('7.3 triggers SELECTION action', () => { + action.trigger(vi.fn()); + action.cleanup(); + expect(mockWeave.triggerAction).toHaveBeenCalledWith( + SELECTION_TOOL_ACTION_NAME + ); + }); + }); +}); diff --git a/code/packages/sdk/src/actions/polygon-tool/constants.ts b/code/packages/sdk/src/actions/polygon-tool/constants.ts new file mode 100644 index 000000000..72653542f --- /dev/null +++ b/code/packages/sdk/src/actions/polygon-tool/constants.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const POLYGON_TOOL_ACTION_NAME = 'polygonTool'; + +export const POLYGON_TOOL_STATE = { + ['IDLE']: 'idle', + ['ADDING']: 'adding', + ['ADDED']: 'added', +} as const; diff --git a/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts b/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts new file mode 100644 index 000000000..c45fe69db --- /dev/null +++ b/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts @@ -0,0 +1,215 @@ +// 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 { WeaveAction } from '@/actions/action'; +import { + type WeavePolygonToolActionState, + type WeavePolygonToolActionTriggerParams, +} from './types'; +import { POLYGON_TOOL_ACTION_NAME, POLYGON_TOOL_STATE } from './constants'; +import { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; +import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; +import type { WeavePolygonNode } from '@/nodes/polygon/polygon'; +import { + WEAVE_POLYGON_PRESETS, + instantiatePreset, + type WeavePolygonPresetDef, +} from '@/nodes/polygon/presets'; +import { WEAVE_POLYGON_NODE_TYPE } from '@/nodes/polygon/constants'; + +export class WeavePolygonToolAction extends WeaveAction { + protected initialized: boolean = false; + protected state!: WeavePolygonToolActionState; + protected polygonId!: string | null; + protected cancelAction!: () => void; + protected preset: string; + onPropsChange = undefined; + onInit = undefined; + + constructor(preset?: string) { + super(); + this.preset = preset ?? 'pentagon'; + this.initialize(); + } + + initialize(): void { + this.initialized = false; + this.state = POLYGON_TOOL_STATE.IDLE; + this.polygonId = null; + this.props = this.initProps(); + } + + getName(): string { + return POLYGON_TOOL_ACTION_NAME; + } + + initProps() { + return { + opacity: 1, + fill: '#ffffffff', + stroke: '#000000ff', + strokeWidth: 1, + }; + } + + getPolygonsPresets(): Record { + return WEAVE_POLYGON_PRESETS; + } + + getPolygonPreset(): string { + return this.preset; + } + + setPolygonPreset(preset: string) { + this.preset = preset; + } + + private setupEvents() { + const stage = this.instance.getStage(); + + window.addEventListener( + 'keydown', + (e) => { + if ( + (e.code === 'Enter' || e.code === 'Escape') && + this.instance.getActiveAction() === POLYGON_TOOL_ACTION_NAME + ) { + this.cancelAction(); + } + }, + { signal: this.instance.getEventsController().signal } + ); + + stage.on('pointermove', () => { + if (this.state === POLYGON_TOOL_STATE.IDLE) return; + + this.setCursor(); + }); + + stage.on('pointerdown', (e) => { + this.setTapStart(e); + + if (this.state !== POLYGON_TOOL_STATE.ADDING) return; + + this.handleAdding(); + }); + + this.initialized = true; + } + + private setState(state: WeavePolygonToolActionState) { + this.state = state; + } + + private addPolygon() { + this.setCursor(); + this.setFocusStage(); + + this.instance.emitEvent( + 'onAddingPolygon' + ); + + this.setState(POLYGON_TOOL_STATE.ADDING); + } + + private handleAdding() { + const { mousePoint, container } = this.instance.getMousePointer(); + + this.polygonId = uuidv4(); + + const presetDef = WEAVE_POLYGON_PRESETS[this.preset]; + const scaleFactor = (this.props.scaleFactor as number | undefined) ?? 1; + const { points, innerRect, width, height } = instantiatePreset( + presetDef, + presetDef.defaultWidth * scaleFactor, + presetDef.defaultHeight * scaleFactor + ); + + const nodeHandler = this.instance.getNodeHandler( + WEAVE_POLYGON_NODE_TYPE + ); + + if (nodeHandler) { + const node = nodeHandler.create(this.polygonId, { + ...this.props, + x: mousePoint?.x ?? 0, + y: mousePoint?.y ?? 0, + width, + height, + sides: presetDef.sides, + points, + innerRect, + }); + this.instance.addNode(node, container?.getAttrs().id); + } + + this.instance.emitEvent( + 'onAddedPolygon' + ); + + this.cancelAction(); + } + + trigger( + cancelAction: () => void, + params: WeavePolygonToolActionTriggerParams + ): void { + if (!this.instance) { + throw new Error('Instance not defined'); + } + + if (!this.initialized) { + this.setupEvents(); + } + + this.preset = params?.presetId ?? 'pentagon'; + + 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.addPolygon(); + } + + 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.polygonId}`); + if (node) { + selectionPlugin.setSelectedNodes([node]); + } + this.instance.triggerAction(SELECTION_TOOL_ACTION_NAME); + } + + this.polygonId = null; + this.setState(POLYGON_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/polygon-tool/types.ts b/code/packages/sdk/src/actions/polygon-tool/types.ts new file mode 100644 index 000000000..18567dc0b --- /dev/null +++ b/code/packages/sdk/src/actions/polygon-tool/types.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { POLYGON_TOOL_STATE } from './constants'; + +export type WeavePolygonToolActionStateKeys = keyof typeof POLYGON_TOOL_STATE; +export type WeavePolygonToolActionState = + (typeof POLYGON_TOOL_STATE)[WeavePolygonToolActionStateKeys]; + + +export type WeavePolygonToolActionTriggerParams = { + presetId?: string; +}; diff --git a/code/packages/sdk/src/index.common.ts b/code/packages/sdk/src/index.common.ts index eda96ffac..1d1d1a0a5 100644 --- a/code/packages/sdk/src/index.common.ts +++ b/code/packages/sdk/src/index.common.ts @@ -68,6 +68,10 @@ export * from './nodes/arrow/types'; export { WeaveRegularPolygonNode } from './nodes/regular-polygon/regular-polygon'; export * from './nodes/regular-polygon/constants'; export * from './nodes/regular-polygon/types'; +export { WeavePolygonNode } from './nodes/polygon/polygon'; +export * from './nodes/polygon/constants'; +export * from './nodes/polygon/types'; +export * from './nodes/polygon/presets'; export { WeaveFrameNode } from './nodes/frame/frame'; export * from './nodes/frame/constants'; export * from './nodes/frame/types'; @@ -148,6 +152,9 @@ export * from './actions/stroke-tool/types'; export { WeaveRegularPolygonToolAction } from './actions/regular-polygon-tool/regular-polygon-tool'; export * from './actions/regular-polygon-tool/constants'; export * from './actions/regular-polygon-tool/types'; +export { WeavePolygonToolAction } from './actions/polygon-tool/polygon-tool'; +export * from './actions/polygon-tool/constants'; +export * from './actions/polygon-tool/types'; export { WeaveFrameToolAction } from './actions/frame-tool/frame-tool'; export * from './actions/frame-tool/constants'; export * from './actions/frame-tool/types'; diff --git a/code/packages/sdk/src/nodes/polygon/__tests__/polygon.test.ts b/code/packages/sdk/src/nodes/polygon/__tests__/polygon.test.ts new file mode 100644 index 000000000..3017195f7 --- /dev/null +++ b/code/packages/sdk/src/nodes/polygon/__tests__/polygon.test.ts @@ -0,0 +1,872 @@ +// 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 { WeavePolygonNode } from '../polygon'; +import { WEAVE_POLYGON_NODE_TYPE } from '../constants'; +import { augmentKonvaNodeClass } from '../../node'; +import type { WeaveElementAttributes } from '@inditextech/weave-types'; +import type { WeavePolygonPoint, WeavePolygonInnerRect } from '../types'; +import { + createMockInstance, + makePluginMock, +} from '../../__tests__/shared/node.test-helpers'; +import { + WEAVE_POLYGON_PRESETS, + instantiatePreset, +} from '../presets'; + +vi.mock('@/weave', () => ({ Weave: class MockWeave {} })); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeNode(transformConfig?: object): { + node: WeavePolygonNode; + mock: ReturnType; +} { + const node = transformConfig + ? new WeavePolygonNode({ config: { transform: transformConfig } }) + : new WeavePolygonNode(); + 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 { + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { points, innerRect } = instantiatePreset( + preset, + preset.defaultWidth, + preset.defaultHeight + ); + return { + id: 'poly-id', + nodeType: WEAVE_POLYGON_NODE_TYPE, + x: 10, + y: 20, + sides: 5, + points, + innerRect, + fill: '#FF0000', + stroke: '#000000', + strokeWidth: 4, + rotation: 0, + scaleX: 1, + scaleY: 1, + opacity: 1, + zIndex: 1, + children: [], + ...overrides, + }; +} + +beforeAll(() => { + augmentKonvaNodeClass(); +}); + +// =========================================================================== +// Tests +// =========================================================================== + +describe('WeavePolygonNode', () => { + // ------------------------------------------------------------------------- + // Suite 1 — constructor + // ------------------------------------------------------------------------- + + describe('constructor', () => { + it('1.1 instantiates with no params and nodeType is "polygon"', () => { + const { node } = makeNode(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((node as any).nodeType).toBe(WEAVE_POLYGON_NODE_TYPE); + }); + + it('1.2 accepts partial transform config', () => { + const { node } = makeNode({ rotateEnabled: false }); + const group = node.onRender(defaultProps()) as Konva.Group; + const props = group.getTransformerProperties(); + expect(props.rotateEnabled).toBe(false); + }); + + it('1.3 initialize property is undefined', () => { + const { node } = makeNode(); + expect(node.initialize).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 2 — onRender: group structure + // ------------------------------------------------------------------------- + + describe('onRender — group structure', () => { + let node: WeavePolygonNode; + let group: Konva.Group; + const props = defaultProps(); + + beforeEach(() => { + ({ node } = makeNode()); + group = node.onRender(props) as Konva.Group; + }); + + it('2.1 returns a Konva.Group', () => { + expect(group).toBeInstanceOf(Konva.Group); + }); + + it('2.2 group name is "node"', () => { + expect(group.name()).toBe('node'); + }); + + it('2.3 group has at least two children (bg + border)', () => { + expect(group.getChildren().length).toBeGreaterThanOrEqual(2); + }); + + it('2.4 group id matches props.id', () => { + expect(group.id()).toBe(props.id); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 3 — onRender: background shape + // ------------------------------------------------------------------------- + + describe('onRender — background shape', () => { + let group: Konva.Group; + let bgShape: Konva.Shape; + const props = defaultProps({ strokeWidth: 4 }); + + beforeEach(() => { + const { node } = makeNode(); + group = node.onRender(props) as Konva.Group; + bgShape = group.findOne(`#${props.id}-bg`) as Konva.Shape; + }); + + it('3.1 bg shape is found by id {id}-bg', () => { + expect(bgShape).toBeTruthy(); + }); + + it('3.2 bg shape nodeId equals props.id', () => { + expect(bgShape.getAttr('nodeId')).toBe(props.id); + }); + + it('3.3 bg shape strokeWidth is 0', () => { + expect(bgShape.strokeWidth()).toBe(0); + }); + + it('3.4 bg shape fill uses props.fill', () => { + expect(bgShape.fill()).toBe(props.fill); + }); + + it('3.5 bg shape fill defaults to "transparent" when props.fill is absent', () => { + const { node: n } = makeNode(); + const g = n.onRender(defaultProps({ fill: undefined })) as Konva.Group; + const bg = g.findOne('#poly-id-bg') as Konva.Shape; + expect(bg.fill()).toBe('transparent'); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 4 — onRender: border shape + // ------------------------------------------------------------------------- + + describe('onRender — border shape', () => { + let group: Konva.Group; + let borderShape: Konva.Shape; + const props = defaultProps({ strokeWidth: 4 }); + + beforeEach(() => { + const { node } = makeNode(); + group = node.onRender(props) as Konva.Group; + borderShape = group.findOne(`#${props.id}-border`) as Konva.Shape; + }); + + it('4.1 border shape is found by id {id}-border', () => { + expect(borderShape).toBeTruthy(); + }); + + it('4.2 border shape uses inside-stroke (strokeWidth=0, innerStrokeWidth=props.strokeWidth)', () => { + expect(borderShape.strokeWidth()).toBe(0); + expect(borderShape.getAttr('innerStrokeWidth')).toBe(props.strokeWidth); + }); + + it('4.3 border shape fill is "transparent"', () => { + expect(borderShape.fill()).toBe('transparent'); + }); + + it('4.4 border shape listening is false', () => { + expect(borderShape.listening()).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 5 — transformer properties + // ------------------------------------------------------------------------- + + describe('transformer properties', () => { + it('5.1 all 8 anchors are enabled', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const tp = group.getTransformerProperties(); + expect(tp.enabledAnchors).toContain('top-center'); + expect(tp.enabledAnchors).toContain('middle-left'); + expect(tp.enabledAnchors).toContain('bottom-center'); + expect(tp.enabledAnchors).toContain('middle-right'); + }); + + it('5.2 keepRatio is false', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const tp = group.getTransformerProperties(); + expect(tp.keepRatio).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 6 — onUpdate + // ------------------------------------------------------------------------- + + describe('onUpdate', () => { + it('6.1 updates fill on the bg shape', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + + const next = defaultProps({ fill: '#0000FF' }); + node.onUpdate(group, next); + + const bgShape = group.findOne(`#${next.id}-bg`) as Konva.Shape; + expect(bgShape.fill()).toBe('#0000FF'); + }); + + it('6.2 updates stroke on the border shape', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + + const next = defaultProps({ stroke: '#AABBCC' }); + node.onUpdate(group, next); + + const borderShape = group.findOne(`#${next.id}-border`) as Konva.Shape; + expect(borderShape.stroke()).toBe('#AABBCC'); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 7 — defaultState / addNodeState / updateNodeState + // ------------------------------------------------------------------------- + + describe('static state methods', () => { + it('7.1 defaultState returns type "polygon"', () => { + const state = WeavePolygonNode.defaultState('test-id'); + expect(state.type).toBe(WEAVE_POLYGON_NODE_TYPE); + }); + + it('7.2 defaultState has points array with 5 entries (pentagon)', () => { + const state = WeavePolygonNode.defaultState('test-id'); + expect(Array.isArray(state.props.points)).toBe(true); + expect((state.props.points as unknown[]).length).toBe(5); + }); + + it('7.3 defaultState has innerRect with tl/tr/bl/br', () => { + const state = WeavePolygonNode.defaultState('test-id'); + const ir = state.props.innerRect as Record; + expect(ir).toHaveProperty('tl'); + expect(ir).toHaveProperty('tr'); + expect(ir).toHaveProperty('bl'); + expect(ir).toHaveProperty('br'); + }); + + it('7.4 addNodeState merges provided props', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const preset = WEAVE_POLYGON_PRESETS.hexagon; + const { points, innerRect } = instantiatePreset(preset, 200, 200); + const result = WeavePolygonNode.addNodeState(base, { + x: 50, + y: 60, + sides: 6, + points, + innerRect, + fill: '#123456', + rotation: 0, + }); + expect(result.props.x).toBe(50); + expect(result.props.fill).toBe('#123456'); + expect((result.props.points as unknown[]).length).toBe(6); + }); + + it('7.5 updateNodeState merges only provided props', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const result = WeavePolygonNode.updateNodeState(base, { + ...base.props, + fill: '#FFFFFF', + }); + expect(result.props.fill).toBe('#FFFFFF'); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 8 — getSchema + // ------------------------------------------------------------------------- + + describe('getSchema', () => { + it('8.1 schema type literal is "polygon"', () => { + const schema = WeavePolygonNode.getSchema(); + expect(() => + schema.parse({ + key: 'k1', + type: WEAVE_POLYGON_NODE_TYPE, + props: { + nodeType: WEAVE_POLYGON_NODE_TYPE, + id: 'poly-1', + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + opacity: 1, + sides: 5, + points: [{ x: 0, y: 0 }], + innerRect: { + tl: { x: 0, y: 0 }, + tr: { x: 1, y: 0 }, + bl: { x: 0, y: 1 }, + br: { x: 1, y: 1 }, + }, + fill: '#FFFFFF', + stroke: '#000000', + strokeWidth: 1, + strokeScaleEnabled: false, + rotation: 0, + zIndex: 1, + children: [], + }, + }) + ).not.toThrow(); + }); + + it('8.2 schema rejects wrong type literal', () => { + const schema = WeavePolygonNode.getSchema(); + expect(() => + schema.parse({ + key: 'k2', + type: 'wrong-type', + props: { + nodeType: 'wrong-type', + id: 'x', + }, + }) + ).toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 9 — realOffset / scaleReset + // ------------------------------------------------------------------------- + + describe('realOffset and scaleReset', () => { + it('9.1 realOffset returns {x:0, y:0}', () => { + const { node } = makeNode(); + const state = WeavePolygonNode.defaultState('test-id'); + const offset = node.realOffset(state); + expect(offset).toEqual({ x: 0, y: 0 }); + }); + + it('9.2 scaleReset is a no-op (does not throw)', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + expect(() => node.scaleReset(group)).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 10 — polygonSelfRect (getSelfRect helper) + // ------------------------------------------------------------------------- + + describe('polygonSelfRect (getSelfRect)', () => { + it('10.1 returns min/max bounds from polygon points', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const bgShape = group.findOne('#poly-id-bg') as Konva.Shape; + const rect = bgShape.getSelfRect(); + expect(rect.x).toBeTypeOf('number'); + expect(rect.y).toBeTypeOf('number'); + expect(rect.width).toBeGreaterThan(0); + expect(rect.height).toBeGreaterThan(0); + }); + + it('10.2 returns zero bounds for empty points', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const bgShape = group.findOne('#poly-id-bg') as Konva.Shape; + bgShape.setAttr('points', []); + const rect = bgShape.getSelfRect(); + expect(rect).toEqual({ x: 0, y: 0, width: 0, height: 0 }); + }); + + it('10.3 border shape getSelfRect also uses polygonSelfRect', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const borderShape = group.findOne('#poly-id-border') as Konva.Shape; + const rect = borderShape.getSelfRect(); + expect(rect.width).toBeGreaterThan(0); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 11 — scaleReset with non-unit scale + // ------------------------------------------------------------------------- + + describe('scaleReset — non-unit scale', () => { + it('11.1 rescales points proportionally and resets scaleX/scaleY to 1', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const pointsBefore = (group.getAttr('points') as WeavePolygonPoint[]).map( + (p) => ({ ...p }) + ); + + group.scaleX(2); + group.scaleY(3); + node.scaleReset(group); + + expect(group.scaleX()).toBe(1); + expect(group.scaleY()).toBe(1); + + const pointsAfter = group.getAttr('points') as WeavePolygonPoint[]; + expect(pointsAfter[1].x).toBeCloseTo(pointsBefore[1].x * 2); + expect(pointsAfter[1].y).toBeCloseTo(pointsBefore[1].y * 3); + }); + + it('11.2 rescales innerRect when present', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const innerBefore = group.getAttr('innerRect') as WeavePolygonInnerRect; + + group.scaleX(2); + group.scaleY(2); + node.scaleReset(group); + + const innerAfter = group.getAttr('innerRect') as WeavePolygonInnerRect; + expect(innerAfter.tl.x).toBeCloseTo(innerBefore.tl.x * 2); + expect(innerAfter.tl.y).toBeCloseTo(innerBefore.tl.y * 2); + expect(innerAfter.br.x).toBeCloseTo(innerBefore.br.x * 2); + expect(innerAfter.br.y).toBeCloseTo(innerBefore.br.y * 2); + }); + + it('11.3 handles missing innerRect without throwing', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + group.setAttr('innerRect', undefined); + + group.scaleX(2); + group.scaleY(2); + expect(() => node.scaleReset(group)).not.toThrow(); + expect(group.scaleX()).toBe(1); + }); + + it('11.4 updates width/height attrs after rescale', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const pointsBefore = (group.getAttr('points') as WeavePolygonPoint[]).map( + (p) => ({ ...p }) + ); + const maxXBefore = Math.max(...pointsBefore.map((p) => p.x)); + + group.scaleX(2); + group.scaleY(1); + node.scaleReset(group); + + expect(group.getAttr('width')).toBeCloseTo(maxXBefore * 2); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 12 — onUpdate: scalePolygonByDimensions + // ------------------------------------------------------------------------- + + describe('onUpdate — scalePolygonByDimensions', () => { + it('12.1 rescales points when width/height differ significantly from current bounds', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + const pointsBefore = (group.getAttr('points') as WeavePolygonPoint[]).map( + (p) => ({ ...p }) + ); + const maxX = Math.max(...pointsBefore.map((p) => p.x)); + const maxY = Math.max(...pointsBefore.map((p) => p.y)); + + node.onUpdate(group, defaultProps({ width: maxX * 2, height: maxY * 2 })); + + const pointsAfter = group.getAttr('points') as WeavePolygonPoint[]; + expect(pointsAfter[1].x).toBeCloseTo(pointsBefore[1].x * 2, 0); + }); + + it('12.2 skips rescaling when scale delta <= 0.001', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + const pointsBefore = (group.getAttr('points') as WeavePolygonPoint[]).map( + (p) => ({ ...p }) + ); + const maxX = Math.max(...pointsBefore.map((p) => p.x)); + const maxY = Math.max(...pointsBefore.map((p) => p.y)); + + node.onUpdate(group, defaultProps({ width: maxX, height: maxY })); + + const pointsAfter = group.getAttr('points') as WeavePolygonPoint[]; + expect(pointsAfter[0].x).toBeCloseTo(pointsBefore[0].x); + }); + + it('12.3 scales innerRect proportionally during dimension rescale', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + const innerBefore = group.getAttr('innerRect') as WeavePolygonInnerRect; + const pointsBefore = (group.getAttr('points') as WeavePolygonPoint[]).map( + (p) => ({ ...p }) + ); + const maxX = Math.max(...pointsBefore.map((p) => p.x)); + const maxY = Math.max(...pointsBefore.map((p) => p.y)); + + node.onUpdate(group, defaultProps({ width: maxX * 2, height: maxY * 2 })); + + const innerAfter = group.getAttr('innerRect') as WeavePolygonInnerRect; + expect(innerAfter.tl.x).toBeCloseTo(innerBefore.tl.x * 2, 0); + expect(innerAfter.tl.y).toBeCloseTo(innerBefore.tl.y * 2, 0); + }); + + it('12.4 skips innerRect scaling when innerRect is absent', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + group.setAttr('innerRect', undefined); + + const pointsBefore = (group.getAttr('points') as WeavePolygonPoint[]).map( + (p) => ({ ...p }) + ); + const maxX = Math.max(...pointsBefore.map((p) => p.x)); + const maxY = Math.max(...pointsBefore.map((p) => p.y)); + + expect(() => + node.onUpdate(group, defaultProps({ width: maxX * 2, height: maxY * 2 })) + ).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 13 — addNodeState: optional label properties + // ------------------------------------------------------------------------- + + describe('addNodeState — label properties', () => { + it('13.1 passes through all optional label and style fields', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { points, innerRect } = instantiatePreset( + preset, + preset.defaultWidth, + preset.defaultHeight + ); + + const result = WeavePolygonNode.addNodeState(base, { + x: 0, + y: 0, + sides: 5, + points, + innerRect, + fill: '#FF0000', + stroke: '#000000', + strokeWidth: 3, + labelText: 'Hello', + labelFontFamily: 'Arial', + labelFontSize: 16, + labelFontStyle: 'bold', + labelFontVariant: 'small-caps', + labelFill: '#FFFFFF', + labelAlign: 'center', + labelVerticalAlign: 'middle', + labelLetterSpacing: 2, + labelLineHeight: 1.5, + labelPaddingX: 12, + labelPaddingY: 8, + }); + + expect(result.props.stroke).toBe('#000000'); + expect(result.props.strokeWidth).toBe(3); + expect(result.props.labelText).toBe('Hello'); + expect(result.props.labelFontFamily).toBe('Arial'); + expect(result.props.labelFontSize).toBe(16); + expect(result.props.labelFontStyle).toBe('bold'); + expect(result.props.labelFontVariant).toBe('small-caps'); + expect(result.props.labelFill).toBe('#FFFFFF'); + expect(result.props.labelAlign).toBe('center'); + expect(result.props.labelVerticalAlign).toBe('middle'); + expect(result.props.labelLetterSpacing).toBe(2); + expect(result.props.labelLineHeight).toBe(1.5); + expect(result.props.labelPaddingX).toBe(12); + expect(result.props.labelPaddingY).toBe(8); + }); + + it('13.2 omits conditional label fields when not provided', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { points, innerRect } = instantiatePreset( + preset, + preset.defaultWidth, + preset.defaultHeight + ); + + const result = WeavePolygonNode.addNodeState(base, { + x: 0, + y: 0, + sides: 5, + points, + innerRect, + fill: '#FF0000', + }); + + // When optional fields are absent the base defaultState values are preserved + expect(result.props.strokeWidth).toBe(1); + expect(result.props.labelFontFamily).toBe('Arial, sans-serif'); + expect(result.props.labelFontSize).toBe(14); + expect(result.props.labelFontStyle).toBe('normal'); + }); + + it('13.3 does not set stroke when not provided or falsy', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { points, innerRect } = instantiatePreset( + preset, + preset.defaultWidth, + preset.defaultHeight + ); + + const result = WeavePolygonNode.addNodeState(base, { + x: 0, + y: 0, + sides: 5, + points, + innerRect, + fill: '#FF0000', + stroke: '', + }); + + expect(result.props.stroke).toBe('#000000'); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 14 — updateNodeState: optional label properties + // ------------------------------------------------------------------------- + + describe('updateNodeState — label properties', () => { + it('14.1 passes through all optional label and style fields', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { points, innerRect } = instantiatePreset( + preset, + preset.defaultWidth, + preset.defaultHeight + ); + + const result = WeavePolygonNode.updateNodeState(base, { + ...base.props, + x: 10, + y: 20, + sides: 5, + points, + innerRect, + fill: '#00FF00', + stroke: '#FF0000', + strokeWidth: 5, + labelText: 'Updated', + labelFontFamily: 'Verdana', + labelFontSize: 18, + labelFontStyle: 'italic', + labelFontVariant: 'normal', + labelFill: '#123456', + labelAlign: 'left', + labelVerticalAlign: 'top', + labelLetterSpacing: 1, + labelLineHeight: 1.2, + labelPaddingX: 6, + labelPaddingY: 4, + }); + + expect(result.props.x).toBe(10); + expect(result.props.stroke).toBe('#FF0000'); + expect(result.props.strokeWidth).toBe(5); + expect(result.props.labelText).toBe('Updated'); + expect(result.props.labelFontFamily).toBe('Verdana'); + expect(result.props.labelFontSize).toBe(18); + expect(result.props.labelFontStyle).toBe('italic'); + expect(result.props.labelFontVariant).toBe('normal'); + expect(result.props.labelFill).toBe('#123456'); + expect(result.props.labelAlign).toBe('left'); + expect(result.props.labelVerticalAlign).toBe('top'); + expect(result.props.labelLetterSpacing).toBe(1); + expect(result.props.labelLineHeight).toBe(1.2); + expect(result.props.labelPaddingX).toBe(6); + expect(result.props.labelPaddingY).toBe(4); + }); + + it('14.2 omits conditional fields when not provided', () => { + const base = WeavePolygonNode.defaultState('test-id'); + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { points, innerRect } = instantiatePreset( + preset, + preset.defaultWidth, + preset.defaultHeight + ); + + const result = WeavePolygonNode.updateNodeState(base, { + x: 0, + y: 0, + sides: 5, + points, + innerRect, + fill: '#FF0000', + }); + + // When optional fields are absent the base defaultState values are preserved + expect(result.props.strokeWidth).toBe(1); + expect(result.props.labelFontFamily).toBe('Arial, sans-serif'); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 15 — onUpdate: full bg/border setAttrs path + // ------------------------------------------------------------------------- + + describe('onUpdate — full bg/border update path', () => { + it('15.1 updates bg shape fill and border stroke in a single call', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + + node.onUpdate( + group, + defaultProps({ fill: '#ABCDEF', stroke: '#111111', strokeWidth: 6 }) + ); + + const bgShape = group.findOne('#poly-id-bg') as Konva.Shape; + const borderShape = group.findOne('#poly-id-border') as Konva.Shape; + + expect(bgShape.fill()).toBe('#ABCDEF'); + expect(borderShape.stroke()).toBe('#111111'); + expect(borderShape.getAttr('innerStrokeWidth')).toBe(6); + }); + + it('15.2 falls back to "transparent" when fill/stroke are absent', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockReturnValue(pluginMock); + const group = node.onRender(defaultProps()) as Konva.Group; + + node.onUpdate(group, defaultProps({ fill: undefined, stroke: undefined })); + + const bgShape = group.findOne('#poly-id-bg') as Konva.Shape; + const borderShape = group.findOne('#poly-id-border') as Konva.Shape; + + expect(bgShape.fill()).toBe('transparent'); + expect(borderShape.stroke()).toBe('transparent'); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 16 — allowedAnchors + // ------------------------------------------------------------------------- + + describe('allowedAnchors', () => { + it('16.1 returns all 8 anchor names', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anchors = (group as any).allowedAnchors(); + expect(anchors).toContain('top-left'); + expect(anchors).toContain('top-center'); + expect(anchors).toContain('top-right'); + expect(anchors).toContain('middle-right'); + expect(anchors).toContain('middle-left'); + expect(anchors).toContain('bottom-left'); + expect(anchors).toContain('bottom-center'); + expect(anchors).toContain('bottom-right'); + expect(anchors).toHaveLength(8); + }); + }); + + // ------------------------------------------------------------------------- + // Suite 17 — transform events and dblClick + // ------------------------------------------------------------------------- + + describe('transform events', () => { + it('17.1 transformstart sets _transforming to true', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + group.fire('transformstart'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((node as any)._transforming).toBe(true); + }); + + it('17.2 transformend sets _transforming to false', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + group.fire('transformstart'); + group.fire('transformend'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((node as any)._transforming).toBe(false); + }); + + it('17.3 transform event calls scaleReset and onUpdate without throwing', () => { + const pluginMock = makePluginMock(); + const { node, mock } = makeNode(); + mock.getPlugin.mockImplementation((key: string) => { + if (key === 'nodesSelection') return pluginMock; + return undefined; + }); + const group = node.onRender(defaultProps()) as Konva.Group; + expect(() => group.fire('transform')).not.toThrow(); + }); + + it('17.4 dblClick does nothing when not selecting', () => { + const { node, mock } = makeNode(); + mock.getRealSelectedNode.mockReturnValue(undefined); + const group = node.onRender(defaultProps()) as Konva.Group; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (group as any).dblClick()).not.toThrow(); + }); + + it('17.5 getNodeMinSize returns an object with width and height', () => { + const { node, mock } = makeNode(); + mock.getStage.mockReturnValue({ + findOne: vi.fn().mockReturnValue(null), + find: vi.fn().mockReturnValue([]), + container: vi.fn().mockReturnValue({ style: { cursor: '' } }), + scaleX: vi.fn().mockReturnValue(1), + scaleY: vi.fn().mockReturnValue(1), + getAbsoluteTransform: vi.fn().mockReturnValue({ + copy: vi.fn().mockReturnThis(), + invert: vi.fn().mockReturnThis(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + point: vi.fn().mockImplementation((p: any) => p), + }), + }); + const group = node.onRender(defaultProps()) as Konva.Group; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const minSize = (group as any).getNodeMinSize(); + expect(minSize).toHaveProperty('width'); + expect(minSize).toHaveProperty('height'); + }); + }); +}); diff --git a/code/packages/sdk/src/nodes/polygon/constants.ts b/code/packages/sdk/src/nodes/polygon/constants.ts new file mode 100644 index 000000000..de17c6442 --- /dev/null +++ b/code/packages/sdk/src/nodes/polygon/constants.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const WEAVE_POLYGON_NODE_TYPE = 'polygon'; diff --git a/code/packages/sdk/src/nodes/polygon/polygon.ts b/code/packages/sdk/src/nodes/polygon/polygon.ts new file mode 100644 index 000000000..8f6f5d587 --- /dev/null +++ b/code/packages/sdk/src/nodes/polygon/polygon.ts @@ -0,0 +1,836 @@ +// 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_POLYGON_NODE_TYPE } from './constants'; +import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; +import type { WeavePolygonNodeParams, WeavePolygonProperties, WeavePolygonPoint, WeavePolygonInnerRect } from './types'; +import { mergeExceptArrays } from '@/utils/utils'; +import { WeaveShapeLabelEditor } from '@/nodes/shared/shape-label-editor'; +import { + labelId, + WEAVE_SHAPE_LABEL_DEFAULTS, +} from '@/nodes/shared/shape-label.constants'; +import { computePolygonLabelMinSize } from '@/index.node'; +import { WEAVE_POLYGON_PRESETS, instantiatePreset } from './presets'; + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +function computePolygonBounds(points: WeavePolygonPoint[]): { + width: number; + height: number; +} { + if (!points.length) return { width: 0, height: 0 }; + const maxX = Math.max(...points.map((p) => p.x)); + const maxY = Math.max(...points.map((p) => p.y)); + return { width: Math.max(1, maxX), height: Math.max(1, maxY) }; +} + +function polygonSelfRect(this: Konva.Shape): { + x: number; + y: number; + width: number; + height: number; +} { + const pts = this.getAttr('points') as WeavePolygonPoint[] | undefined; + if (!pts?.length) return { x: 0, y: 0, width: 0, height: 0 }; + const minX = Math.min(...pts.map((p) => p.x)); + const minY = Math.min(...pts.map((p) => p.y)); + const maxX = Math.max(...pts.map((p) => p.x)); + const maxY = Math.max(...pts.map((p) => p.y)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function getPolygonLabelTextBounds( + innerRect: WeavePolygonInnerRect, + paddingX: number, + paddingY: number +) { + return { + x: innerRect.tl.x + paddingX, + y: innerRect.tl.y + paddingY, + width: Math.max(1, innerRect.tr.x - innerRect.tl.x - paddingX * 2), + height: Math.max(1, innerRect.bl.y - innerRect.tl.y - paddingY * 2), + }; +} + +function sceneFunc(context: Konva.Context, shape: Konva.Shape) { + const pts = shape.getAttr('points') as WeavePolygonPoint[] | undefined; + if (!pts || pts.length < 3) return; + context.beginPath(); + context.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) { + context.lineTo(pts[i].x, pts[i].y); + } + context.closePath(); + context.fillStrokeShape(shape); +} + +/** + * Draws an "inside" stroke for the border shape. + * Clips to the polygon interior then draws 2× the stroke width so the outer + * half is clipped away — resulting in a stroke of correct visual width that + * stays entirely inside the polygon boundary. + * strokeWidth is intentionally 0 on the shape (so getClientRect doesn't + * expand the bounding box); the real width is stored in `innerStrokeWidth`. + */ +function borderSceneFunc(context: Konva.Context, shape: Konva.Shape) { + const pts = shape.getAttr('points') as WeavePolygonPoint[] | undefined; + if (!pts || pts.length < 3) return; + + const sw = shape.getAttr('innerStrokeWidth') as number | undefined; + if (!sw) return; + + const stroke = shape.stroke() as string; + if (!stroke || stroke === 'transparent') return; + + const ctx = context._context; + + const drawPath = () => { + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) { + ctx.lineTo(pts[i].x, pts[i].y); + } + ctx.closePath(); + }; + + ctx.save(); + drawPath(); + ctx.clip(); + drawPath(); + ctx.lineWidth = sw * 2; + ctx.strokeStyle = stroke; + ctx.stroke(); + ctx.restore(); +} + +// --------------------------------------------------------------------------- +// WeavePolygonNode +// --------------------------------------------------------------------------- + +export class WeavePolygonNode extends WeaveNode { + private readonly config: WeavePolygonProperties; + protected nodeType: string = WEAVE_POLYGON_NODE_TYPE; + initialize = undefined; + private _shapeLabelEditor: WeaveShapeLabelEditor | undefined; + private _transforming = false; + + private get shapeLabelEditor(): WeaveShapeLabelEditor { + this._shapeLabelEditor ??= new WeaveShapeLabelEditor(this.instance); + return this._shapeLabelEditor; + } + + constructor(params?: WeavePolygonNodeParams) { + super(); + + const { config } = params ?? {}; + + this.config = { + transform: { ...config?.transform }, + }; + } + + private getLabelTextBounds(group: Konva.Group) { + const attrs = group.getAttrs() as WeaveElementAttributes; + const innerRect = attrs.innerRect as WeavePolygonInnerRect | undefined; + const paddingX = + (attrs.labelPaddingX as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; + const paddingY = + (attrs.labelPaddingY as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + + if (!innerRect) { + return { x: 0, y: 0, width: 1, height: 1 }; + } + + return getPolygonLabelTextBounds(innerRect, paddingX, paddingY); + } + + private scalePolygonByDimensions( + polygon: Konva.Group, + nextProps: WeaveElementAttributes, + nodeInstance: WeaveElementInstance + ): WeavePolygonPoint[] { + let points = polygon.getAttr('points') as WeavePolygonPoint[]; + const propsMaxX = points.length ? Math.max(...points.map((p) => p.x)) : 0; + const propsMaxY = points.length ? Math.max(...points.map((p) => p.y)) : 0; + const wantWidth = nextProps.width as number | undefined; + const wantHeight = nextProps.height as number | undefined; + + if (wantWidth === undefined || wantHeight === undefined) return points; + + const sX = propsMaxX > 0 ? wantWidth / propsMaxX : 1; + const sY = propsMaxY > 0 ? wantHeight / propsMaxY : 1; + if (Math.abs(sX - 1) <= 0.001 && Math.abs(sY - 1) <= 0.001) return points; + + const scaledPoints: WeavePolygonPoint[] = points.map((p) => ({ + x: p.x * sX, + y: p.y * sY, + })); + const prevInnerRect = polygon.getAttr('innerRect') as + | WeavePolygonInnerRect + | undefined; + if (prevInnerRect) { + const scaledInnerRect: WeavePolygonInnerRect = { + tl: { x: prevInnerRect.tl.x * sX, y: prevInnerRect.tl.y * sY }, + tr: { x: prevInnerRect.tr.x * sX, y: prevInnerRect.tr.y * sY }, + bl: { x: prevInnerRect.bl.x * sX, y: prevInnerRect.bl.y * sY }, + br: { x: prevInnerRect.br.x * sX, y: prevInnerRect.br.y * sY }, + }; + polygon.setAttr('innerRect', scaledInnerRect); + } + polygon.setAttr('points', scaledPoints); + points = scaledPoints; + + if (!this._transforming) { + this.instance.updateNode(this.serialize(nodeInstance)); + } + return points; + } + + private onLabelGrow( + polygon: Konva.Group, + bgShape: Konva.Shape | undefined, + borderShape: Konva.Shape | undefined, + nodeInstance: WeaveElementInstance, + neededHeight: number + ): void { + const livePoints = polygon.getAttr('points') as WeavePolygonPoint[]; + const liveInnerRect = polygon.getAttr('innerRect') as + | WeavePolygonInnerRect + | undefined; + if (!liveInnerRect) return; + + const currentBoundsHeight = liveInnerRect.bl.y - liveInnerRect.tl.y; + if (neededHeight <= currentBoundsHeight) return; + + const oldHeight = Math.max(...livePoints.map((p) => p.y)); + const scale = + currentBoundsHeight > 0 ? neededHeight / currentBoundsHeight : 1; + const newHeight = oldHeight * scale; + + const newPoints: WeavePolygonPoint[] = livePoints.map((p) => ({ + ...p, + y: p.y * scale, + })); + const newInnerRect: WeavePolygonInnerRect = { + tl: { ...liveInnerRect.tl, y: liveInnerRect.tl.y * scale }, + tr: { ...liveInnerRect.tr, y: liveInnerRect.tr.y * scale }, + bl: { ...liveInnerRect.bl, y: liveInnerRect.bl.y * scale }, + br: { ...liveInnerRect.br, y: liveInnerRect.br.y * scale }, + }; + + polygon.setAttr('points', newPoints); + polygon.setAttr('innerRect', newInnerRect); + polygon.setAttr('height', newHeight); + + bgShape?.setAttr('points', newPoints); + borderShape?.setAttr('points', newPoints); + + if (!this._transforming) { + this.instance.updateNode(this.serialize(nodeInstance)); + } + } + + private triggerPolygonLabelEdit( + polygon: Konva.Group, + props: WeaveElementAttributes + ): void { + const onCommit = (labelText: string) => { + const updatedGroup = this.instance + .getStage() + .findOne(`#${props.id}`); + if (!updatedGroup) return; + const serialized = this.serialize(updatedGroup); + serialized.props.labelText = labelText; + this.instance.updateNode(serialized); + }; + + const currentLabelTextBounds = this.getLabelTextBounds(polygon); + + this.shapeLabelEditor.triggerEditMode( + polygon, + currentLabelTextBounds, + onCommit, + (neededShapeHeight) => { + const liveAttrs = polygon.getAttrs() as WeaveElementAttributes; + const livePoints = liveAttrs.points as WeavePolygonPoint[]; + const liveInnerRect = liveAttrs.innerRect as WeavePolygonInnerRect; + const liveInnerRectHeight = liveInnerRect.bl.y - liveInnerRect.tl.y; + + if (neededShapeHeight <= liveInnerRectHeight) return; + + const oldHeight = Math.max(...livePoints.map((p) => p.y)); + const scale = + liveInnerRectHeight > 0 + ? neededShapeHeight / liveInnerRectHeight + : 1; + const newHeight = oldHeight * scale; + + const newPoints: WeavePolygonPoint[] = livePoints.map((p) => ({ + ...p, + y: p.y * scale, + })); + + const newInnerRect: WeavePolygonInnerRect = { + tl: { ...liveInnerRect.tl, y: liveInnerRect.tl.y * scale }, + tr: { ...liveInnerRect.tr, y: liveInnerRect.tr.y * scale }, + bl: { ...liveInnerRect.bl, y: liveInnerRect.bl.y * scale }, + br: { ...liveInnerRect.br, y: liveInnerRect.br.y * scale }, + }; + + polygon.setAttrs({ + points: newPoints, + innerRect: newInnerRect, + height: newHeight, + }); + this.onUpdate(polygon, polygon.getAttrs()); + + const newLabelTextBounds = this.getLabelTextBounds(polygon); + this.shapeLabelEditor.repositionTextArea(polygon, newLabelTextBounds); + } + ); + } + + scaleReset(group: Konva.Group): void { + const scaleX = group.scaleX(); + const scaleY = group.scaleY(); + + if (scaleX === 1 && scaleY === 1) return; + + const points = group.getAttr('points') as WeavePolygonPoint[]; + const innerRect = group.getAttr('innerRect') as + | WeavePolygonInnerRect + | undefined; + + const newPoints: WeavePolygonPoint[] = points.map((p) => ({ + x: p.x * scaleX, + y: p.y * scaleY, + })); + + const newInnerRect: WeavePolygonInnerRect | undefined = innerRect + ? { + tl: { x: innerRect.tl.x * scaleX, y: innerRect.tl.y * scaleY }, + tr: { x: innerRect.tr.x * scaleX, y: innerRect.tr.y * scaleY }, + bl: { x: innerRect.bl.x * scaleX, y: innerRect.bl.y * scaleY }, + br: { x: innerRect.br.x * scaleX, y: innerRect.br.y * scaleY }, + } + : undefined; + + const absTransform = group.getAbsoluteTransform().copy(); + + group.setAttr('points', newPoints); + if (newInnerRect) group.setAttr('innerRect', newInnerRect); + group.scaleX(1); + group.scaleY(1); + + // Keep width/height in sync with scaled vertices + group.setAttr('width', Math.max(...newPoints.map((p) => p.x))); + group.setAttr('height', Math.max(...newPoints.map((p) => p.y))); + + const newTransform = group.getAbsoluteTransform(); + const dx = absTransform.m[4] - newTransform.m[4]; + const dy = absTransform.m[5] - newTransform.m[5]; + + group.x(group.x() + dx); + group.y(group.y() + dy); + } + + onRender(props: WeaveElementAttributes): WeaveElementInstance { + const polygon = new Konva.Group({ + ...props, + name: 'node', + }); + + const points = polygon.getAttr('points') as WeavePolygonPoint[]; + const strokeWidth = (props.strokeWidth as number) || 0; + + const bgShape = new Konva.Shape({ + id: `${props.id}-bg`, + nodeId: props.id, + points, + ...computePolygonBounds(points), + fill: (props.fill as string) || 'transparent', + strokeWidth: 0, + strokeScaleEnabled: false, + sceneFunc, + }); + + bgShape.getSelfRect = polygonSelfRect.bind(bgShape); + + polygon.add(bgShape); + + const borderShape = new Konva.Shape({ + id: `${props.id}-border`, + points, + fill: 'transparent', + stroke: (props.stroke as string) || 'transparent', + strokeWidth: 0, + innerStrokeWidth: strokeWidth, + strokeScaleEnabled: false, + listening: false, + sceneFunc: borderSceneFunc, + }); + + borderShape.getSelfRect = polygonSelfRect.bind(borderShape); + + polygon.add(borderShape); + + const innerRect = polygon.getAttr('innerRect') as + | WeavePolygonInnerRect + | undefined; + const paddingX = + (props.labelPaddingX as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; + const paddingY = + (props.labelPaddingY as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + const labelTextBounds = innerRect + ? getPolygonLabelTextBounds(innerRect, paddingX, paddingY) + : { x: 0, y: 0, width: 1, height: 1 }; + + this.shapeLabelEditor.renderLabel(polygon, props, labelTextBounds); + + borderShape.moveToTop(); + bgShape.moveToBottom(); + + this.setupDefaultNodeAugmentation(polygon); + + const defaultTransformerProperties = this.defaultGetTransformerProperties( + this.config.transform + ); + + polygon.getTransformerProperties = function () { + return { + ...defaultTransformerProperties, + enabledAnchors: [ + 'top-left', + 'top-center', + 'top-right', + 'middle-right', + 'middle-left', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ], + keepRatio: false, + }; + }; + + polygon.allowedAnchors = function () { + return [ + 'top-left', + 'top-center', + 'top-right', + 'middle-right', + 'middle-left', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ]; + }; + + this.setupDefaultNodeEvents(polygon); + + polygon.on('transformstart', () => { + this._transforming = true; + }); + + polygon.on('transform', () => { + this.scaleReset(polygon); + this.onUpdate(polygon, polygon.getAttrs()); + }); + + polygon.on('transformend', () => { + this._transforming = false; + }); + + polygon.dblClick = () => { + if (this.shapeLabelEditor.isEditing()) return; + if (!(this.isSelecting() && this.isNodeSelected(polygon))) return; + this.triggerPolygonLabelEdit(polygon, props); + }; + + polygon.getNodeMinSize = () => { + return computePolygonLabelMinSize(this.instance.getStage(), polygon); + }; + + return polygon; + } + + onUpdate( + nodeInstance: WeaveElementInstance, + nextProps: WeaveElementAttributes + ): void { + nodeInstance.setAttrs({ ...nextProps }); + + const polygon = nodeInstance as Konva.Group; + const strokeWidth = (nextProps.strokeWidth as number) || 0; + + // ── Resize-by-dimensions ───────────────────────────────────────────────── + // If width/height are stored in props and differ from maxX/maxY of the + // current vertex set, rescale all vertices (and innerRect) to match. + // This allows external callers (properties panels, automation) to resize + // the polygon by simply setting width/height on the node props. + const points = this.scalePolygonByDimensions(polygon, nextProps, nodeInstance); + // ───────────────────────────────────────────────────────────────────────── + + const bgShape = polygon.findOne(`#${nextProps.id}-bg`); + if (bgShape) { + bgShape.setAttrs({ + points, + ...computePolygonBounds(points), + fill: nextProps.fill || 'transparent', + strokeWidth: 0, + strokeScaleEnabled: false, + }); + bgShape.moveToBottom(); + } + + const borderShape = polygon.findOne(`#${nextProps.id}-border`); + if (borderShape) { + borderShape.setAttrs({ + points, + fill: 'transparent', + stroke: nextProps.stroke || 'transparent', + strokeWidth: 0, + innerStrokeWidth: strokeWidth, + strokeScaleEnabled: false, + listening: false, + }); + } + + const paddingX = + (nextProps.labelPaddingX as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingX; + const paddingY = + (nextProps.labelPaddingY as number | undefined) ?? + WEAVE_SHAPE_LABEL_DEFAULTS.labelPaddingY; + const innerRect = polygon.getAttr('innerRect') as + | WeavePolygonInnerRect + | undefined; + const labelTextBounds = innerRect + ? getPolygonLabelTextBounds(innerRect, paddingX, paddingY) + : { x: 0, y: 0, width: 1, height: 1 }; + + this.shapeLabelEditor.updateLabel( + polygon, + nextProps, + labelTextBounds, + (neededHeight) => + this.onLabelGrow(polygon, bgShape, borderShape, nodeInstance, neededHeight) + ); + + const labelNode = polygon.findOne(`#${labelId(nextProps.id as string)}`); + if (labelNode) { + labelNode.moveToTop(); + borderShape?.moveToTop(); + } + + const nodesSelectionPlugin = + this.instance.getPlugin('nodesSelection'); + + if (nodesSelectionPlugin) { + nodesSelectionPlugin.getTransformer().forceUpdate(); + } + } + + realOffset(_element: WeaveStateElement): Konva.Vector2d { + return { x: 0, y: 0 }; + } + + static defaultState(nodeId: string): WeaveStateElement { + const preset = WEAVE_POLYGON_PRESETS.pentagon; + const { + points, + innerRect, + width, + height, + } = instantiatePreset(preset, preset.defaultWidth, preset.defaultHeight); + + return { + ...super.defaultState(nodeId), + type: WEAVE_POLYGON_NODE_TYPE, + props: { + ...super.defaultState(nodeId).props, + nodeType: WEAVE_POLYGON_NODE_TYPE, + x: 0, + y: 0, + width, + height, + sides: preset.sides, + points, + innerRect, + stroke: '#000000', + fill: '#FFFFFF', + strokeWidth: 1, + strokeScaleEnabled: false, + 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, + sides: props.sides, + points: props.points, + innerRect: props.innerRect, + rotation: props.rotation, + fill: props.fill, + ...(props.stroke && { stroke: props.stroke }), + ...(props.strokeWidth !== undefined && { + strokeWidth: props.strokeWidth, + }), + ...(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.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, + }), + }, + }); + } + + static updateNodeState( + prevNodeState: WeaveStateElement, + nextProps: WeaveElementAttributes + ): WeaveStateElement { + return mergeExceptArrays(prevNodeState, { + props: { + x: nextProps.x, + y: nextProps.y, + ...(nextProps.width !== undefined && { width: nextProps.width }), + ...(nextProps.height !== undefined && { height: nextProps.height }), + sides: nextProps.sides, + points: nextProps.points, + innerRect: nextProps.innerRect, + rotation: nextProps.rotation, + fill: nextProps.fill, + ...(nextProps.stroke && { stroke: nextProps.stroke }), + ...(nextProps.strokeWidth !== undefined && { + strokeWidth: nextProps.strokeWidth, + }), + ...(nextProps.labelText !== undefined && { + labelText: nextProps.labelText, + }), + ...(nextProps.labelFontFamily !== undefined && { + labelFontFamily: nextProps.labelFontFamily, + }), + ...(nextProps.labelFontSize !== undefined && { + labelFontSize: nextProps.labelFontSize, + }), + ...(nextProps.labelFontStyle !== undefined && { + labelFontStyle: nextProps.labelFontStyle, + }), + ...(nextProps.labelFontVariant !== undefined && { + labelFontVariant: nextProps.labelFontVariant, + }), + ...(nextProps.labelFill !== undefined && { + labelFill: nextProps.labelFill, + }), + ...(nextProps.labelAlign !== undefined && { + labelAlign: nextProps.labelAlign, + }), + ...(nextProps.labelVerticalAlign !== undefined && { + labelVerticalAlign: nextProps.labelVerticalAlign, + }), + ...(nextProps.labelLetterSpacing !== undefined && { + labelLetterSpacing: nextProps.labelLetterSpacing, + }), + ...(nextProps.labelLineHeight !== undefined && { + labelLineHeight: nextProps.labelLineHeight, + }), + ...(nextProps.labelPaddingX !== undefined && { + labelPaddingX: nextProps.labelPaddingX, + }), + ...(nextProps.labelPaddingY !== undefined && { + labelPaddingY: nextProps.labelPaddingY, + }), + }, + }); + } + + static getSchema() { + const baseSchema = super.getSchema(); + + const nodeSchema = baseSchema.extend({ + type: z + .literal(WEAVE_POLYGON_NODE_TYPE) + .describe( + `Type of the node, for a polygon node it will always be "${WEAVE_POLYGON_NODE_TYPE}"` + ), + props: baseSchema.shape.props.extend({ + nodeType: z + .literal(WEAVE_POLYGON_NODE_TYPE) + .describe( + `Type of the node, for a polygon node it will always be "${WEAVE_POLYGON_NODE_TYPE}"` + ), + + sides: z + .number() + .describe('Number of sides of the polygon (3 or more)'), + + width: z + .number() + .optional() + .describe( + 'Visual width of the polygon in pixels (= maxX of vertices). Setting this rescales vertices proportionally.' + ), + + height: z + .number() + .optional() + .describe( + 'Visual height of the polygon in pixels (= maxY of vertices). Setting this rescales vertices proportionally.' + ), + + points: z + .array(z.object({ x: z.number(), y: z.number() })) + .describe( + 'Vertex positions of the polygon in group-local pixel space' + ), + + innerRect: z + .object({ + tl: z.object({ x: z.number(), y: z.number() }), + tr: z.object({ x: z.number(), y: z.number() }), + bl: z.object({ x: z.number(), y: z.number() }), + br: z.object({ x: z.number(), y: z.number() }), + }) + .describe( + 'Largest inscribed axis-aligned rectangle inside the polygon (used for label bounds)' + ), + + fill: z + .string() + .describe( + 'Fill color of the polygon in hex format with alpha channel (e.g. #RRGGBBAA)' + ), + + stroke: z + .string() + .describe( + 'Stroke color of the polygon in hex format with alpha channel (e.g. #RRGGBBAA)' + ), + strokeWidth: z + .number() + .describe('Stroke width of the polygon in pixels'), + strokeScaleEnabled: z + .boolean() + .describe( + 'Whether the polygon stroke width should scale when the node is scaled. Defaults to false.' + ), + + labelText: z + .string() + .optional() + .describe('Text label displayed inside the polygon'), + labelFontFamily: z + .string() + .optional() + .describe('Font family for the label text'), + labelFontSize: z + .number() + .optional() + .describe('Font size for the label text in pixels'), + labelFontStyle: z + .string() + .optional() + .describe( + 'Font style for the label text (e.g. "normal", "bold", "italic", "bold italic")' + ), + labelFontVariant: z + .string() + .optional() + .describe( + 'Font variant for the label text (e.g. "normal", "small-caps")' + ), + labelFill: z + .string() + .optional() + .describe('Color of the label text in hex format (e.g. #RRGGBBAA)'), + labelAlign: z + .string() + .optional() + .describe( + 'Horizontal alignment of the label text ("left", "center", "right")' + ), + labelVerticalAlign: z + .string() + .optional() + .describe( + 'Vertical alignment of the label text ("top", "middle", "bottom")' + ), + labelLetterSpacing: z + .number() + .optional() + .describe('Letter spacing for the label text in pixels'), + labelLineHeight: z + .number() + .optional() + .describe('Line height multiplier for the label text'), + labelPaddingX: z + .number() + .optional() + .describe( + 'Horizontal inset (padding) in pixels applied on each side of the label' + ), + labelPaddingY: z + .number() + .optional() + .describe( + 'Vertical inset (padding) in pixels applied on top and bottom of the label' + ), + }), + }); + + return nodeSchema; + } +} diff --git a/code/packages/sdk/src/nodes/polygon/presets.ts b/code/packages/sdk/src/nodes/polygon/presets.ts new file mode 100644 index 000000000..093e60d46 --- /dev/null +++ b/code/packages/sdk/src/nodes/polygon/presets.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type { WeavePolygonPoint, WeavePolygonInnerRect } from './types'; + +type NormalizedPoint = { x: number; y: number }; + +type NormalizedInnerRect = { + tl: NormalizedPoint; + tr: NormalizedPoint; + bl: NormalizedPoint; + br: NormalizedPoint; +}; + +export type WeavePolygonPresetDef = { + label: string; + sides: number; + defaultWidth: number; + defaultHeight: number; + /** Vertices in [0,0,1,1] circumscribed unit space. */ + normalizedPoints: NormalizedPoint[]; + /** Largest inscribed axis-aligned rect in [0,0,1,1] space. */ + normalizedInnerRect: NormalizedInnerRect; +}; + +export const WEAVE_POLYGON_PRESETS: Record = { + triangle: { + label: 'Triangle', + sides: 3, + defaultWidth: 100, + defaultHeight: 100, + normalizedPoints: [ + { x: 0.5, y: 0 }, + { x: 0.933013, y: 0.75 }, + { x: 0.066987, y: 0.75 }, + ], + normalizedInnerRect: { + tl: { x: 0.283494, y: 0.375 }, + tr: { x: 0.716506, y: 0.375 }, + bl: { x: 0.283494, y: 0.75 }, + br: { x: 0.716506, y: 0.75 }, + }, + }, + diamond: { + label: 'Diamond', + sides: 4, + defaultWidth: 100, + defaultHeight: 100, + normalizedPoints: [ + { x: 0.5, y: 0 }, + { x: 1, y: 0.5 }, + { x: 0.5, y: 1 }, + { x: 0, y: 0.5 }, + ], + normalizedInnerRect: { + tl: { x: 0.25, y: 0.25 }, + tr: { x: 0.75, y: 0.25 }, + bl: { x: 0.25, y: 0.75 }, + br: { x: 0.75, y: 0.75 }, + }, + }, + pentagon: { + label: 'Pentagon', + sides: 5, + defaultWidth: 100, + defaultHeight: 100, + normalizedPoints: [ + { x: 0.5, y: 0 }, + { x: 0.975528, y: 0.345492 }, + { x: 0.793893, y: 0.904508 }, + { x: 0.206107, y: 0.904508 }, + { x: 0.024472, y: 0.345492 }, + ], + normalizedInnerRect: { + tl: { x: 0.132634, y: 0.316578 }, + tr: { x: 0.867366, y: 0.316578 }, + bl: { x: 0.132634, y: 0.678381 }, + br: { x: 0.867366, y: 0.678381 }, + }, + }, + hexagon: { + label: 'Hexagon', + sides: 6, + defaultWidth: 100, + defaultHeight: 100, + normalizedPoints: [ + { x: 0.5, y: 0 }, + { x: 0.933013, y: 0.25 }, + { x: 0.933013, y: 0.75 }, + { x: 0.5, y: 1 }, + { x: 0.066987, y: 0.75 }, + { x: 0.066987, y: 0.25 }, + ], + normalizedInnerRect: { + tl: { x: 0.066987, y: 0.25 }, + tr: { x: 0.933013, y: 0.25 }, + bl: { x: 0.066987, y: 0.75 }, + br: { x: 0.933013, y: 0.75 }, + }, + }, + octagon: { + label: 'Octagon', + sides: 8, + defaultWidth: 100, + defaultHeight: 100, + normalizedPoints: [ + { x: 0.5, y: 0 }, + { x: 0.853553, y: 0.146447 }, + { x: 1, y: 0.5 }, + { x: 0.853553, y: 0.853553 }, + { x: 0.5, y: 1 }, + { x: 0.146447, y: 0.853553 }, + { x: 0, y: 0.5 }, + { x: 0.146447, y: 0.146447 }, + ], + normalizedInnerRect: { + tl: { x: 0.25, y: 0.25 }, + tr: { x: 0.75, y: 0.25 }, + bl: { x: 0.25, y: 0.75 }, + br: { x: 0.75, y: 0.75 }, + }, + }, + decagon: { + label: 'Decagon', + sides: 10, + defaultWidth: 100, + defaultHeight: 100, + normalizedPoints: [ + { x: 0.5, y: 0 }, + { x: 0.793893, y: 0.095492 }, + { x: 0.975528, y: 0.345492 }, + { x: 0.975528, y: 0.654508 }, + { x: 0.793893, y: 0.904508 }, + { x: 0.5, y: 1 }, + { x: 0.206107, y: 0.904508 }, + { x: 0.024472, y: 0.654508 }, + { x: 0.024472, y: 0.345492 }, + { x: 0.206107, y: 0.095492 }, + ], + normalizedInnerRect: { + tl: { x: 0.093851, y: 0.35 }, + tr: { x: 0.906149, y: 0.35 }, + bl: { x: 0.093851, y: 0.75 }, + br: { x: 0.906149, y: 0.75 }, + }, + }, +} as const satisfies Record; + +export type WeavePolygonPreset = keyof typeof WEAVE_POLYGON_PRESETS; + +/** + * Scales a preset's normalized points and inner rect to actual pixel dimensions. + * + * Points are normalized so that minX = 0 and minY = 0 in the resulting pixel + * space. This ensures the polygon group's position corresponds exactly to the + * visual top-left of the polygon, which is required for the snapping system to + * work correctly (it assumes nodeBox.x === node.x()). + */ +export function instantiatePreset( + def: WeavePolygonPresetDef, + width: number, + height: number +): { + points: WeavePolygonPoint[]; + innerRect: WeavePolygonInnerRect; + width: number; + height: number; +} { + const rawPoints = def.normalizedPoints.map((p) => ({ + x: p.x * width, + y: p.y * height, + })); + + const minX = Math.min(...rawPoints.map((p) => p.x)); + const minY = Math.min(...rawPoints.map((p) => p.y)); + + const points: WeavePolygonPoint[] = rawPoints.map((p) => ({ + x: p.x - minX, + y: p.y - minY, + })); + + const ir = def.normalizedInnerRect; + const innerRect: WeavePolygonInnerRect = { + tl: { x: ir.tl.x * width - minX, y: ir.tl.y * height - minY }, + tr: { x: ir.tr.x * width - minX, y: ir.tr.y * height - minY }, + bl: { x: ir.bl.x * width - minX, y: ir.bl.y * height - minY }, + br: { x: ir.br.x * width - minX, y: ir.br.y * height - minY }, + }; + + const visualWidth = Math.max(...points.map((p) => p.x)); + const visualHeight = Math.max(...points.map((p) => p.y)); + + return { points, innerRect, width: visualWidth, height: visualHeight }; +} diff --git a/code/packages/sdk/src/nodes/polygon/types.ts b/code/packages/sdk/src/nodes/polygon/types.ts new file mode 100644 index 000000000..2445ca487 --- /dev/null +++ b/code/packages/sdk/src/nodes/polygon/types.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import type { WeaveNodeTransformerProperties } from '@inditextech/weave-types'; + +export type { WeaveShapeLabelProps } from '@/nodes/shared/shape-label.types'; + +export type WeavePolygonPoint = { x: number; y: number }; + +export type WeavePolygonInnerRect = { + tl: WeavePolygonPoint; + tr: WeavePolygonPoint; + bl: WeavePolygonPoint; + br: WeavePolygonPoint; +}; + +export type WeavePolygonProperties = { + transform: WeaveNodeTransformerProperties; +}; + +export type WeavePolygonNodeParams = { + config: Partial; +}; + diff --git a/docs/content/docs/main/build/actions/index.mdx b/docs/content/docs/main/build/actions/index.mdx index d63e1d8cb..1444cad94 100644 --- a/docs/content/docs/main/build/actions/index.mdx +++ b/docs/content/docs/main/build/actions/index.mdx @@ -168,9 +168,9 @@ Enables users to create new rectangle nodes on the canvas. - + -Enables users to create new regular polygons nodes on the canvas. +Enables users to place a polygon node on the canvas using a preset shape (triangle, diamond, pentagon, hexagon, octagon or decagon). @@ -222,6 +222,12 @@ Enables users to create continuos lines on the canvas with N segments on the can + + +Enables users to create new regular polygons nodes on the canvas. + + + ### Grouping Tools diff --git a/docs/content/docs/main/build/actions/meta.json b/docs/content/docs/main/build/actions/meta.json index 45a2eb277..86aac39de 100644 --- a/docs/content/docs/main/build/actions/meta.json +++ b/docs/content/docs/main/build/actions/meta.json @@ -15,8 +15,8 @@ "images-tool", "measure-tool", "move-tool", + "polygon-tool", "rectangle-tool", - "regular-polygon-tool", "selection-tool", "star-tool", "stroke-tool", @@ -26,7 +26,8 @@ "zoom-out-tool", "arrow-tool", "line-tool", - "pen-tool" + "pen-tool", + "regular-polygon-tool" ], "title": "Actions" -} \ No newline at end of file +} diff --git a/docs/content/docs/main/build/actions/polygon-tool.mdx b/docs/content/docs/main/build/actions/polygon-tool.mdx new file mode 100644 index 000000000..1740d1db3 --- /dev/null +++ b/docs/content/docs/main/build/actions/polygon-tool.mdx @@ -0,0 +1,115 @@ +--- +title: Polygon Tool +description: Add a polygon to the canvas using a preset shape +--- + +## Introduction + +This action enables users to create new polygon nodes on the canvas by clicking or touching it. +The shape drawn is determined by a **preset** — one of the six built-in polygon shapes. + +## Dependencies + +This action needs registered on the Weave instance the following element: + +- [Polygon](/docs/main/build/nodes/polygon) node + +## Usage + +
+ +
+ +### Import the Action + +Start by importing the action: + +```ts +import { WeavePolygonToolAction } from "@inditextech/weave-sdk"; +``` + +
+ +
+ +### Register the Action + +Then register the action on the [Weave](/docs/sdk/api-reference/weave) class instance. + +```ts +const instance = new Weave({ + ... + actions: [ + ..., + new WeavePolygonToolAction(), // [!code ++] + ] +}) +``` + +You can pass an optional preset ID to the constructor to set the default preset: + +```ts +new WeavePolygonToolAction("hexagon") +``` + +Available preset IDs: `triangle`, `diamond`, `pentagon` (default), `hexagon`, `octagon`, `decagon`. + +
+ +
+ +### Setup the action trigger + +Setup on a button or any element on the UI the user can interact with on the action event: + +```ts +instance.triggerAction("polygonTool"); +``` + +You can also pass a `presetId` at trigger time to override the constructor default: + +```ts +instance.triggerAction("polygonTool", { presetId: "hexagon" }); +``` + +--- + +For example on a button on React: + +```tsx +import React from "react"; +import { useWeave } from "@inditextech/weave-react"; + +const MyPolygonToolTriggerComponent = () => { + const instance = useWeave((state) => state.instance); + + const triggerTool = React.useCallback(() => { + instance.triggerAction("polygonTool", { presetId: "hexagon" }); + }, [instance]); + + return ; +}; +``` + +
+ +
+ +### Trigger the action + +Finally a user triggers the UI element that launches the action. + +--- + +When active the user can: + +- **Click** on the canvas, it will place the selected preset polygon at the cursor position with its default size. +- **Touch** on the canvas, it will place the selected preset polygon at the touch position with its default size. + +Once placed, the polygon is added to the canvas as a fully functional node. This action integrates +seamlessly with Weave.js's real-time state system, ensuring the new element appears instantly for +all connected users. + +
+ +
diff --git a/docs/content/docs/main/build/actions/regular-polygon-tool.mdx b/docs/content/docs/main/build/actions/regular-polygon-tool.mdx index 233603cd3..abbbfac10 100644 --- a/docs/content/docs/main/build/actions/regular-polygon-tool.mdx +++ b/docs/content/docs/main/build/actions/regular-polygon-tool.mdx @@ -1,8 +1,12 @@ --- -title: Regular Polygon Tool +title: Regular Polygon Tool (deprecated) description: Add a regular polygon to the canvas --- + + This action is deprecated. Use the [Polygon Tool](/docs/main/build/actions/polygon-tool) action instead. + + ![Regular Polygon Tool action on use on the Weave.js showcase](/images/actions/regular-polygon-tool.gif) ## Introduction diff --git a/docs/content/docs/main/build/nodes/index.mdx b/docs/content/docs/main/build/nodes/index.mdx index c3cd5e9bc..911b38106 100644 --- a/docs/content/docs/main/build/nodes/index.mdx +++ b/docs/content/docs/main/build/nodes/index.mdx @@ -117,11 +117,11 @@ stroke, corner radius and more. - + -The Regular Polygon node is a simple, versatile primitive shape used to render regular polygons -elements on the canvas. It supports customizable properties such as position, radius, sides, -fill color, stroke and more. +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. @@ -183,6 +183,14 @@ smoothing options. + + +The Regular Polygon node is a simple, versatile primitive shape used to render regular polygons +elements on the canvas. It supports customizable properties such as position, radius, sides, +fill color, stroke and more. + + + ### Support diff --git a/docs/content/docs/main/build/nodes/meta.json b/docs/content/docs/main/build/nodes/meta.json index 921b115e6..7907b789f 100644 --- a/docs/content/docs/main/build/nodes/meta.json +++ b/docs/content/docs/main/build/nodes/meta.json @@ -9,8 +9,8 @@ "image", "layer", "measure", + "polygon", "rectangle", - "regular-polygon", "stage", "star", "stroke", @@ -18,6 +18,7 @@ "text", "video", "arrow", - "line" + "line", + "regular-polygon" ] } diff --git a/docs/content/docs/main/build/nodes/polygon.mdx b/docs/content/docs/main/build/nodes/polygon.mdx new file mode 100644 index 000000000..bd4b34207 --- /dev/null +++ b/docs/content/docs/main/build/nodes/polygon.mdx @@ -0,0 +1,76 @@ +--- +title: Polygon +description: Draw a polygon on the canvas using a preset shape +--- + +The Polygon node is a versatile primitive shape used to render polygon elements on the canvas. +Unlike the [Regular Polygon](/docs/main/build/nodes/regular-polygon) node, it is based on a +set of explicit vertex points and an inner bounding rectangle, enabling arbitrary convex +polygons to be rendered with a consistent, inside-stroke border. + +It supports customizable properties such as position, fill color, stroke, and an optional +inline text label. + +They can also respond to interactions like dragging, resizing, or selection. + +## Presets + +The polygon node ships with six built-in presets, each defining the normalized vertex positions +and the largest inscribed axis-aligned rectangle (used for label placement): + +| Preset ID | Label | Sides | +|-----------|-------|-------| +| `triangle` | Triangle | 3 | +| `diamond` | Diamond | 4 | +| `pentagon` | Pentagon | 5 | +| `hexagon` | Hexagon | 6 | +| `octagon` | Octagon | 8 | +| `decagon` | Decagon | 10 | + +## Usage + +
+ +
+ +### Import the Node + +Start by importing the node: + +```ts +import { WeavePolygonNode } from "@inditextech/weave-sdk"; +``` + +
+ +
+ +### Register the Node + +Then register the node on the [Weave](/docs/sdk/api-reference/weave) class instance. + +```ts +const instance = new Weave({ + ... + nodes: [ + ..., + new WeavePolygonNode(), // [!code ++] + ] +}) +``` + +
+ +
+ +### Use the node + +Once the node is registered you can use it on [Actions](/docs/main/build/actions) or +[Plugins](/docs/main/build/plugins) or even other [Nodes](/docs/main/build/nodes). + +We provide an action named [Polygon Tool](/docs/main/build/actions/polygon-tool) that +allows users to add Polygon nodes to the application. + +
+ +
diff --git a/docs/content/docs/main/build/nodes/regular-polygon.mdx b/docs/content/docs/main/build/nodes/regular-polygon.mdx index 154e77b55..a4a4a4dec 100644 --- a/docs/content/docs/main/build/nodes/regular-polygon.mdx +++ b/docs/content/docs/main/build/nodes/regular-polygon.mdx @@ -1,8 +1,12 @@ --- -title: Regular Polygon +title: Regular Polygon (deprecated) description: Draw a regular polygon on the canvas --- + + This node is deprecated. Use the [Polygon](/docs/main/build/nodes/polygon) node instead. + + ![Regular Polygon on Weave.js showcase](/images/nodes/regular-polygon.png) The Regular Polygon node is a simple, versatile primitive shape used to render star diff --git a/docs/content/docs/main/changelog/5.x/5.0.0.mdx b/docs/content/docs/main/changelog/5.x/5.0.0.mdx index 7635e632d..92f773966 100644 --- a/docs/content/docs/main/changelog/5.x/5.0.0.mdx +++ b/docs/content/docs/main/changelog/5.x/5.0.0.mdx @@ -1,6 +1,6 @@ --- title: v5.0.0 -description: Rectangle and Ellipse internal labels, Store lifecycle changes, removed use of @syncedstore/core and new alpha API to manipulate document state from server-side on a low-level mode and minor fixes to stroke node and brush tool to improve usability +description: Rectangle and Ellipse internal labels, New Polygon Node, Store lifecycle changes, removed use of @syncedstore/core and new alpha API to manipulate document state from server-side on a low-level mode and minor fixes to stroke node and brush tool to improve usability --- ## Metadata @@ -11,6 +11,7 @@ description: Rectangle and Ellipse internal labels, Store lifecycle changes, rem - [#1080](https://github.com/InditexTech/weavejs/issues/1080) [alpha] API to manipulate document state natively on server-side - [#1101](https://github.com/InditexTech/weavejs/issues/1101) Add inline text labels to shape nodes (Rectangle and Ellipse) +- [#1104](https://github.com/InditexTech/weavejs/issues/1104) Add Polygon Node and Polygon Drawing Tool with Label Support ### Changed diff --git a/docs/content/docs/sdk/api-reference/actions/index.mdx b/docs/content/docs/sdk/api-reference/actions/index.mdx index 0a1cf8f53..4133014dc 100644 --- a/docs/content/docs/sdk/api-reference/actions/index.mdx +++ b/docs/content/docs/sdk/api-reference/actions/index.mdx @@ -125,9 +125,9 @@ application, or drag & drop external files. Enables users to create new rectangle nodes on the canvas. - + -Enables users to create new regular polygons nodes on the canvas. +Enables users to place a polygon node on the canvas using a preset shape (triangle, diamond, pentagon, hexagon, octagon or decagon). @@ -179,6 +179,12 @@ Enables users to create continuos lines on the canvas with N segments on the can + + +Enables users to create new regular polygons nodes on the canvas. + + + ### Support Tools diff --git a/docs/content/docs/sdk/api-reference/actions/meta.json b/docs/content/docs/sdk/api-reference/actions/meta.json index 88b4cb9cc..b6caaca31 100644 --- a/docs/content/docs/sdk/api-reference/actions/meta.json +++ b/docs/content/docs/sdk/api-reference/actions/meta.json @@ -16,8 +16,8 @@ "images-tool", "measure-tool", "move-tool", + "polygon-tool", "rectangle-tool", - "regular-polygon-tool", "selection-tool", "star-tool", "stroke-tool", @@ -27,7 +27,8 @@ "zoom-out-tool", "arrow-tool", "line-tool", - "pen-tool" + "pen-tool", + "regular-polygon-tool" ], "title": "Actions" -} \ No newline at end of file +} diff --git a/docs/content/docs/sdk/api-reference/actions/polygon-tool.mdx b/docs/content/docs/sdk/api-reference/actions/polygon-tool.mdx new file mode 100644 index 000000000..9850d0e08 --- /dev/null +++ b/docs/content/docs/sdk/api-reference/actions/polygon-tool.mdx @@ -0,0 +1,106 @@ +--- +title: WeavePolygonToolAction +description: Add a polygon to the canvas using a preset shape +--- + +import { TypeTable } from "fumadocs-ui/components/type-table"; + +## Overview + +The [WeavePolygonToolAction](https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/actions/polygon-tool/polygon-tool.ts) +class allows users to place a polygon node on the canvas by clicking or touching it. + +The shape is selected via a **preset** — one of the six built-in polygon definitions shipped +with the SDK. The preset can be set at construction time, changed programmatically, or +overridden per-trigger via the `presetId` trigger parameter. + +Each interaction results in the creation of a [WeavePolygonNode](/docs/sdk/api-reference/nodes/polygon), +added to the shared state and synchronized across all users. + +The class extends the [WeaveAction](/docs/sdk/api-reference/actions/action) class. + +## Name + +This action `name` property value is `polygonTool`. + +## Import + +```shell +import { WeavePolygonToolAction } from "@inditextech/weave-sdk"; +``` + +## Instantiation + +```ts +new WeavePolygonToolAction(preset?: string); +``` + +### Parameters + + + +## Trigger parameters + +```ts +type WeavePolygonToolActionTriggerParams = { + presetId?: string; +}; +``` + + + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `onAddingPolygon` | `undefined` | Fired when the action enters the adding state (user is about to place the polygon). | +| `onAddedPolygon` | `undefined` | Fired after the polygon node has been placed on the canvas. | + +## Methods + +### getPolygonsPresets + +```ts +getPolygonsPresets(): Record +``` + +Returns all available polygon presets. The built-in presets are: +`triangle`, `diamond`, `pentagon`, `hexagon`, `octagon`, `decagon`. + +--- + +### getPolygonPreset + +```ts +getPolygonPreset(): string +``` + +Returns the currently active preset ID. + +--- + +### setPolygonPreset + +```ts +setPolygonPreset(preset: string): void +``` + +Sets the active preset ID. Takes effect on the next `trigger` call (unless `presetId` is +supplied via trigger params, which takes precedence). diff --git a/docs/content/docs/sdk/api-reference/actions/regular-polygon-tool.mdx b/docs/content/docs/sdk/api-reference/actions/regular-polygon-tool.mdx index d8768f280..57ebaca79 100644 --- a/docs/content/docs/sdk/api-reference/actions/regular-polygon-tool.mdx +++ b/docs/content/docs/sdk/api-reference/actions/regular-polygon-tool.mdx @@ -1,10 +1,14 @@ --- -title: WeaveRegularPolygonToolAction +title: WeaveRegularPolygonToolAction (deprecated) description: Add a regular polygon to the canvas --- import { TypeTable } from "fumadocs-ui/components/type-table"; + + This action is deprecated. Use the [WeavePolygonToolAction](/docs/sdk/api-reference/actions/polygon-tool) instead. + + ## Overview The [WeaveRegularPolygonToolAction](https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/actions/regular-polygon-tool/regular-polygon-tool.ts) diff --git a/docs/content/docs/sdk/api-reference/nodes/index.mdx b/docs/content/docs/sdk/api-reference/nodes/index.mdx index 7d33846d9..5af01f2a0 100644 --- a/docs/content/docs/sdk/api-reference/nodes/index.mdx +++ b/docs/content/docs/sdk/api-reference/nodes/index.mdx @@ -127,11 +127,10 @@ stroke, corner radius and more. - + -The WeaveRegularPolygonNode node is a simple, versatile primitive shape used to render regular polygons -elements on the canvas. It supports customizable properties such as position, radius, sides, fill color, -stroke and more. +The WeavePolygonNode node renders a polygon shape on the canvas using explicit vertex points and a +preset definition. It supports fill color, stroke, and an optional inline text label. @@ -193,6 +192,14 @@ smoothing options. + + +The WeaveRegularPolygonNode node is a simple, versatile primitive shape used to render regular polygons +elements on the canvas. It supports customizable properties such as position, radius, sides, fill color, +stroke and more. + + + ### Support diff --git a/docs/content/docs/sdk/api-reference/nodes/meta.json b/docs/content/docs/sdk/api-reference/nodes/meta.json index bc7bd8d47..e37ff041e 100644 --- a/docs/content/docs/sdk/api-reference/nodes/meta.json +++ b/docs/content/docs/sdk/api-reference/nodes/meta.json @@ -10,8 +10,8 @@ "node", "layer", "measure", + "polygon", "rectangle", - "regular-polygon", "stage", "star", "stroke", @@ -19,6 +19,7 @@ "text", "video", "arrow", - "line" + "line", + "regular-polygon" ] } diff --git a/docs/content/docs/sdk/api-reference/nodes/polygon.mdx b/docs/content/docs/sdk/api-reference/nodes/polygon.mdx new file mode 100644 index 000000000..423438654 --- /dev/null +++ b/docs/content/docs/sdk/api-reference/nodes/polygon.mdx @@ -0,0 +1,128 @@ +--- +title: WeavePolygonNode +description: Polygon node API Reference +--- + +import { TypeTable } from "fumadocs-ui/components/type-table"; + +## Overview + +The [WeavePolygonNode](https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/nodes/polygon/polygon.ts) +class represents a polygon shape within the Weave.js canvas. + +Unlike the `WeaveRegularPolygonNode`, which uses Konva's built-in `RegularPolygon` primitive, +`WeavePolygonNode` is drawn via explicit vertex points (`points: WeavePolygonPoint[]`). This enables +arbitrary convex polygon shapes and precise inside-stroke rendering that never exceeds the shape boundary. + +The node also supports an inline text label whose bounding box is derived from the polygon's +`innerRect` — the largest axis-aligned rectangle that fits inside the shape. + +The class extends the [WeaveNode](/docs/sdk/api-reference/nodes/node) class. + +## Type + +This node `nodeType` property value is `polygon`. + +## Import + +```shell +import { WeavePolygonNode } from "@inditextech/weave-sdk"; +``` + +## Instantiation + +```ts +new WeavePolygonNode(params?: WeavePolygonNodeParams); +``` + +## TypeScript types + +```ts +type WeavePolygonPoint = { x: number; y: number }; + +type WeavePolygonInnerRect = { + tl: WeavePolygonPoint; + tr: WeavePolygonPoint; + bl: WeavePolygonPoint; + br: WeavePolygonPoint; +}; + +type WeaveNodeTransformerProperties = Konva.TransformerConfig; + +type WeavePolygonProperties = { + transform: WeaveNodeTransformerProperties; +}; + +type WeavePolygonNodeParams = { + config: Partial; +}; +``` + +## Parameters + +For `WeavePolygonNodeParams`: + +", + }, + }} +/> + +For `WeavePolygonProperties`: + + + +## Presets + +The polygon node ships with six built-in presets available via the exported constant +`WEAVE_POLYGON_PRESETS`. Each preset defines normalized vertex positions and an inner +bounding rectangle used for label placement. + +| Preset ID | Label | Sides | +|-----------|-------|-------| +| `triangle` | Triangle | 3 | +| `diamond` | Diamond | 4 | +| `pentagon` | Pentagon | 5 | +| `hexagon` | Hexagon | 6 | +| `octagon` | Octagon | 8 | +| `decagon` | Decagon | 10 | + +## Default values + +```ts +const WEAVE_TRANSFORMER_ANCHORS = { + ["TOP_LEFT"]: "top-left", + ["TOP_CENTER"]: "top-center", + ["TOP_RIGHT"]: "top-right", + ["MIDDLE_RIGHT"]: "middle-right", + ["MIDDLE_LEFT"]: "middle-left", + ["BOTTOM_LEFT"]: "bottom-left", + ["BOTTOM_CENTER"]: "bottom-center", + ["BOTTOM_RIGHT"]: "bottom-right", +}; + +const WEAVE_DEFAULT_ENABLED_ANCHORS: string[] = Object.values( + WEAVE_TRANSFORMER_ANCHORS +); + +const WEAVE_DEFAULT_TRANSFORM_PROPERTIES: WeaveNodeTransformerProperties = { + rotateEnabled: true, + resizeEnabled: true, + enabledAnchors: WEAVE_DEFAULT_ENABLED_ANCHORS, + borderStrokeWidth: 3, + padding: 0, +}; +``` diff --git a/docs/content/docs/sdk/api-reference/nodes/regular-polygon.mdx b/docs/content/docs/sdk/api-reference/nodes/regular-polygon.mdx index 8e45eb952..cb189f68f 100644 --- a/docs/content/docs/sdk/api-reference/nodes/regular-polygon.mdx +++ b/docs/content/docs/sdk/api-reference/nodes/regular-polygon.mdx @@ -1,10 +1,14 @@ --- -title: WeaveRegularPolygonNode +title: WeaveRegularPolygonNode (deprecated) description: Ellipse node API Reference --- import { TypeTable } from "fumadocs-ui/components/type-table"; + + This node is deprecated. Use the [WeavePolygonNode](/docs/sdk/api-reference/nodes/polygon) instead. + + ## Overview The [WeaveRegularPolygonNode](https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/nodes/regular-polygon/regular-polygon.ts)