diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..d62e63276 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,202 @@ + + +# Copilot Instructions for Weave.js + +## Project Overview + +Weave.js is a collaborative whiteboard/canvas SDK built on [Konva](https://konvajs.org/) (2D canvas rendering) and [Yjs](https://yjs.dev/) (CRDT-based real-time sync). It is a monorepo managed with [Nx](https://nx.dev/) and npm workspaces. All source code lives under `code/`. + +## Build, Test, and Lint Commands + +All commands run from the `code/` directory. + +```bash +# Install dependencies +npm install + +# Build all packages +npm run build + +# Build a single package +npm run build --workspace=@inditextech/weave-sdk + +# Lint all packages +npm run lint + +# Lint a single package +npm run lint --workspace=@inditextech/weave-sdk + +# Format all packages +npm run format + +# Run all tests +npm run test + +# Run tests for a single package +npm run test --workspace=@inditextech/weave-sdk + +# Run a single test file (from within a package directory) +npx vitest run path/to/file.test.ts + +# Type-check the SDK +npm run types:check --workspace=@inditextech/weave-sdk +``` + +## Repository Structure + +``` +code/packages/ + sdk/ # Core SDK (@inditextech/weave-sdk) + types/ # Shared TypeScript types (@inditextech/weave-types) + react/ # React integration (@inditextech/weave-react) + renderer-konva-base/ # Base Konva renderer + renderer-konva-react-reconciler/ # React-reconciler-based Konva renderer + store-standalone/ # In-memory store (no network) + store-websockets/ # WebSocket-based collaborative store + store-azure-web-pubsub/ # Azure Web PubSub collaborative store + create-backend-app/ # CLI to scaffold a backend test app + create-frontend-app/ # CLI to scaffold a frontend test app +docs/ # Fumadocs-based documentation site (MDX) +``` + +## Architecture + +The `Weave` class (in `sdk/src/weave.ts`) is the central orchestrator. It is constructed with a `WeaveConfig` that wires together the four extension points: + +| Extension Point | Base Class | Purpose | +|-----------------|----------------|------------------------------------------------------| +| `WeaveStore` | Abstract class | Yjs sync transport (WebSocket, Azure WS, standalone) | +| `WeaveRenderer` | Abstract class | Konva rendering engine | +| `WeaveNode` | Abstract class | Canvas shape definition | +| `WeaveAction` | Abstract class | Tool behavior (draw, select, move…) | +| `WeavePlugin` | Abstract class | Cross-cutting features (selection, snapping, grid…) | + +Internally, `Weave` delegates to a set of **Manager** classes (e.g., `WeaveStageManager`, `WeaveStateManager`, `WeavePluginsManager`) which are not meant to be extended or replaced. + +### Data Flow + +1. A `WeaveStore` holds a `Y.Doc` with two maps: `weave` (node state) and `weaveMetadata`. +2. On every Yjs `afterTransaction`, subsequent state updates are batched via `requestAnimationFrame`. +3. `onStateChange` triggers `Weave.render()`, which calls `WeaveRenderer.render()`. +4. The renderer diffing maps each state element to a registered `WeaveNode` by `nodeType`, calling `onRender()` for creates and `onUpdate()` for updates. + +### Custom Node Pattern + +Extend `WeaveNode` and implement at minimum: + +```ts +export class MyNode extends WeaveNode { + protected nodeType = 'myNodeType'; + initialize = undefined; + + onRender(props: WeaveElementAttributes): WeaveElementInstance { + const group = new Konva.Group({ ...props, name: 'node' }); + // ... add Konva shapes to group + this.setupDefaultNodeAugmentation(group); + this.setupDefaultNodeEvents(group); + return group; + } + + onUpdate(instance: WeaveElementInstance, nextProps: WeaveElementAttributes): void { + instance.setAttrs({ ...nextProps }); + // ... update child shapes + } +} +``` + +Always call `setupDefaultNodeAugmentation()` and `setupDefaultNodeEvents()` when implementing custom nodes. + +### Custom Action Pattern + +Extend `WeaveAction` and implement `trigger()`: + +```ts +export class MyToolAction extends WeaveAction { + protected name = 'myTool'; + onPropsChange = undefined; + onInit = undefined; + + trigger(cancelAction: () => void, params?: unknown): unknown { + // Set up Konva stage event listeners + } + + cleanup(): void { + // Remove event listeners + } +} +``` + +### Custom Plugin Pattern + +Extend `WeavePlugin` and implement lifecycle hooks: + +```ts +export class MyPlugin extends WeavePlugin { + protected name = 'myPlugin'; + + initialize(): void { /* called once at construction */ } + onInit(): void { /* called when Weave initializes */ } + onRender(): void { /* called on each render */ } + enable(): void { this.enabled = true; } + disable(): void { this.enabled = false; } +} +``` + +### Custom Store Pattern + +Extend `WeaveStore` and implement the abstract methods `connect()`, `disconnect()`, `handleAwarenessChange()`, and `setAwarenessInfo()`. The base class handles all Yjs document management and undo/redo. + +## Key Conventions + +### SPDX License Headers + +Every source file must start with: + +```ts +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 +``` + +### Path Aliases + +Within the `sdk` package, `@/` maps to `./src/`. Use `@inditextech/weave-types` for shared types. + +### TypeScript + +- Strict mode is enabled. Avoid `any`; if unavoidable, use `// eslint-disable-next-line @typescript-eslint/no-explicit-any`. +- `noUnusedLocals` is enforced. +- Use `import type` for type-only imports (`verbatimModuleSyntax` is on). + +### Testing + +Tests use [Vitest](https://vitest.dev/). Test files follow the `**/*.test.ts` pattern. Not all packages currently have test suites. + +### Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/). Enforced by commitlint with `@commitlint/config-conventional`. + +### Peer Dependencies: Konva & Yjs + +Both `konva` and `yjs` are **peer dependencies** pinned to exact versions (`konva@10.0.2`, `yjs@13.6.27`). When linking packages locally, set these env vars to avoid duplicate instances: + +```bash +WEAVE_KONVA_PATH=/code/node_modules/konva +WEAVE_YJS_PATH=/code/node_modules/yjs +``` + +### Dual Entry Points (SDK) + +The SDK publishes two entry points: +- `.` — browser/client (`dist/sdk.js`) +- `./server` — Node.js server-side (`dist/sdk.node.js`) + +Server-side code lives in `src/backend.ts` and `src/index.node.ts`. + +### Nx Caching + +Run `npm run reset` (which runs `nx reset`) to clear the Nx computation cache if builds behave unexpectedly. diff --git a/code/CHANGELOG.md b/code/CHANGELOG.md index 124fc05f8..7fe23013b 100644 --- a/code/CHANGELOG.md +++ b/code/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [#1089](https://github.com/InditexTech/weavejs/issues/1089) Refactor Image node and tools to avoid saving image fallback on the state - [#1090](https://github.com/InditexTech/weavejs/issues/1090) Remove usage of @syncedstore/core - [#1091](https://github.com/InditexTech/weavejs/issues/1091) Make Store Connection Lifecycle Asynchronous and Increase Azure Web PubSub Frame Size to 512 KB diff --git a/code/packages/sdk/src/actions/image-tool/__tests__/image-tool.test.ts b/code/packages/sdk/src/actions/image-tool/__tests__/image-tool.test.ts index 4d08c5aa7..b98685c3e 100644 --- a/code/packages/sdk/src/actions/image-tool/__tests__/image-tool.test.ts +++ b/code/packages/sdk/src/actions/image-tool/__tests__/image-tool.test.ts @@ -68,6 +68,9 @@ function makeImageNodeHandler() { return { getImageSource: vi.fn().mockReturnValue({ width: 400, height: 300 }), getFallbackImageSource: vi.fn().mockReturnValue(null), + getImageFallbackId: vi.fn().mockReturnValue(undefined), + isImageFallbackEnabled: vi.fn().mockReturnValue(false), + getFallbackImageSourceURL: vi.fn().mockReturnValue(undefined), create: vi.fn().mockReturnValue({ getAttrs: vi.fn().mockReturnValue({ id: 'test-uuid' }), }), @@ -441,11 +444,11 @@ describe('WeaveImageToolAction', () => { expect(entry['uploadType']).toBeNull(); }); - it('6.3 props.imageFallback/width/height set from image', () => { + it('6.3 props.width/height set from image (imageFallback not propagated to props)', () => { triggerImageURL(); const entry = ((action as unknown as R)['imageAction'] as R)['test-uuid'] as R; const props = entry['props'] as R; - expect(props['imageFallback']).toBe(mockImageURL.fallback); + expect(props['imageFallback']).toBeUndefined(); expect(props['width']).toBe(mockImageURL.width); expect(props['height']).toBe(mockImageURL.height); }); @@ -778,7 +781,7 @@ describe('WeaveImageToolAction', () => { expect(imageNodeHandler.getImageSource).toHaveBeenCalled(); }); - it('13.4 uploadType=file → getFallbackImageSource, then loadImageDataURL if null', async () => { + it('13.4 uploadType=file + imageFallbackURL → loadImageDataURL called if imageSource null', async () => { const imageNodeHandler = makeImageNodeHandler(); imageNodeHandler.getImageSource.mockReturnValue(null); imageNodeHandler.getFallbackImageSource.mockReturnValue(null); @@ -789,7 +792,7 @@ describe('WeaveImageToolAction', () => { (action as unknown as R)['imageAction'] = { 'test-uuid': makeImageActionEntry({ uploadType: WEAVE_IMAGE_TOOL_UPLOAD_TYPE.FILE, - props: { imageFallback: 'data:image/png;base64,abc', width: 400, height: 300 }, + props: { imageFallbackURL: 'data:image/png;base64,abc', width: 400, height: 300 }, }), }; await (action as unknown as R)['addImageNode']('test-uuid'); @@ -905,7 +908,7 @@ describe('WeaveImageToolAction', () => { expect((action as unknown as R)['cancelAction']).toHaveBeenCalled(); }); - it('14.2 uploadType=FILE → getFallbackImageSource, loadImageDataURL if null', async () => { + it('14.2 uploadType=FILE + imageFallbackURL → loadImageDataURL called if imageSource null', async () => { const handler = makeImageNodeHandler(); handler.getImageSource.mockReturnValue(null); handler.getFallbackImageSource.mockReturnValue(null); @@ -916,7 +919,7 @@ describe('WeaveImageToolAction', () => { (action as unknown as R)['imageAction'] = { 'test-uuid': makeImageActionEntry({ uploadType: WEAVE_IMAGE_TOOL_UPLOAD_TYPE.FILE, - props: { imageFallback: 'data:image/png;base64,abc', width: 0, height: 0 }, + props: { imageFallbackURL: 'data:image/png;base64,abc', width: 0, height: 0 }, }), }; await (action as unknown as R)['handleAdding']('test-uuid'); diff --git a/code/packages/sdk/src/actions/image-tool/image-tool.ts b/code/packages/sdk/src/actions/image-tool/image-tool.ts index db39ccd4e..9775b2d5a 100644 --- a/code/packages/sdk/src/actions/image-tool/image-tool.ts +++ b/code/packages/sdk/src/actions/image-tool/image-tool.ts @@ -8,16 +8,15 @@ import { type WeaveImageToolActionTriggerParams, type WeaveImageToolActionState, type WeaveImageToolActionOnAddedEvent, - type WeaveImageToolActionUploadType, type WeaveImageToolDragAndDropProperties, type WeaveImageToolActionParams, type WeaveImageToolActionConfig, type WeaveImageFile, type WeaveImageURL, - type WeaveImageToolActionUploadFunction, type WeaveImageToolActionOnImageUploadedEvent, type WeaveImageToolActionTriggerReturn, type WeaveImageToolActionOnImageUploadedErrorEvent, + type ImageToolActionData, } from './types'; import { WEAVE_IMAGE_TOOL_ACTION_NAME, @@ -30,26 +29,12 @@ import Konva from 'konva'; import type { WeaveImageNode } from '@/nodes/image/image'; import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; import { mergeExceptArrays } from '@/utils/utils'; -import type { - WeaveElementAttributes, - WeaveElementInstance, -} from '@inditextech/weave-types'; +import type { WeaveElementInstance } from '@inditextech/weave-types'; import { downscaleImageFile, getImageSizeFromFile } from '@/utils/image'; -type ImageToolActionData = { - props: WeaveElementAttributes; - imageId: string | null; - container: Konva.Layer | Konva.Node | undefined; - imageFile: WeaveImageFile | null; - imageURL: WeaveImageURL | null; - forceMainContainer: boolean; - clickPoint: Konva.Vector2d | null; - uploadType: WeaveImageToolActionUploadType | null; - uploadImageFunction: WeaveImageToolActionUploadFunction | null; -}; - export class WeaveImageToolAction extends WeaveAction { protected readonly config: WeaveImageToolActionConfig; + protected initInitialized: boolean = false; protected initialized: boolean = false; protected initialCursor: string | null = null; protected state!: WeaveImageToolActionState; @@ -98,7 +83,9 @@ export class WeaveImageToolAction extends WeaveAction { } onInit(): void { - this.instance.addEventListener('onStageDrop', (e: DragEvent) => { + if (this.initInitialized) return; + + const handleImageOnStageDrop = (e: DragEvent) => { const dragId = this.instance.getDragStartedId(); const dragProperties = this.instance.getDragProperties(); @@ -128,7 +115,11 @@ export class WeaveImageToolAction extends WeaveAction { position: mousePoint, }); } - }); + }; + + this.instance.addEventListener('onStageDrop', handleImageOnStageDrop); + + this.initInitialized = true; } private setupEvents() { @@ -269,12 +260,31 @@ export class WeaveImageToolAction extends WeaveAction { this.imageAction[nodeId].props = { ...this.imageAction[nodeId].props, - imageFallback: dataURL, + ...(this.imageAction[nodeId].imageId && { + imageId: this.imageAction[nodeId].imageId, + }), imageURL: undefined, + imageFallbackURL: dataURL, width: realImageSize.width, height: realImageSize.height, }; + const imageNodeHandler = this.getImageNodeHandler(); + + if (!imageNodeHandler) { + return; + } + + imageNodeHandler.saveImageFallback( + this.imageAction[nodeId].props, + dataURL + ); + + imageNodeHandler.cacheImageFallbackURL( + this.imageAction[nodeId].props, + dataURL + ); + this.addImageNode(nodeId, params?.position); } catch { this.cancelAction(); @@ -295,6 +305,37 @@ export class WeaveImageToolAction extends WeaveAction { return window.matchMedia('(pointer: coarse)').matches; } + private async getImageSource(nodeId: string) { + const imageNodeHandler = this.getImageNodeHandler(); + + if (!imageNodeHandler) { + return undefined; + } + + let imageSource = imageNodeHandler.getImageSource(nodeId); + const imageFallbackId = imageNodeHandler.getImageFallbackId( + this.imageAction[nodeId].props + ); + if ( + this.imageAction[nodeId].props.imageFallbackURL && + !imageNodeHandler.isImageFallbackEnabled() + ) { + imageSource ??= await this.loadImageDataURL( + this.imageAction[nodeId].props.imageFallbackURL + ); + } + if (imageFallbackId && imageNodeHandler.isImageFallbackEnabled()) { + imageSource = imageNodeHandler.getFallbackImageSource(nodeId); + const imageFallbackURL = + imageNodeHandler.getFallbackImageSourceURL(imageFallbackId); + if (imageFallbackURL) { + imageSource ??= await this.loadImageDataURL(imageFallbackURL); + } + } + + return imageSource; + } + private async addImageNode(nodeId: string, position?: Konva.Vector2d) { const stage = this.instance.getStage(); @@ -315,28 +356,25 @@ export class WeaveImageToolAction extends WeaveAction { } if (this.imageAction[nodeId]) { - const { uploadType } = this.imageAction[nodeId]; - const mousePos = stage.getRelativePointerPosition(); this.tempImageId = uuidv4(); - let imageSource = imageNodeHandler.getImageSource(nodeId); - if (uploadType === 'file') { - imageSource = imageNodeHandler.getFallbackImageSource(nodeId); - imageSource ??= await this.loadImageDataURL( - this.imageAction[nodeId].props.imageFallback - ); - } + const imageSource = await this.getImageSource(nodeId); if (!imageSource) { this.cancelAction(); return; } - const aspectRatio = imageSource.width / imageSource.height; + if ( + !this.tempImageNode && + this.tempImageId && + !this.isTouchDevice() && + imageSource + ) { + const aspectRatio = imageSource.width / imageSource.height; - if (!this.tempImageNode && this.tempImageId && !this.isTouchDevice()) { const cursorPadding = this.config.style.cursor.padding; const imageThumbnailWidth = this.config.style.cursor.imageThumbnail.width; @@ -392,13 +430,7 @@ export class WeaveImageToolAction extends WeaveAction { const { uploadType, imageURL, forceMainContainer } = this.imageAction[nodeId]; - let imageSource = imageNodeHandler.getImageSource(nodeId); - if (uploadType === WEAVE_IMAGE_TOOL_UPLOAD_TYPE.FILE) { - imageSource = imageNodeHandler.getFallbackImageSource(nodeId); - imageSource ??= await this.loadImageDataURL( - this.imageAction[nodeId].props.imageFallback - ); - } + const imageSource = await this.getImageSource(nodeId); if (!imageSource && !position) { this.cancelAction(); @@ -426,6 +458,13 @@ export class WeaveImageToolAction extends WeaveAction { } if (nodeHandler) { + if (this.imageAction[nodeId].props.imageFallbackURL) { + this.imageAction[nodeId].props = { + ...this.imageAction[nodeId].props, + imageFallbackURL: undefined, + }; + } + const node = nodeHandler.create(nodeId, { ...this.imageAction[nodeId].props, x: this.imageAction[nodeId].clickPoint.x, @@ -469,7 +508,10 @@ export class WeaveImageToolAction extends WeaveAction { ) => { const { uploadImageFunction, imageFile } = imageActionData; try { - const imageURL = await uploadImageFunction?.(imageFile!.file); + const imageURL = await uploadImageFunction?.( + imageFile!.file, + this.imageAction[nodeId].imageId! + ); if (!imageURL) { return; @@ -532,6 +574,7 @@ export class WeaveImageToolAction extends WeaveAction { clickPoint: null, imageFile: null, imageURL: null, + imageFallbackURL: null, container: params?.container, forceMainContainer: params?.forceMainContainer ?? false, uploadType: null, @@ -571,7 +614,6 @@ export class WeaveImageToolAction extends WeaveAction { this.imageAction[nodeId].imageURL = params.image; this.imageAction[nodeId].props = { ...this.imageAction[nodeId].props, - imageFallback: params.image.fallback, width: params.image.width, height: params.image.height, }; diff --git a/code/packages/sdk/src/actions/image-tool/types.ts b/code/packages/sdk/src/actions/image-tool/types.ts index 426585235..e3ac22b60 100644 --- a/code/packages/sdk/src/actions/image-tool/types.ts +++ b/code/packages/sdk/src/actions/image-tool/types.ts @@ -7,7 +7,11 @@ import { WEAVE_IMAGE_TOOL_UPLOAD_TYPE, WEAVE_IMAGE_TOOL_STATE, } from './constants'; -import type { DeepPartial, ImageCrossOrigin } from '@inditextech/weave-types'; +import type { + DeepPartial, + ImageCrossOrigin, + WeaveElementAttributes, +} from '@inditextech/weave-types'; export type WeaveImageToolActionUploadTypeKeys = keyof typeof WEAVE_IMAGE_TOOL_UPLOAD_TYPE; @@ -45,13 +49,13 @@ export type WeaveImageFile = { export type WeaveImageURL = { url: string; - fallback: string; width: number; height: number; }; export type WeaveImageToolActionUploadFunction = ( - file: File + file: File, + imageId: string ) => Promise; export type WeaveImageToolActionTriggerParams = ( @@ -98,3 +102,16 @@ export type WeaveImageToolActionConfig = { export type WeaveImageToolActionParams = { config: DeepPartial; }; + +export type ImageToolActionData = { + props: WeaveElementAttributes; + imageId: string | null; + container: Konva.Layer | Konva.Node | undefined; + imageFile: WeaveImageFile | null; + imageURL: WeaveImageURL | null; + imageFallbackURL: string | null; + forceMainContainer: boolean; + clickPoint: Konva.Vector2d | null; + uploadType: WeaveImageToolActionUploadType | null; + uploadImageFunction: WeaveImageToolActionUploadFunction | null; +}; diff --git a/code/packages/sdk/src/actions/images-tool/images-tool.ts b/code/packages/sdk/src/actions/images-tool/images-tool.ts index 99974a7d0..91f7b4042 100644 --- a/code/packages/sdk/src/actions/images-tool/images-tool.ts +++ b/code/packages/sdk/src/actions/images-tool/images-tool.ts @@ -47,6 +47,7 @@ import { loadImageSource } from '@/utils/image'; export class WeaveImagesToolAction extends WeaveAction { private readonly config: WeaveImagesToolActionParams; + protected initInitialized: boolean = false; protected initialized: boolean = false; protected initialCursor: string | null = null; protected state!: WeaveImagesToolActionState; @@ -111,7 +112,9 @@ export class WeaveImagesToolAction extends WeaveAction { } onInit(): void { - this.instance.addEventListener('onStageDrop', (e: DragEvent) => { + if (this.initInitialized) return; + + const handleImagesOnStageDrop = (e: DragEvent) => { const dragId = this.instance.getDragStartedId(); const dragProperties = this.instance.getDragProperties(); @@ -138,7 +141,11 @@ export class WeaveImagesToolAction extends WeaveAction { } ); } - }); + }; + + this.instance.addEventListener('onStageDrop', handleImagesOnStageDrop); + + this.initInitialized = true; } private setupEvents() { @@ -469,7 +476,10 @@ export class WeaveImagesToolAction extends WeaveAction { const { imageId } = imageFile; const uploadImageFunctionInternal = async () => { - const imageURL = await this.uploadImageFunction(imageFile.file); + const imageURL = await this.uploadImageFunction( + imageFile.file, + imageId ?? '' + ); return imageURL; }; @@ -536,7 +546,6 @@ export class WeaveImagesToolAction extends WeaveAction { type: WEAVE_IMAGE_TOOL_UPLOAD_TYPE.IMAGE_URL, image: { url: imageURL.url, - fallback: imageURL.fallback, width: imageURL.width, height: imageURL.height, }, diff --git a/code/packages/sdk/src/actions/images-tool/types.ts b/code/packages/sdk/src/actions/images-tool/types.ts index 1eb9e0022..594e73735 100644 --- a/code/packages/sdk/src/actions/images-tool/types.ts +++ b/code/packages/sdk/src/actions/images-tool/types.ts @@ -37,7 +37,8 @@ export type WeaveImagesToolActionTriggerCommonParams = { export type WeaveImagesToolActionInternalUploadFunction = () => Promise; export type WeaveImagesToolActionUploadFunction = ( - file: File + file: File, + imageId: string ) => Promise; export type WeaveImagesToolActionOnStartUploadingFunction = () => void | Promise; @@ -54,7 +55,6 @@ export type WeaveImagesFile = { export type WeaveImagesURL = { url: string; - fallback: string; width: number; height: number; options?: ImageOptions; diff --git a/code/packages/sdk/src/actions/video-tool/video-tool.ts b/code/packages/sdk/src/actions/video-tool/video-tool.ts index 592c34581..b09be81df 100644 --- a/code/packages/sdk/src/actions/video-tool/video-tool.ts +++ b/code/packages/sdk/src/actions/video-tool/video-tool.ts @@ -20,6 +20,7 @@ import { SELECTION_TOOL_ACTION_NAME } from '../selection-tool/constants'; import type { WeaveVideoNode } from '@/nodes/video/video'; export class WeaveVideoToolAction extends WeaveAction { + protected initInitialized: boolean = false; protected initialized: boolean = false; protected initialCursor: string | null = null; protected state!: WeaveVideoToolActionState; @@ -74,7 +75,9 @@ export class WeaveVideoToolAction extends WeaveAction { } onInit(): void { - this.instance.addEventListener('onStageDrop', (e: DragEvent) => { + if (this.initInitialized) return; + + const handleVideoOnStageDrop = (e: DragEvent) => { const dragId = this.instance.getDragStartedId(); const dragProperties = this.instance.getDragProperties(); @@ -95,7 +98,11 @@ export class WeaveVideoToolAction extends WeaveAction { position: mousePoint, }); } - }); + }; + + this.instance.addEventListener('onStageDrop', handleVideoOnStageDrop); + + this.initInitialized = true; } private setupEvents() { diff --git a/code/packages/sdk/src/managers/__tests__/fonts.test.ts b/code/packages/sdk/src/managers/__tests__/fonts.test.ts index 0c8734aa3..c590b63db 100644 --- a/code/packages/sdk/src/managers/__tests__/fonts.test.ts +++ b/code/packages/sdk/src/managers/__tests__/fonts.test.ts @@ -4,10 +4,11 @@ // @vitest-environment node -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WeaveFontsManager } from '../fonts'; import type { Weave } from '@/weave'; import type { WeaveFont } from '@inditextech/weave-types'; +import type { WeaveFontFamily } from '@/types'; const FONT_A: WeaveFont = { id: 'font-a', name: 'Font A' }; const FONT_B: WeaveFont = { id: 'font-b', name: 'Font B' }; @@ -136,4 +137,135 @@ describe('WeaveFontsManager', () => { expect(weave.emitEvent).toHaveBeenCalledWith('onFontsLoaded', []); }); }); + + describe('loadFontFamily() and loadFontsFamilies() via function config', () => { + const FONT_FAMILY: WeaveFontFamily = { + family: 'TestFont', + fontFaces: [{ source: 'url(test.woff2)', weight: '400', style: 'normal' }], + offset: { x: 0, y: 2 }, + supportedStyles: ['normal'], + }; + + let mockFontFaceInstance: { load: ReturnType }; + let MockFontFaceClass: ReturnType; + let documentFontsAdd: ReturnType; + + beforeEach(() => { + mockFontFaceInstance = { load: vi.fn().mockResolvedValue(undefined) }; + MockFontFaceClass = vi.fn().mockImplementation(() => mockFontFaceInstance); + documentFontsAdd = vi.fn(); + vi.stubGlobal('FontFace', MockFontFaceClass); + vi.stubGlobal('document', { fonts: { add: documentFontsAdd } }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('constructs FontFace with family, source, and remaining descriptors', async () => { + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([FONT_FAMILY]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + expect(MockFontFaceClass).toHaveBeenCalledWith('TestFont', 'url(test.woff2)', { + weight: '400', + style: 'normal', + }); + }); + + it('calls load() and document.fonts.add() for each font face', async () => { + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([FONT_FAMILY]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + expect(mockFontFaceInstance.load).toHaveBeenCalledTimes(1); + expect(documentFontsAdd).toHaveBeenCalledWith(mockFontFaceInstance); + }); + + it('resolves loadFontFamily with the correct WeaveFont shape', async () => { + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([FONT_FAMILY]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + expect(mgr.getFonts()).toEqual([ + { + id: 'TestFont', + name: 'TestFont, sans-serif', + offsetY: 2, + supportedStyles: ['normal'], + }, + ]); + }); + + it('loads multiple font families and returns all as WeaveFont[]', async () => { + const FONT_FAMILY_B: WeaveFontFamily = { + family: 'AnotherFont', + fontFaces: [{ source: 'url(another.woff2)' }], + offset: { x: 0, y: 0 }, + supportedStyles: ['bold'], + }; + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([FONT_FAMILY, FONT_FAMILY_B]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + const fonts = mgr.getFonts(); + expect(fonts).toHaveLength(2); + expect(fonts[0].id).toBe('TestFont'); + expect(fonts[1].id).toBe('AnotherFont'); + }); + + it('handles a font family with multiple font faces', async () => { + const MULTI_FACE_FAMILY: WeaveFontFamily = { + family: 'MultiFont', + fontFaces: [ + { source: 'url(multi-regular.woff2)', weight: '400' }, + { source: 'url(multi-bold.woff2)', weight: '700' }, + ], + offset: { x: 0, y: 0 }, + supportedStyles: ['normal', 'bold'], + }; + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([MULTI_FACE_FAMILY]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + expect(MockFontFaceClass).toHaveBeenCalledTimes(2); + expect(mockFontFaceInstance.load).toHaveBeenCalledTimes(2); + }); + + it('returns empty array and logs error when FontFace.load() rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + mockFontFaceInstance.load.mockRejectedValue(new Error('load failed')); + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([FONT_FAMILY]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + expect(consoleSpy).toHaveBeenCalledWith('Error loading fonts families', expect.any(Error)); + expect(mgr.getFonts()).toEqual([]); + consoleSpy.mockRestore(); + }); + + it('emits onFontsLoaded with empty array when loadFontsFamilies errors', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + mockFontFaceInstance.load.mockRejectedValue(new Error('load failed')); + const { weave } = makeMockWeave( + async (loadFontsFamilies: (f: WeaveFontFamily[]) => Promise) => + loadFontsFamilies([FONT_FAMILY]) + ); + const mgr = new WeaveFontsManager(weave); + await mgr.loadFonts(); + expect(weave.emitEvent).toHaveBeenCalledWith('onFontsLoaded', []); + }); + }); }); diff --git a/code/packages/sdk/src/managers/export/__tests__/export.test.ts b/code/packages/sdk/src/managers/export/__tests__/export.test.ts index f1ba869bf..3a9f73326 100644 --- a/code/packages/sdk/src/managers/export/__tests__/export.test.ts +++ b/code/packages/sdk/src/managers/export/__tests__/export.test.ts @@ -46,6 +46,8 @@ function makeStage() { toImage: vi.fn(), toBlob: vi.fn(), toCanvas: vi.fn(), + clone: vi.fn(), + destroy: vi.fn(), }; } @@ -77,6 +79,7 @@ describe('WeaveExportManager', () => { let layer: ReturnType; let mockInst: ReturnType; let manager: WeaveExportManager; + let clonedStage: ReturnType; beforeEach(() => { vi.mocked(getExportBoundingBox).mockReturnValue(MOCK_BOUNDS); @@ -91,6 +94,14 @@ describe('WeaveExportManager', () => { stage = makeStage(); layer = makeLayer(); + + // Set up cloned stage (returned by stage.clone() — used by all export methods) + clonedStage = makeStage(); + clonedStage.findOne.mockImplementation((sel: string) => + sel === '#mainLayer' ? layer : undefined + ); + stage.clone.mockReturnValue(clonedStage); + mockInst = makeInstance(stage, layer); manager = new WeaveExportManager(mockInst.instance as never); @@ -113,14 +124,14 @@ describe('WeaveExportManager', () => { return Promise.resolve({ toBuffer: vi.fn().mockReturnValue(MOCK_BUFFER) }); }); - // Default stage callback mocks (for exportArea* methods) - stage.toImage.mockImplementation((options: Record) => + // Default stage callback mocks (for exportArea* methods — called on clonedStage) + clonedStage.toImage.mockImplementation((options: Record) => (options?.callback as (img: typeof MOCK_IMG) => void)?.(MOCK_IMG) ); - stage.toBlob.mockImplementation((options: Record) => + clonedStage.toBlob.mockImplementation((options: Record) => (options?.callback as (blob: Blob) => void)?.(MOCK_BLOB) ); - stage.toCanvas.mockImplementation((options: Record) => { + clonedStage.toCanvas.mockImplementation((options: Record) => { if (options?.callback) { (options.callback as (canvas: typeof MOCK_CANVAS_ELEMENT) => void)(MOCK_CANVAS_ELEMENT); return; @@ -222,43 +233,33 @@ describe('WeaveExportManager', () => { expect(img).toBe(MOCK_IMG); }); - it('disables plugins and re-enables if they were enabled', async () => { + it('client-side export does not call disable/enable on plugins (uses clone)', async () => { const sel = makePlugin(true); const grid = makePlugin(true); vi.mocked(manager.getNodesSelectionPlugin).mockReturnValue(sel as never); vi.mocked(manager.getStageGridPlugin).mockReturnValue(grid as never); await manager.exportNodesAsImage([], bound as never, {}); - expect(sel.disable).toHaveBeenCalled(); - expect(sel.enable).toHaveBeenCalled(); - expect(grid.disable).toHaveBeenCalled(); - expect(grid.enable).toHaveBeenCalled(); + expect(sel.disable).not.toHaveBeenCalled(); + expect(grid.disable).not.toHaveBeenCalled(); }); it('does NOT re-enable a plugin that was already disabled', async () => { const sel = makePlugin(false); vi.mocked(manager.getNodesSelectionPlugin).mockReturnValue(sel as never); await manager.exportNodesAsImage([], bound as never, {}); - expect(sel.disable).toHaveBeenCalled(); + expect(sel.disable).not.toHaveBeenCalled(); expect(sel.enable).not.toHaveBeenCalled(); }); - it('restores original stage position and scale after export', async () => { - stage.x.mockReturnValue(50); - stage.y.mockReturnValue(60); - stage.scaleX.mockReturnValue(2); - stage.scaleY.mockReturnValue(3); + it('original stage is not modified after export', async () => { await manager.exportNodesAsImage([], bound as never, {}); - expect(stage.position).toHaveBeenCalledWith({ x: 50, y: 60 }); - expect(stage.scale).toHaveBeenCalledWith({ x: 2, y: 3 }); + expect(stage.position).not.toHaveBeenCalled(); + expect(stage.scale).not.toHaveBeenCalled(); }); - it('returns a pending Promise when mainLayer is null', async () => { - (mockInst.instance as { getMainLayer: ReturnType }).getMainLayer.mockReturnValue(null); - const result = await Promise.race([ - manager.exportNodesAsImage([], bound as never, {}), - Promise.resolve('timeout'), - ]); - expect(result).toBe('timeout'); + it('rejects when mainLayer is null', async () => { + clonedStage.findOne.mockReturnValue(null); + await expect(manager.exportNodesAsImage([], bound as never, {})).rejects.toThrow('Main layer not found'); }); }); @@ -282,28 +283,22 @@ describe('WeaveExportManager', () => { ); }); - it('disables/re-enables plugins', async () => { + it('client-side export does not call disable/enable on plugins (uses clone)', async () => { const sel = makePlugin(true); vi.mocked(manager.getNodesSelectionPlugin).mockReturnValue(sel as never); await manager.exportNodesAsBlob([], bound as never, {}); - expect(sel.disable).toHaveBeenCalled(); - expect(sel.enable).toHaveBeenCalled(); + expect(sel.disable).not.toHaveBeenCalled(); + expect(sel.enable).not.toHaveBeenCalled(); }); - it('restores stage position and scale', async () => { - stage.x.mockReturnValue(10); - stage.y.mockReturnValue(20); + it('original stage is not modified after export', async () => { await manager.exportNodesAsBlob([], bound as never, {}); - expect(stage.position).toHaveBeenCalledWith({ x: 10, y: 20 }); + expect(stage.position).not.toHaveBeenCalled(); }); - it('returns a pending Promise when mainLayer is null', async () => { - (mockInst.instance as { getMainLayer: ReturnType }).getMainLayer.mockReturnValue(null); - const result = await Promise.race([ - manager.exportNodesAsBlob([], bound as never, {}), - Promise.resolve('timeout'), - ]); - expect(result).toBe('timeout'); + it('rejects when mainLayer is null', async () => { + clonedStage.findOne.mockReturnValue(null); + await expect(manager.exportNodesAsBlob([], bound as never, {})).rejects.toThrow('Main layer not found'); }); }); @@ -317,28 +312,23 @@ describe('WeaveExportManager', () => { expect(canvas).toBe(MOCK_CANVAS_ELEMENT); }); - it('disables/re-enables plugins', async () => { + it('client-side export does not call disable/enable on plugins (uses clone)', async () => { const grid = makePlugin(true); vi.mocked(manager.getStageGridPlugin).mockReturnValue(grid as never); await manager.exportNodesAsCanvas([], bound as never, {}); - expect(grid.disable).toHaveBeenCalled(); - expect(grid.enable).toHaveBeenCalled(); + expect(grid.disable).not.toHaveBeenCalled(); + expect(grid.enable).not.toHaveBeenCalled(); }); - it('restores stage position and scale', async () => { - stage.x.mockReturnValue(5); - stage.y.mockReturnValue(7); + it('original stage is not modified after export', async () => { await manager.exportNodesAsCanvas([], bound as never, {}); - expect(stage.position).toHaveBeenCalledWith({ x: 5, y: 7 }); + expect(stage.position).not.toHaveBeenCalled(); + expect(stage.scale).not.toHaveBeenCalled(); }); - it('returns a pending Promise when mainLayer is null', async () => { - (mockInst.instance as { getMainLayer: ReturnType }).getMainLayer.mockReturnValue(null); - const result = await Promise.race([ - manager.exportNodesAsCanvas([], bound as never, {}), - Promise.resolve('timeout'), - ]); - expect(result).toBe('timeout'); + it('rejects when mainLayer is null', async () => { + clonedStage.findOne.mockReturnValue(null); + await expect(manager.exportNodesAsCanvas([], bound as never, {})).rejects.toThrow('Main layer not found'); }); }); @@ -348,7 +338,7 @@ describe('WeaveExportManager', () => { const area = { x: 10, y: 20, width: 100, height: 200 }; it('rejects when mainLayer is null', async () => { - (mockInst.instance as { getMainLayer: ReturnType }).getMainLayer.mockReturnValue(null); + clonedStage.findOne.mockReturnValue(null); await expect(manager.exportAreaAsImage(area, {})).rejects.toThrow('Main layer not found'); }); @@ -363,10 +353,9 @@ describe('WeaveExportManager', () => { vi.mocked(manager.getNodesSelectionPlugin).mockReturnValue(sel as never); vi.mocked(manager.getStageGridPlugin).mockReturnValue(grid as never); await manager.exportAreaAsImage(area, {}); - expect(sel.disable).toHaveBeenCalled(); - expect(sel.enable).toHaveBeenCalled(); - expect(grid.disable).toHaveBeenCalled(); - expect(grid.enable).toHaveBeenCalled(); + // client-side export uses clone; no plugin disable/enable + expect(sel.disable).not.toHaveBeenCalled(); + expect(grid.disable).not.toHaveBeenCalled(); }); it('does NOT re-enable a plugin that was already disabled', async () => { @@ -376,11 +365,9 @@ describe('WeaveExportManager', () => { expect(sel.enable).not.toHaveBeenCalled(); }); - it('restores stage position and scale', async () => { - stage.x.mockReturnValue(3); - stage.y.mockReturnValue(4); + it('original stage is not modified after export', async () => { await manager.exportAreaAsImage(area, {}); - expect(stage.position).toHaveBeenCalledWith({ x: 3, y: 4 }); + expect(stage.position).not.toHaveBeenCalled(); }); }); @@ -390,7 +377,7 @@ describe('WeaveExportManager', () => { const area = { x: 10, y: 20, width: 100, height: 200 }; it('rejects when mainLayer is null', async () => { - (mockInst.instance as { getMainLayer: ReturnType }).getMainLayer.mockReturnValue(null); + clonedStage.findOne.mockReturnValue(null); await expect(manager.exportAreaAsBlob(area, {})).rejects.toThrow('Main layer not found'); }); @@ -400,23 +387,24 @@ describe('WeaveExportManager', () => { }); it('rejects when blob is null', async () => { - stage.toBlob.mockImplementation((options: Record) => + clonedStage.toBlob.mockImplementation((options: Record) => (options?.callback as (b: null) => void)?.(null) ); await expect(manager.exportAreaAsBlob(area, {})).rejects.toThrow('Failed to generate image blob'); }); - it('restores stage position and scale', async () => { + it('original stage is not modified after export', async () => { await manager.exportAreaAsBlob(area, {}); - expect(stage.position).toHaveBeenCalled(); - expect(stage.scale).toHaveBeenCalled(); + expect(stage.position).not.toHaveBeenCalled(); + expect(stage.scale).not.toHaveBeenCalled(); }); it('disables plugins before export', async () => { const sel = makePlugin(true); vi.mocked(manager.getNodesSelectionPlugin).mockReturnValue(sel as never); await manager.exportAreaAsBlob(area, {}); - expect(sel.disable).toHaveBeenCalled(); + // client-side export uses clone; no plugin disable/enable + expect(sel.disable).not.toHaveBeenCalled(); }); }); @@ -426,7 +414,7 @@ describe('WeaveExportManager', () => { const area = { x: 10, y: 20, width: 100, height: 200 }; it('rejects when mainLayer is null', async () => { - (mockInst.instance as { getMainLayer: ReturnType }).getMainLayer.mockReturnValue(null); + clonedStage.findOne.mockReturnValue(null); await expect(manager.exportAreaAsCanvas(area, {})).rejects.toThrow('Main layer not found'); }); @@ -439,14 +427,15 @@ describe('WeaveExportManager', () => { const grid = makePlugin(true); vi.mocked(manager.getStageGridPlugin).mockReturnValue(grid as never); await manager.exportAreaAsCanvas(area, {}); - expect(grid.disable).toHaveBeenCalled(); - expect(grid.enable).toHaveBeenCalled(); + // client-side export uses clone; no plugin disable/enable + expect(grid.disable).not.toHaveBeenCalled(); + expect(grid.enable).not.toHaveBeenCalled(); }); - it('restores stage position and scale', async () => { + it('original stage is not modified after export', async () => { await manager.exportAreaAsCanvas(area, {}); - expect(stage.position).toHaveBeenCalled(); - expect(stage.scale).toHaveBeenCalled(); + expect(stage.position).not.toHaveBeenCalled(); + expect(stage.scale).not.toHaveBeenCalled(); }); }); diff --git a/code/packages/sdk/src/managers/export/export.ts b/code/packages/sdk/src/managers/export/export.ts index bb8f5aa1c..0fb98c3d3 100644 --- a/code/packages/sdk/src/managers/export/export.ts +++ b/code/packages/sdk/src/managers/export/export.ts @@ -6,6 +6,7 @@ import { type Logger } from 'pino'; import { Weave } from '@/weave'; import { v4 as uuidv4 } from 'uuid'; import { + type BoundingBox, type WeaveElementInstance, type WeaveExportNodesOptions, WEAVE_EXPORT_BACKGROUND_COLOR, @@ -15,9 +16,15 @@ import { import Konva from 'konva'; import { getExportBoundingBox } from '@/utils/utils'; import type { WeaveNodesSelectionPlugin } from '@/plugins/nodes-selection/nodes-selection'; -import { WEAVE_NODES_SELECTION_KEY } from '@/plugins/nodes-selection/constants'; +import { + WEAVE_NODES_SELECTION_KEY, + WEAVE_NODES_SELECTION_LAYER_ID, +} from '@/plugins/nodes-selection/constants'; import type { WeaveStageGridPlugin } from '@/plugins/stage-grid/stage-grid'; -import { WEAVE_STAGE_GRID_PLUGIN_KEY } from '@/plugins/stage-grid/constants'; +import { + WEAVE_GRID_LAYER_ID, + WEAVE_STAGE_GRID_PLUGIN_KEY, +} from '@/plugins/stage-grid/constants'; export class WeaveExportManager { private instance: Weave; @@ -257,35 +264,144 @@ export class WeaveExportManager { return { pixelRatio: pr, outW, outH }; } + private cloneStageForExport(): { + stage: Konva.Stage; + mainLayer: Konva.Layer; + originalScale: { scaleX: number; scaleY: number }; + } { + const originalStage = this.instance.getStage(); + const stage = originalStage.clone(); + const mainLayer = stage.findOne('#mainLayer') as Konva.Layer; + + if (!mainLayer) { + throw new Error('Main layer not found'); + } + + const selectionLayer = stage.findOne( + `#${WEAVE_NODES_SELECTION_LAYER_ID}` + ) as Konva.Layer; + selectionLayer?.destroy(); + + const gridLayer = stage.findOne(`#${WEAVE_GRID_LAYER_ID}`) as Konva.Layer; + gridLayer?.destroy(); + + const scaleX = originalStage.scaleX(); + const scaleY = originalStage.scaleY(); + + return { stage, mainLayer, originalScale: { scaleX, scaleY } }; + } + + private setupForExport({ + nodes, + bounds, + stage, + mainLayer, + scaleX, + scaleY, + options, + exportArea, + }: { + nodes: WeaveElementInstance[]; + bounds: BoundingBox; + stage: Konva.Stage; + mainLayer: Konva.Layer; + scaleX: number; + scaleY: number; + options: WeaveExportNodesOptions; + exportArea: boolean; + }): { + exportGroup: Konva.Group; + pixelRatio: number; + backgroundRect: { x: number; y: number; width: number; height: number }; + } { + const { + padding = 0, + pixelRatio = 1, + backgroundColor = WEAVE_EXPORT_BACKGROUND_COLOR, + } = options; + + const unscaledBounds = { + x: bounds.x / scaleX, + y: bounds.y / scaleY, + width: bounds.width / scaleX, + height: bounds.height / scaleY, + }; + + const background = new Konva.Rect({ + x: unscaledBounds.x - padding, + y: unscaledBounds.y - padding, + width: unscaledBounds.width + 2 * padding, + height: unscaledBounds.height + 2 * padding, + strokeWidth: 0, + fill: backgroundColor, + }); + + const exportGroup = new Konva.Group(); + + if (exportArea) { + mainLayer.add(background); + } else { + exportGroup.add(background); + + for (const node of nodes) { + const clonedNode = node.clone({ id: uuidv4() }); + const absPos = node.getAbsolutePosition(); + clonedNode.absolutePosition({ + x: absPos.x / scaleX, + y: absPos.y / scaleY, + }); + exportGroup.add(clonedNode); + } + + mainLayer.add(exportGroup); + } + + const backgroundRect = background.getClientRect(); + + stage.batchDraw(); + + const { pixelRatio: finalPixelRatio } = this.fitKonvaPixelRatio( + Math.round(backgroundRect.width), + Math.round(backgroundRect.height), + pixelRatio + ); + + return { + exportGroup, + pixelRatio: finalPixelRatio, + backgroundRect, + }; + } + exportNodesAsImage( nodes: WeaveElementInstance[], boundingNodes: (nodes: Konva.Node[]) => Konva.Node[], options: WeaveExportNodesOptions ): Promise { return new Promise((resolve) => { - const { format, padding, pixelRatio, backgroundColor } = - this.parseExportOptions(options); - const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = - this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(); - const mainLayer = this.instance.getMainLayer(); + const { format = WEAVE_EXPORT_FORMATS.PNG } = options; + + const { + stage, + mainLayer, + originalScale: { scaleX, scaleY }, + } = this.cloneStageForExport(); if (mainLayer) { - const { exportGroup, backgroundRect } = this.buildNodesExportGroup( + const { + pixelRatio: finalPixelRatio, + backgroundRect, + exportGroup, + } = this.setupForExport({ nodes, - boundingNodes, + bounds: getExportBoundingBox(boundingNodes(nodes)), stage, mainLayer, - padding, - backgroundColor - ); - - const { pixelRatio: finalPixelRatio } = this.fitKonvaPixelRatio( - Math.round(backgroundRect.width), - Math.round(backgroundRect.height), - pixelRatio - ); + scaleX, + scaleY, + options, + exportArea: false, + }); exportGroup.toImage({ x: Math.round(backgroundRect.x), @@ -297,11 +413,9 @@ export class WeaveExportManager { quality: options.quality ?? 1, callback: (img: HTMLImageElement) => { exportGroup.destroy(); - this.restoreStage(stage, originalPosition, originalScale); - this.restorePlugins( - nodesSelectionPluginPrev, - nodesStageGridPluginPrev - ); + + stage.destroy(); + resolve(img); }, }); @@ -315,29 +429,29 @@ export class WeaveExportManager { options: WeaveExportNodesOptions ): Promise { return new Promise((resolve, reject) => { - const { format, padding, pixelRatio, backgroundColor } = - this.parseExportOptions(options); - const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = - this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(); - const mainLayer = this.instance.getMainLayer(); + const { format = WEAVE_EXPORT_FORMATS.PNG } = options; + + const { + stage, + mainLayer, + originalScale: { scaleX, scaleY }, + } = this.cloneStageForExport(); if (mainLayer) { - const { exportGroup, backgroundRect } = this.buildNodesExportGroup( + const { + pixelRatio: finalPixelRatio, + backgroundRect, + exportGroup, + } = this.setupForExport({ nodes, - boundingNodes, + bounds: getExportBoundingBox(boundingNodes(nodes)), stage, mainLayer, - padding, - backgroundColor - ); - - const { pixelRatio: finalPixelRatio } = this.fitKonvaPixelRatio( - Math.round(backgroundRect.width), - Math.round(backgroundRect.height), - pixelRatio - ); + scaleX, + scaleY, + options, + exportArea: false, + }); exportGroup.toBlob({ x: Math.round(backgroundRect.x), @@ -349,11 +463,9 @@ export class WeaveExportManager { quality: options.quality ?? 1, callback: (blob: Blob | null) => { exportGroup.destroy(); - this.restoreStage(stage, originalPosition, originalScale); - this.restorePlugins( - nodesSelectionPluginPrev, - nodesStageGridPluginPrev - ); + + stage.destroy(); + if (!blob) { reject(new Error('Failed to generate image blob')); return; @@ -371,29 +483,29 @@ export class WeaveExportManager { options: WeaveExportNodesOptions ): Promise { return new Promise((resolve) => { - const { format, padding, pixelRatio, backgroundColor } = - this.parseExportOptions(options); - const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = - this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(); - const mainLayer = this.instance.getMainLayer(); + const { format = WEAVE_EXPORT_FORMATS.PNG } = options; + + const { + stage, + mainLayer, + originalScale: { scaleX, scaleY }, + } = this.cloneStageForExport(); if (mainLayer) { - const { exportGroup, backgroundRect } = this.buildNodesExportGroup( + const { + pixelRatio: finalPixelRatio, + backgroundRect, + exportGroup, + } = this.setupForExport({ nodes, - boundingNodes, + bounds: getExportBoundingBox(boundingNodes(nodes)), stage, mainLayer, - padding, - backgroundColor - ); - - const { pixelRatio: finalPixelRatio } = this.fitKonvaPixelRatio( - Math.round(backgroundRect.width), - Math.round(backgroundRect.height), - pixelRatio - ); + scaleX, + scaleY, + options, + exportArea: false, + }); exportGroup.toCanvas({ x: Math.round(backgroundRect.x), @@ -405,11 +517,9 @@ export class WeaveExportManager { quality: options.quality ?? 1, callback: (canvas: HTMLCanvasElement) => { exportGroup.destroy(); - this.restoreStage(stage, originalPosition, originalScale); - this.restorePlugins( - nodesSelectionPluginPrev, - nodesStageGridPluginPrev - ); + + stage.destroy(); + resolve(canvas); }, }); @@ -422,44 +532,41 @@ export class WeaveExportManager { options: WeaveExportNodesOptions ): Promise { return new Promise((resolve) => { - const { format, padding, pixelRatio, backgroundColor } = - this.parseExportOptions(options); - const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = - this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(true); - const mainLayer = this.instance.getMainLayer(); - - if (!mainLayer) { - throw new Error('Main layer not found'); - } + const { format = WEAVE_EXPORT_FORMATS.PNG } = options; - const { background } = this.buildAreaBackground( - area, + const { stage, mainLayer, - padding, - backgroundColor - ); - - stage.toImage({ - x: area.x, - y: area.y, - width: area.width, - height: area.height, - mimeType: format, - pixelRatio, - quality: options.quality ?? 1, - callback: (img) => { - background.destroy(); - this.restoreStage(stage, originalPosition, originalScale); - this.restorePlugins( - nodesSelectionPluginPrev, - nodesStageGridPluginPrev - ); - resolve(img); - }, - }); + originalScale: { scaleX, scaleY }, + } = this.cloneStageForExport(); + + if (mainLayer) { + const { pixelRatio: finalPixelRatio } = this.setupForExport({ + nodes: [], + bounds: area, + stage, + mainLayer, + scaleX, + scaleY, + options, + exportArea: true, + }); + + stage.toImage({ + x: area.x, + y: area.y, + width: area.width, + height: area.height, + mimeType: format, + pixelRatio: finalPixelRatio, + quality: options.quality ?? 1, + callback: (img) => { + stage.destroy(); + + resolve(img); + }, + }); + } }); } @@ -468,48 +575,46 @@ export class WeaveExportManager { options: WeaveExportNodesOptions ): Promise { return new Promise((resolve, reject) => { - const { format, padding, pixelRatio, backgroundColor } = - this.parseExportOptions(options); - const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = - this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(true); - const mainLayer = this.instance.getMainLayer(); - - if (!mainLayer) { - throw new Error('Main layer not found'); - } + const { format = WEAVE_EXPORT_FORMATS.PNG } = options; - const { background } = this.buildAreaBackground( - area, + const { stage, mainLayer, - padding, - backgroundColor - ); - - stage.toBlob({ - x: area.x, - y: area.y, - width: area.width, - height: area.height, - mimeType: format, - pixelRatio, - quality: options.quality ?? 1, - callback: (blob: Blob | null) => { - background.destroy(); - this.restoreStage(stage, originalPosition, originalScale); - this.restorePlugins( - nodesSelectionPluginPrev, - nodesStageGridPluginPrev - ); - if (!blob) { - reject(new Error('Failed to generate image blob')); - return; - } - resolve(blob); - }, - }); + originalScale: { scaleX, scaleY }, + } = this.cloneStageForExport(); + + if (mainLayer) { + const { pixelRatio: finalPixelRatio } = this.setupForExport({ + nodes: [], + bounds: area, + stage, + mainLayer, + scaleX, + scaleY, + options, + exportArea: true, + }); + + stage.toBlob({ + x: area.x, + y: area.y, + width: area.width, + height: area.height, + mimeType: format, + pixelRatio: finalPixelRatio, + quality: options.quality ?? 1, + callback: (blob: Blob | null) => { + stage.destroy(); + + if (!blob) { + reject(new Error('Failed to generate image blob')); + return; + } + + resolve(blob); + }, + }); + } }); } @@ -518,44 +623,41 @@ export class WeaveExportManager { options: WeaveExportNodesOptions ): Promise { return new Promise((resolve) => { - const { format, padding, pixelRatio, backgroundColor } = - this.parseExportOptions(options); - const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = - this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(true); - const mainLayer = this.instance.getMainLayer(); - - if (!mainLayer) { - throw new Error('Main layer not found'); - } + const { format = WEAVE_EXPORT_FORMATS.PNG } = options; - const { background } = this.buildAreaBackground( - area, + const { stage, mainLayer, - padding, - backgroundColor - ); - - stage.toCanvas({ - x: area.x, - y: area.y, - width: area.width, - height: area.height, - mimeType: format, - pixelRatio, - quality: options.quality ?? 1, - callback: (canvas: HTMLCanvasElement) => { - background.destroy(); - this.restoreStage(stage, originalPosition, originalScale); - this.restorePlugins( - nodesSelectionPluginPrev, - nodesStageGridPluginPrev - ); - resolve(canvas); - }, - }); + originalScale: { scaleX, scaleY }, + } = this.cloneStageForExport(); + + if (mainLayer) { + const { pixelRatio: finalPixelRatio } = this.setupForExport({ + nodes: [], + bounds: area, + stage, + mainLayer, + scaleX, + scaleY, + options, + exportArea: true, + }); + + stage.toCanvas({ + x: area.x, + y: area.y, + width: area.width, + height: area.height, + mimeType: format, + pixelRatio: finalPixelRatio, + quality: options.quality ?? 1, + callback: (canvas: HTMLCanvasElement) => { + stage.destroy(); + + resolve(canvas); + }, + }); + } }); } @@ -572,8 +674,7 @@ export class WeaveExportManager { this.parseExportOptions(options); const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(); + const { stage, originalPosition, originalScale } = this.saveAndResetStage(); const mainLayer = this.instance.getMainLayer(); if (!mainLayer) { @@ -637,8 +738,7 @@ export class WeaveExportManager { this.parseExportOptions(options); const { nodesSelectionPluginPrev, nodesStageGridPluginPrev } = this.saveAndDisablePlugins(); - const { stage, originalPosition, originalScale } = - this.saveAndResetStage(); + const { stage, originalPosition, originalScale } = this.saveAndResetStage(); const mainLayer = this.instance.getMainLayer(); if (!mainLayer) { diff --git a/code/packages/sdk/src/managers/fonts.ts b/code/packages/sdk/src/managers/fonts.ts index e394ed8a3..742988966 100644 --- a/code/packages/sdk/src/managers/fonts.ts +++ b/code/packages/sdk/src/managers/fonts.ts @@ -5,6 +5,7 @@ import { type Logger } from 'pino'; import { type WeaveFont } from '@inditextech/weave-types'; import { Weave } from '@/weave'; +import type { WeaveFontFamily } from '@/types'; export class WeaveFontsManager { private instance: Weave; @@ -17,6 +18,51 @@ export class WeaveFontsManager { this.logger.debug('Fonts manager created'); } + private async loadFontFamily( + fontFamily: WeaveFontFamily + ): Promise { + const fontsPromises = []; + + for (const fontFace of fontFamily.fontFaces) { + const { source, ...fontFaceDescriptors } = fontFace; + const fontVariant = new FontFace( + fontFamily.family, + source, + fontFaceDescriptors + ); + fontsPromises.push( + fontVariant.load().then(() => document.fonts.add(fontVariant)) + ); + } + + await Promise.all(fontsPromises); + + return { + id: fontFamily.family, + name: `${fontFamily.family}, sans-serif`, + offsetY: fontFamily.offset.y, + supportedStyles: fontFamily.supportedStyles, + }; + } + + private async loadFontsFamilies( + fontFamilies: WeaveFontFamily[] + ): Promise { + const familiesPromises = []; + + for (const fontFamily of fontFamilies) { + familiesPromises.push(this.loadFontFamily(fontFamily)); + } + + try { + const fonts = await Promise.all(familiesPromises); + return fonts; + } catch (ex) { + console.error('Error loading fonts families', ex); + return []; + } + } + async loadFonts(): Promise { this.logger.info('Loading fonts'); @@ -30,7 +76,7 @@ export class WeaveFontsManager { let fontsToLoad: WeaveFont[] = []; if (fontsConfig && fontsConfig instanceof Function) { - fontsToLoad = await fontsConfig(); + fontsToLoad = await fontsConfig(this.loadFontsFamilies.bind(this)); } if (fontsConfig && fontsConfig instanceof Array) { fontsToLoad = fontsConfig; diff --git a/code/packages/sdk/src/nodes/image/__tests__/image.test.ts b/code/packages/sdk/src/nodes/image/__tests__/image.test.ts index f3ed7ab0c..4b4c9b986 100644 --- a/code/packages/sdk/src/nodes/image/__tests__/image.test.ts +++ b/code/packages/sdk/src/nodes/image/__tests__/image.test.ts @@ -453,7 +453,7 @@ describe('WeaveImageNode', () => { expect(withoutOptional.props.imageFallback).toBeUndefined(); expect(withoutOptional.props.imageId).toBeUndefined(); - expect(withOptional.props.imageFallback).toBe('fallback-data'); + expect(withOptional.props.imageFallback).toBeUndefined(); expect(withOptional.props.imageId).toBe('asset-1'); }); }); @@ -622,7 +622,7 @@ describe('WeaveImageNode', () => { expect(internalImage.getAttrs().visible).toBe(true); }); - it('11.2 uses fallback when imageURL missing and fallback exists', () => { + it('11.2 fallback not used when imageFallback.enabled is false (default)', () => { const { node } = makeNode(); const privateNode = getPrivateNode(node); const fallback = makeMockImageElement() as unknown as HTMLImageElement; @@ -631,7 +631,8 @@ describe('WeaveImageNode', () => { const group = node.onRender(defaultProps({ imageURL: undefined })) as Konva.Group; const internalImage = group.findOne('#test-image-image') as Konva.Image; - expect(internalImage.getAttrs().image).toBe(fallback); + // imageFallback.enabled=false → hasFallbackAndFinalImageNotLoaded is false → fallback not applied + expect(internalImage.getAttrs().image).toBeUndefined(); }); it('11.3 without sources it calls loadImage and createImageElement', () => { @@ -1218,10 +1219,11 @@ describe('WeaveImageNode', () => { ); }); - it('21.3 loadFallback=true calls preloadFallbackImage', () => { + it('21.3 loadFallback=true with imageFallback.enabled=false calls preloadImage (not preloadFallbackImage)', () => { const { node } = makeNode(); const privateNode = getPrivateNode(node); const fallbackSpy = vi.spyOn(node, 'preloadFallbackImage').mockImplementation(() => {}); + const preloadSpy = vi.spyOn(node, 'preloadImage').mockImplementation(() => {}); const { group } = createRenderableGroup(); privateNode.loadImage( @@ -1231,12 +1233,9 @@ describe('WeaveImageNode', () => { false ); - expect(fallbackSpy).toHaveBeenCalledWith( - 'test-image', - 'fallback-image', - expect.any(Object), - false - ); + // imageFallback.enabled=false → preloadFallbackImage NOT called; preloadImage IS called + expect(fallbackSpy).not.toHaveBeenCalled(); + expect(preloadSpy).toHaveBeenCalled(); }); it('21.4 onLoad without fallback shows internal image', () => { @@ -1254,11 +1253,11 @@ describe('WeaveImageNode', () => { expect(internalImage.isVisible()).toBe(true); }); - it('21.5 onLoad with fallback schedules retry', () => { + it('21.5 onLoad with useFallback=true schedules retry via preloadImage', () => { vi.useFakeTimers(); const { node } = makeNode(); const { group } = createRenderableGroup(); - vi.spyOn(node, 'preloadFallbackImage').mockImplementation((id, _url, handlers) => { + vi.spyOn(node, 'preloadImage').mockImplementation((id, _url, handlers) => { getPrivateNode(node).imageFallback[id] = makeMockImageElement() as unknown as HTMLImageElement; getPrivateNode(node).imageSource[id] = makeMockImageElement() as unknown as HTMLImageElement; handlers.onLoad(); @@ -1303,19 +1302,23 @@ describe('WeaveImageNode', () => { expect(setErrorStateSpy).toHaveBeenCalledWith('test-image', group); }); - it('21.8 onError with fallback recurses into fallback load', () => { + it('21.8 onError without imageFallback.enabled sets error state (no recursion)', () => { const { node } = makeNode(); const privateNode = getPrivateNode(node); const loadImageSpy = vi.spyOn(privateNode, 'loadImage'); + const setErrorStateSpy = vi.spyOn(privateNode, 'setErrorState').mockImplementation(() => {}); vi.spyOn(node, 'preloadImage').mockImplementation((_id, _url, handlers) => { handlers.onError(new Error('fail', { cause: 'ErrorLoadingImage' })); }); const { group } = createRenderableGroup(); const props = defaultProps({ imageFallback: 'fallback-image' }); + privateNode.imageTryoutAttempts['test-image'] = 99; // exhaust retries privateNode.loadImage(props, group, false, false); - expect(loadImageSpy).toHaveBeenCalledWith(expect.objectContaining(props), group, true); + // imageFallback.enabled=false → no recursive loadImage with fallback; setErrorState called instead + expect(loadImageSpy).toHaveBeenCalledTimes(1); + expect(setErrorStateSpy).toHaveBeenCalled(); }); it('21.9 onError during tryout below max schedules another tryout', () => { @@ -1581,7 +1584,7 @@ describe('WeaveImageNode', () => { }); describe('30 — setupNotUsedImagesCleanup', () => { - it('30.1 schedules cleanup on each call with current implementation', () => { + it('30.1 schedules cleanup only once per idle period (guard prevents double-scheduling)', () => { vi.useFakeTimers(); setupCleanupSpy.mockRestore(); const { node } = makeNode(); @@ -1590,7 +1593,8 @@ describe('WeaveImageNode', () => { getPrivateNode(node).setupNotUsedImagesCleanup(); getPrivateNode(node).setupNotUsedImagesCleanup(); - expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + // Second call is a no-op because notUsedImagesCleanup is already set + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); }); it('30.2 removes stale entries after timer fires', () => { @@ -1806,5 +1810,355 @@ describe('WeaveImageNode', () => { expect(mock.stateTransactional).toHaveBeenCalledTimes(1); expect(mock.emitEvent).not.toHaveBeenCalled(); }); + + it('34.3 calls removeNodeNT when nodeHandler is found', () => { + const { node, mock } = makeNode(); + const imageNode = new Konva.Group({ id: 'test-image' }); + imageNode.add(new Konva.Image({ id: 'test-image-image' } as Konva.ImageConfig)); + imageNode.add(new Konva.Group({ id: 'test-image-cropGroup' })); + const reference = new Konva.Rect({ id: 'ref-1', nodeType: 'rect' }); + const fakeState = { id: 'ref-1' }; + mock.getNodeHandler.mockReturnValue({ serialize: vi.fn().mockReturnValue(fakeState) }); + vi.spyOn(WeaveImageCrop.prototype, 'handleClipExternal').mockImplementation(() => {}); + + node.cropImageWithReference(imageNode, reference); + + expect(mock.removeNodeNT).toHaveBeenCalledWith(fakeState); + }); + }); + + describe('35 — imageFallback methods', () => { + function makeFallbackNode() { + return makeNode({ + imageFallback: { + enabled: true, + getId: vi.fn().mockImplementation((params: Record) => `fallback-${params['id'] ?? 'x'}`), + getDataURL: vi.fn().mockReturnValue('data:image/png;base64,fetched'), + onPersist: vi.fn(), + } as WeaveImageProperties['imageFallback'], + }); + } + + it('35.1 getImageFallbackId returns id when enabled', () => { + const { node } = makeFallbackNode(); + const result = node.getImageFallbackId({ id: 'img-1' }); + expect(result).toBe('fallback-img-1'); + }); + + it('35.2 getImageFallbackId returns undefined when disabled', () => { + const { node } = makeNode(); + const result = node.getImageFallbackId({ id: 'img-1' }); + expect(result).toBeUndefined(); + }); + + it('35.3 saveImageFallback calls onPersist when enabled', () => { + const { node } = makeFallbackNode(); + const priv = getPrivateNode(node); + node.saveImageFallback({ id: 'img-1' }, 'data:image/png;base64,abc'); + expect(priv.config.imageFallback.onPersist).toHaveBeenCalledWith( + { id: 'img-1' }, + 'data:image/png;base64,abc' + ); + }); + + it('35.4 saveImageFallback does nothing when disabled', () => { + const { node } = makeNode(); + expect(() => node.saveImageFallback({ id: 'img-1' }, 'data:abc')).not.toThrow(); + }); + + it('35.5 cacheImageFallbackURL stores provided dataURL', () => { + const { node } = makeFallbackNode(); + node.cacheImageFallbackURL({ id: 'img-1' }, 'data:image/png;base64,provided'); + const priv = getPrivateNode(node) as unknown as { imageFallbackURL: Record }; + expect(priv.imageFallbackURL['fallback-img-1']).toBe('data:image/png;base64,provided'); + }); + + it('35.6 cacheImageFallbackURL fetches via getDataURL when no dataURL given', () => { + const { node } = makeFallbackNode(); + node.cacheImageFallbackURL({ id: 'img-1' }); + const priv = getPrivateNode(node) as unknown as { imageFallbackURL: Record }; + expect(priv.imageFallbackURL['fallback-img-1']).toBe('data:image/png;base64,fetched'); + }); + + it('35.7 cacheImageFallbackURL does nothing when disabled', () => { + const { node } = makeNode(); + expect(() => node.cacheImageFallbackURL({ id: 'img-1' }, 'data:abc')).not.toThrow(); + }); + }); + + describe('36 — getter helpers', () => { + it('36.1 isImageFallbackEnabled returns false by default', () => { + const { node } = makeNode(); + expect(node.isImageFallbackEnabled()).toBe(false); + }); + + it('36.2 isImageFallbackEnabled returns true when configured', () => { + const { node } = makeNode({ + imageFallback: { + enabled: true, + getId: vi.fn(), + getDataURL: vi.fn(), + onPersist: vi.fn(), + } as WeaveImageProperties['imageFallback'], + }); + expect(node.isImageFallbackEnabled()).toBe(true); + }); + + it('36.3 getFallbackImageSource returns stored fallback element', () => { + const { node } = makeNode(); + const priv = getPrivateNode(node); + const img = makeMockImageElement() as unknown as HTMLImageElement; + priv.imageFallback['abc'] = img; + expect(node.getFallbackImageSource('abc')).toBe(img); + }); + + it('36.4 getFallbackImageSourceURL returns stored fallback URL', () => { + const { node } = makeNode(); + const priv = getPrivateNode(node) as unknown as { imageFallbackURL: Record }; + priv.imageFallbackURL['abc'] = 'data:image/png;base64,xyz'; + expect(node.getFallbackImageSourceURL('abc')).toBe('data:image/png;base64,xyz'); + }); + + it('36.5 getImageSource returns stored image element', () => { + const { node } = makeNode(); + const priv = getPrivateNode(node); + const img = makeMockImageElement() as unknown as HTMLImageElement; + priv.imageSource['abc'] = img; + expect(node.getImageSource('abc')).toBe(img); + }); + }); + + describe('37 — forceLoadFallbackImage', () => { + it('37.1 calls loadImage with useFallback=true when node found', () => { + const { node, mock } = makeNode(); + const priv = getPrivateNode(node); + const imageNode = new Konva.Group({ id: 'test-image' }); + mock.getStage().findOne.mockReturnValue(imageNode); + const loadSpy = vi.spyOn(priv, 'loadImage').mockImplementation(() => {}); + + node.forceLoadFallbackImage(imageNode, 'data:image/png;base64,fallback'); + + expect(loadSpy).toHaveBeenCalledWith( + expect.anything(), + imageNode, + true + ); + }); + + it('37.2 does not call loadImage when node not found', () => { + const { node, mock } = makeNode(); + const priv = getPrivateNode(node); + mock.getStage().findOne.mockReturnValue(null); + const loadSpy = vi.spyOn(priv, 'loadImage').mockImplementation(() => {}); + const nodeInstance = new Konva.Group({ id: 'missing-node' }); + + node.forceLoadFallbackImage(nodeInstance, 'data:image/png;base64,x'); + + expect(loadSpy).not.toHaveBeenCalled(); + }); + }); + + describe('38 — onRender: group crop callbacks', () => { + it('38.1 group.triggerCrop() calls node.triggerCrop', () => { + const { node, mock } = makeNode(); + mock.getActiveAction.mockReturnValue(SELECTION_TOOL_ACTION_NAME); + const group = node.onRender(defaultProps()) as Konva.Group; + getSelectionPlugin(mock).getSelectedNodes.mockReturnValue([group]); + const triggerCropSpy = vi.spyOn(node, 'triggerCrop').mockImplementation(() => {}); + + group.triggerCrop?.(); + + expect(triggerCropSpy).toHaveBeenCalledWith(group, { cmdCtrl: { triggered: false } }); + }); + + it('38.2 group.closeCrop() calls node.closeCrop', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const closeCropSpy = vi.spyOn(node, 'closeCrop').mockImplementation(() => {}); + + group.closeCrop?.(WEAVE_IMAGE_CROP_END_TYPE.CANCEL); + + expect(closeCropSpy).toHaveBeenCalledWith(group, WEAVE_IMAGE_CROP_END_TYPE.CANCEL); + }); + + it('38.3 group.resetCrop() returns early when stage.findOne returns null', () => { + const { node, mock } = makeNode(); + mock.getStage().findOne.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + + expect(() => group.resetCrop?.()).not.toThrow(); + }); + + it('38.4 group.resetCrop() calls imageCrop.unCrop when image found', () => { + const { node, mock } = makeNode(); + const { group: imageGroup, internalImage, cropGroup } = createRenderableGroup(); + mock.getStage().findOne.mockReturnValue(imageGroup); + const unCropSpy = vi.spyOn(WeaveImageCrop.prototype, 'unCrop').mockImplementation(() => {}); + const renderedGroup = node.onRender(defaultProps()) as Konva.Group; + + renderedGroup.resetCrop?.(); + + void internalImage; + void cropGroup; + expect(unCropSpy).toHaveBeenCalled(); + }); + }); + + describe('39 — onRender: nodeDragStart and cmdCtrl early returns', () => { + it('39.1 nodeDragStart returns early when utilityLayer is null', () => { + const { node, mock } = makeNode(); + mock.getUtilityLayer.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + + expect(() => fireKonvaEvent(group, 'nodeDragStart')).not.toThrow(); + }); + + it('39.2 nodeDragStart returns early when transformer is null', () => { + const { node, mock } = makeNode(); + getSelectionPlugin(mock).getTransformer.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + + expect(() => fireKonvaEvent(group, 'nodeDragStart')).not.toThrow(); + }); + + it('39.3 onCmdCtrlPressed returns early when utilityLayer is null', () => { + const { node, mock } = makeNode(); + mock.getUtilityLayer.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + vi.spyOn(group, 'isDragging').mockReturnValue(false); + + expect(() => fireKonvaEvent(group, 'onCmdCtrlPressed')).not.toThrow(); + }); + + it('39.4 onCmdCtrlPressed returns early when isDragging', () => { + const { node, mock } = makeNode(); + const transformer = { hide: vi.fn(), show: vi.fn() }; + getSelectionPlugin(mock).getTransformer.mockReturnValue(transformer); + const group = node.onRender(defaultProps()) as Konva.Group; + vi.spyOn(group, 'isDragging').mockReturnValue(true); + + fireKonvaEvent(group, 'onCmdCtrlPressed'); + + expect(transformer.hide).not.toHaveBeenCalled(); + }); + + it('39.5 onCmdCtrlPressed returns early when transformer is null', () => { + const { node, mock } = makeNode(); + getSelectionPlugin(mock).getTransformer.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + vi.spyOn(group, 'isDragging').mockReturnValue(false); + + expect(() => fireKonvaEvent(group, 'onCmdCtrlPressed')).not.toThrow(); + }); + + it('39.6 onCmdCtrlReleased returns early when utilityLayer is null', () => { + const { node, mock } = makeNode(); + mock.getUtilityLayer.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + vi.spyOn(group, 'isDragging').mockReturnValue(false); + + expect(() => fireKonvaEvent(group, 'onCmdCtrlReleased')).not.toThrow(); + }); + + it('39.7 onCmdCtrlReleased returns early when isDragging', () => { + const { node, mock } = makeNode(); + const transformer = { hide: vi.fn(), show: vi.fn() }; + getSelectionPlugin(mock).getTransformer.mockReturnValue(transformer); + const group = node.onRender(defaultProps()) as Konva.Group; + vi.spyOn(group, 'isDragging').mockReturnValue(true); + + fireKonvaEvent(group, 'onCmdCtrlReleased'); + + expect(transformer.show).not.toHaveBeenCalled(); + }); + + it('39.8 onCmdCtrlReleased returns early when transformer is null', () => { + const { node, mock } = makeNode(); + getSelectionPlugin(mock).getTransformer.mockReturnValue(null); + const group = node.onRender(defaultProps()) as Konva.Group; + vi.spyOn(group, 'isDragging').mockReturnValue(false); + + expect(() => fireKonvaEvent(group, 'onCmdCtrlReleased')).not.toThrow(); + }); + }); + + describe('40 — onUpdate: cropInfo / cropSize / empty imageURL', () => { + it('40.1 cropInfo truthy branch is applied', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const cropInfo = { x: 0, y: 0, width: 50, height: 50, scaleX: 1 }; + + node.onUpdate(group, defaultProps({ cropInfo })); + + expect(group.getAttrs().cropInfo).toEqual(cropInfo); + }); + + it('40.2 cropSize truthy branch is applied', () => { + const { node } = makeNode(); + const group = node.onRender(defaultProps()) as Konva.Group; + const cropSize = { width: 50, height: 50 }; + + node.onUpdate(group, defaultProps({ cropSize })); + + expect(group.getAttrs().cropSize).toEqual(cropSize); + }); + + it('40.3 empty actualImageURL triggers forceLoadImage', () => { + const { node, mock } = makeNode(); + const group = node.onRender(defaultProps({ imageURL: '' })) as Konva.Group; + group.setAttr('imageURL', ''); + const imageNode = new Konva.Group({ id: 'test-image' }); + mock.getStage().findOne.mockReturnValue(imageNode); + const priv = getPrivateNode(node); + const loadSpy = vi.spyOn(priv, 'loadImage').mockImplementation(() => {}); + + node.onUpdate(group, defaultProps({ imageURL: 'http://example.com/new.jpg' })); + + expect(loadSpy).toHaveBeenCalled(); + }); + }); + + describe('41 — triggerCrop: missing image early return and show callback', () => { + it('41.1 returns early when stage.findOne returns null for image', () => { + const { node, mock } = makeNode(); + mock.getActiveAction.mockReturnValue(SELECTION_TOOL_ACTION_NAME); + const imageNode = new Konva.Group({ id: 'test-image', cropping: false }); + getSelectionPlugin(mock).getSelectedNodes.mockReturnValue([imageNode]); + mock.getStage().findOne.mockReturnValue(null); + + expect(() => node.triggerCrop(imageNode, { cmdCtrl: { triggered: false } })).not.toThrow(); + expect(mock.getStage().mode).toHaveBeenCalledWith(WEAVE_STAGE_IMAGE_CROPPING_MODE); + }); + + it('41.2 show callback covers crop-end branch', () => { + const { node, mock } = makeNode(); + mock.getActiveAction.mockReturnValue(SELECTION_TOOL_ACTION_NAME); + const imageNode = new Konva.Group({ id: 'test-image', cropping: false }); + const internalImage = new Konva.Image({ id: 'test-image-image' } as Konva.ImageConfig); + const cropGroup = new Konva.Group({ id: 'test-image-cropGroup' }); + imageNode.add(internalImage); + imageNode.add(cropGroup); + getSelectionPlugin(mock).getSelectedNodes.mockReturnValue([imageNode]); + mock.getStage().findOne.mockReturnValue(imageNode); + vi.spyOn(WeaveImageCrop.prototype, 'show').mockImplementation((cb: () => void) => { cb(); }); + + node.triggerCrop(imageNode, { cmdCtrl: { triggered: false } }); + + expect(mock.emitEvent).toHaveBeenCalledWith('onImageCropEnd', expect.objectContaining({ instance: imageNode })); + }); + }); + + describe('42 — updateNodeState with imageId', () => { + it('42.1 imageId is included when provided', () => { + const base = WeaveImageNode.defaultState('test-image'); + const result = WeaveImageNode.updateNodeState(base, defaultProps({ imageId: 'asset-42' })); + expect((result.props as Record)['imageId']).toBe('asset-42'); + }); + + it('42.2 imageId is omitted when not provided', () => { + const base = WeaveImageNode.defaultState('test-image'); + const result = WeaveImageNode.updateNodeState(base, defaultProps()); + expect((result.props as Record)['imageId']).toBeUndefined(); + }); }); }); diff --git a/code/packages/sdk/src/nodes/image/constants.ts b/code/packages/sdk/src/nodes/image/constants.ts index 6e547f660..a856be728 100644 --- a/code/packages/sdk/src/nodes/image/constants.ts +++ b/code/packages/sdk/src/nodes/image/constants.ts @@ -47,7 +47,9 @@ export const WEAVE_IMAGE_DEFAULT_CONFIG: WeaveImageProperties = { retryDelayMs: 2000, }, crossOrigin: 'anonymous', - useFallbackImage: true, + imageFallback: { + enabled: false, + }, cropMode: { enabled: true, triggers: { diff --git a/code/packages/sdk/src/nodes/image/image.ts b/code/packages/sdk/src/nodes/image/image.ts index f826e2734..375c0afbe 100644 --- a/code/packages/sdk/src/nodes/image/image.ts +++ b/code/packages/sdk/src/nodes/image/image.ts @@ -46,6 +46,7 @@ export class WeaveImageNode extends WeaveNode { protected imageCrop!: WeaveImageCrop | null; protected nodeType: string = WEAVE_IMAGE_NODE_TYPE; protected notUsedImagesCleanup!: NodeJS.Timeout | null; + protected imageFallbackURL!: Record; private readonly cursorsFallback: WeaveImageCursors = { loading: 'wait', }; @@ -70,6 +71,33 @@ export class WeaveImageNode extends WeaveNode { this.imageTryoutIds = {}; this.imageTryoutAttempts = {}; this.imageFallback = {}; + this.imageFallbackURL = {}; + } + + getImageFallbackId(params: WeaveElementAttributes): string | undefined { + if (this.config.imageFallback.enabled) { + return this.config.imageFallback.getId(params); + } + return undefined; + } + + saveImageFallback(params: WeaveElementAttributes, dataURL: string): void { + if (this.config.imageFallback.enabled) { + this.config.imageFallback.onPersist(params, dataURL); + } + } + + cacheImageFallbackURL(params: WeaveElementAttributes, dataURL?: string) { + if (this.config.imageFallback.enabled) { + const imageFallbackId = this.config.imageFallback.getId(params); + let finalDataURL: string = ''; + if (dataURL) { + finalDataURL = dataURL; + } else { + finalDataURL = this.config.imageFallback.getDataURL(imageFallbackId); + } + this.imageFallbackURL[imageFallbackId] = finalDataURL; + } } private setupNotUsedImagesCleanup() { @@ -95,9 +123,10 @@ export class WeaveImageNode extends WeaveNode { const bindedCleanupHandler = cleanupHandler.bind(this); - if (!this.notUsedImagesCleanup) { - setTimeout(bindedCleanupHandler, this.config.cleanup.intervalMs); - } + this.notUsedImagesCleanup ??= setTimeout( + bindedCleanupHandler, + this.config.cleanup.intervalMs + ); } preloadCursors() { @@ -417,8 +446,8 @@ export class WeaveImageNode extends WeaveNode { const hasFinalImageLoaded = this.imageSource[id] && imageProps.imageURL; const hasFallbackAndFinalImageNotLoaded = !imageProps.imageURL && - this.imageFallback[id] && - this.config.useFallbackImage; + this.imageFallback[id] !== undefined && + this.config.imageFallback.enabled; if (hasFinalImageLoaded || hasFallbackAndFinalImageNotLoaded) { imagePlaceholder?.destroy(); @@ -464,7 +493,7 @@ export class WeaveImageNode extends WeaveNode { this.updateImageCrop(image); } else { this.updatePlaceholderSize(image); - this.loadImage(imageProps, image, true); + this.loadImage(imageProps, image, this.config.imageFallback.enabled); } if (this.config.performance.cache.enabled) { @@ -807,6 +836,9 @@ export class WeaveImageNode extends WeaveNode { const id = nodeInstance.getAttrs().id; const node = nodeInstance as Konva.Group; + const actualImageURL = `${nodeInstance.getAttrs().imageURL ?? ''}`; + const nextImageURL = `${nextProps.imageURL ?? ''}`; + nodeInstance.setAttrs({ ...nextProps, ...(nextProps.cropInfo @@ -834,7 +866,15 @@ export class WeaveImageNode extends WeaveNode { delete internalImageProps.imageURL; delete internalImageProps.zIndex; - // Loading image + if (actualImageURL === '' && nextImageURL !== '') { + nodeInstance.setAttrs({ + ...nodeInstance.getAttrs(), + imageURL: nextProps.imageURL, + }); + this.forceLoadImage(nodeInstance); + } + + // Not loaded image if (!this.imageState[id ?? '']?.loaded) { imagePlaceholder?.setAttrs({ ...internalImageProps, @@ -1086,12 +1126,23 @@ export class WeaveImageNode extends WeaveNode { let preloadFunction = this.preloadImage.bind(this); - const loadFallback = - useFallback && imageProps.imageFallback && this.config.useFallbackImage; + const loadFallback = useFallback; - if (loadFallback) { + let fallbackImage = undefined; + if (loadFallback && this.config.imageFallback.enabled) { preloadFunction = this.preloadFallbackImage.bind(this); - realImageURL = imageProps.imageFallback; + const imageFallbackId = this.config.imageFallback.getId(imageProps); + if (!this.imageFallbackURL[imageFallbackId]) { + const dataURL = this.config.imageFallback.getDataURL(imageFallbackId); + this.cacheImageFallbackURL(imageProps, dataURL); + } + if (this.imageFallbackURL[imageFallbackId]) { + fallbackImage = this.imageFallbackURL[imageFallbackId]; + } + } + + if (fallbackImage) { + realImageURL = fallbackImage; } this.loadAsyncElement(id); @@ -1206,7 +1257,7 @@ export class WeaveImageNode extends WeaveNode { isInvalidImage = true; } - if (!this.config.useFallbackImage && !isInvalidImage) { + if (!this.config.imageFallback.enabled && !isInvalidImage) { const tryoutAttempts = this.imageTryoutAttempts[id] ?? 0; if ( @@ -1236,7 +1287,7 @@ export class WeaveImageNode extends WeaveNode { } if ( - this.config.useFallbackImage && + this.config.imageFallback.enabled && !useFallback && !loadTryout && imageProps.imageFallback @@ -1352,10 +1403,18 @@ export class WeaveImageNode extends WeaveNode { } } + isImageFallbackEnabled(): boolean { + return this.config.imageFallback.enabled; + } + getFallbackImageSource(imageId: string): HTMLImageElement | undefined { return this.imageFallback[imageId]; } + getFallbackImageSourceURL(imageId: string): string | undefined { + return this.imageFallbackURL[imageId]; + } + getImageSource(imageId: string): HTMLImageElement | undefined { return this.imageSource[imageId]; } @@ -1441,6 +1500,24 @@ export class WeaveImageNode extends WeaveNode { } } + forceLoadFallbackImage( + nodeInstance: WeaveElementInstance, + dataURL: string + ): void { + const nodeId = nodeInstance.getAttrs().id ?? ''; + const node = this.instance.getStage().findOne(`#${nodeId}`); + + if (this.imageTryoutIds[nodeId]) { + clearTimeout(this.imageTryoutIds[nodeId]); + delete this.imageTryoutIds[nodeId]; + } + + if (node) { + this.cacheImageFallbackURL(node.getAttrs(), dataURL); + this.loadImage(node.getAttrs(), node as Konva.Group, true); + } + } + onDestroy(nodeInstance: WeaveElementInstance) { const nodeId = nodeInstance.getAttrs().id ?? ''; @@ -1565,9 +1642,6 @@ export class WeaveImageNode extends WeaveNode { height: props.height, rotation: props.rotation, imageURL: props.imageURL, - ...(props.imageFallback && { - imageFallback: props.imageFallback, - }), ...(props.imageId && { imageId: props.imageId, }), @@ -1599,9 +1673,6 @@ export class WeaveImageNode extends WeaveNode { height: nextProps.height, rotation: nextProps.rotation, imageURL: nextProps.imageURL, - ...(nextProps.imageFallback && { - imageFallback: nextProps.imageFallback, - }), ...(nextProps.imageId && { imageId: nextProps.imageId, }), @@ -1647,12 +1718,6 @@ export class WeaveImageNode extends WeaveNode { imageURL: z .string() .describe('The URL of the image to be rendered by the node'), - imageFallback: z - .string() - .optional() - .describe( - 'The fallback image to display while the image to loads, it must be a base64 string with the format: data:image/{format};base64,{data}' - ), adding: z.boolean().default(false), diff --git a/code/packages/sdk/src/nodes/image/types.ts b/code/packages/sdk/src/nodes/image/types.ts index 1669a804e..3bbf215c8 100644 --- a/code/packages/sdk/src/nodes/image/types.ts +++ b/code/packages/sdk/src/nodes/image/types.ts @@ -65,8 +65,17 @@ export type WeaveImageProperties = { }; crossOrigin: ImageCrossOrigin; transform?: WeaveNodeTransformerProperties; - useFallbackImage?: boolean; urlTransformer?: URLTransformerFunction; + imageFallback: + | { + enabled: true; + getId: (params: WeaveElementAttributes) => string; + getDataURL: (imageFallbackId: string) => string; + onPersist: (params: WeaveElementAttributes, dataURL: string) => void; + } + | { + enabled: false; + }; onDblClick?: (instance: WeaveImageNode, node: Konva.Group) => void; cropMode: { enabled: boolean; diff --git a/code/packages/sdk/src/nodes/node.ts b/code/packages/sdk/src/nodes/node.ts index 29f7f3b48..c56d457d2 100644 --- a/code/packages/sdk/src/nodes/node.ts +++ b/code/packages/sdk/src/nodes/node.ts @@ -355,7 +355,9 @@ export abstract class WeaveNode implements WeaveNodeBase { ): boolean { const activeGroupId = selectionPlugin.getActiveGroupContext(); if (activeGroupId === null) return false; - const activeGroupNode = this.instance.getStage().findOne(`#${activeGroupId}`); + const activeGroupNode = this.instance + .getStage() + .findOne(`#${activeGroupId}`); if (!activeGroupNode) return false; const hoveredId = node.getAttrs().id ?? ''; let current: Konva.Node | null = activeGroupNode; @@ -836,13 +838,23 @@ export abstract class WeaveNode implements WeaveNodeBase { const isInGroupContext = (this.getSelectionPlugin()?.getActiveGroupContext() ?? null) !== null; - if ( - !isInGroupContext && + const shouldUpdateMove = this.isSelecting() && this.getSelectionPlugin()?.getSelectedNodes().length === 1 && (realNodeTarget.getAttrs().lockToContainer === undefined || - !realNodeTarget.getAttrs().lockToContainer) - ) { + !realNodeTarget.getAttrs().lockToContainer); + + if (isInGroupContext && shouldUpdateMove) { + if (realNodeTarget.getAttrs().isCloned) { + this.instance.getCloningManager().removeClone(realNodeTarget); + } + + this.instance.updateNodeNT( + this.serialize(realNodeTarget as WeaveElementInstance) + ); + } + + if (!isInGroupContext && shouldUpdateMove) { this.instance.stateTransactional(() => { clearContainerTargets(this.instance); diff --git a/code/packages/sdk/src/plugins/nodes-selection/nodes-selection.ts b/code/packages/sdk/src/plugins/nodes-selection/nodes-selection.ts index cf0e45e73..77914594b 100644 --- a/code/packages/sdk/src/plugins/nodes-selection/nodes-selection.ts +++ b/code/packages/sdk/src/plugins/nodes-selection/nodes-selection.ts @@ -108,6 +108,7 @@ export class WeaveNodesSelectionPlugin this._activeGroupContext = null; } + getName(): string { return WEAVE_NODES_SELECTION_KEY; } diff --git a/code/packages/sdk/src/plugins/nodes-snapping/__tests__/nodes-snapping.custom-guides.test.ts b/code/packages/sdk/src/plugins/nodes-snapping/__tests__/nodes-snapping.custom-guides.test.ts index e8325ae26..795a6f7f5 100644 --- a/code/packages/sdk/src/plugins/nodes-snapping/__tests__/nodes-snapping.custom-guides.test.ts +++ b/code/packages/sdk/src/plugins/nodes-snapping/__tests__/nodes-snapping.custom-guides.test.ts @@ -30,6 +30,7 @@ vi.mock('../nodes-snapping.guide-distance-to-target-info', () => ({ handleTarget = vi.fn(); cleanup = vi.fn(); cleanupTarget = vi.fn(); + handleDistanceLine = vi.fn(); }, })); vi.mock('../utils', () => ({ @@ -40,8 +41,9 @@ vi.mock('../nodes-selection/nodes-selection', () => ({})); // ─── imports ────────────────────────────────────────────────────────────────── +import Konva from 'konva'; import { WeaveNodesSnappingCustomGuides } from '../nodes-snapping.custom-guides'; -import { GUIDE_KIND } from '../constants'; +import { GUIDE_KIND, GUIDE_ORIENTATION } from '../constants'; import type { Guide } from '../types'; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -283,6 +285,13 @@ describe('saveCustomGuide', () => { expect(manager.getAllCustomGuides()['newContainer']).toHaveLength(1); }); + it('initializes customGuides map when it is null before saving', () => { + const { manager } = setup(); + (manager as unknown as { customGuides: null }).customGuides = null as never; + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + expect(manager.getAllCustomGuides()).toHaveProperty('c1'); + }); + it('appends to existing container array', () => { const { manager } = setup(); manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); @@ -615,3 +624,918 @@ describe('getSelectedGuide', () => { expect(manager.getSelectedGuide()).toBeNull(); }); }); + +// ─── deserialize (via initialize with persistence + string metadata) ────────── + +describe('deserialize via initialize', () => { + it('returns empty object when guides metadata is absent', async () => { + const { manager } = setup({ persistence: true, metadata: {} }); + await manager.initialize(); + expect(manager.getAllCustomGuides()).toEqual({}); + }); + + it('parses guides from a serialized JSON string in metadata', async () => { + const guide = makeGuide('g1', 'V', 'c1', true); + const { manager } = setup({ + persistence: true, + metadata: { guides: JSON.stringify({ c1: [guide] }) }, + }); + await manager.initialize(); + expect(manager.getAllCustomGuides()).toHaveProperty('c1'); + }); + + it('returns empty object on malformed JSON string (catch branch)', async () => { + const { manager } = setup({ + persistence: true, + metadata: { guides: '{invalid-json' }, + }); + await manager.initialize(); + // deserialize catches the parse error and returns [], so customGuides is [] + expect(manager.getAllCustomGuides()).toBeDefined(); + }); +}); + +// ─── initialize persistence hooks and onStateMetadataChange ────────────────── + +describe('initialize persistence hooks', () => { + it('registers weave:onRemoveNode hook when persistence is enabled', async () => { + const { manager, weave } = setup({ persistence: true, metadata: {} }); + await manager.initialize(); + expect(weave.getHooks().hook).toHaveBeenCalledWith('weave:onRemoveNode', expect.any(Function)); + }); + + it('hook fires deleteContainerGuides when a frame node is removed', async () => { + const guide = makeGuide('g1', 'V', 'frameId', true); + const { manager, weave } = setup({ + persistence: true, + metadata: { guides: { frameId: [guide] } }, + }); + await manager.initialize(); + const hookHandler = weave._hooks['weave:onRemoveNode'][0]; + hookHandler({ getAttrs: () => ({ nodeType: 'frame' }), id: () => 'frameId' }); + expect(manager.getAllCustomGuides()).not.toHaveProperty('frameId'); + }); + + it('does NOT fire deleteContainerGuides for non-frame node types', async () => { + const guide = makeGuide('g1', 'V', 'c1', true); + const { manager, weave } = setup({ + persistence: true, + metadata: { guides: { c1: [guide] } }, + }); + await manager.initialize(); + const hookHandler = weave._hooks['weave:onRemoveNode'][0]; + hookHandler({ getAttrs: () => ({ nodeType: 'rectangle' }), id: () => 'c1' }); + expect(manager.getAllCustomGuides()).toHaveProperty('c1'); + }); + + it('registers onStateMetadataChange listener when persistence is enabled', async () => { + const { manager, weave } = setup({ persistence: true, metadata: {} }); + await manager.initialize(); + expect(weave.addEventListener).toHaveBeenCalledWith('onStateMetadataChange', expect.any(Function)); + }); + + it('updates customGuides when onStateMetadataChange fires', async () => { + const guide = makeGuide('g1', 'V', 'c1', true); + const stage = makeStage(); + const weave = makeWeave(stage, {}); + weave.getMetadata + .mockReturnValueOnce({}) // initial call in initialize + .mockReturnValue({ guides: { c1: [guide] } }); // subsequent calls + + const layer = makeLayer(); + layer.find = vi.fn().mockReturnValue([]); + + const manager = new WeaveNodesSnappingCustomGuides(weave as never, layer as never, { + persistence: { enabled: true }, + movement: { delta: 0.5, shiftDelta: 10 }, + style: DEFAULT_STYLE as never, + targetDistanceStyle: DEFAULT_TARGET_STYLE, + }); + await manager.initialize(); + + const handler = weave._eventListeners['onStateMetadataChange'][0]; + handler(); + expect(manager.getAllCustomGuides()).toHaveProperty('c1'); + }); + + it('hides and re-renders guides when onStateMetadataChange fires', async () => { + const stage = makeStage(); + const weave = makeWeave(stage, {}); + weave.getMetadata.mockReturnValue({}); + const layer = makeLayer(); + const destroyMock = vi.fn(); + layer.find = vi.fn().mockReturnValue([{ destroy: destroyMock }]); + + const manager = new WeaveNodesSnappingCustomGuides(weave as never, layer as never, { + persistence: { enabled: true }, + movement: { delta: 0.5, shiftDelta: 10 }, + style: DEFAULT_STYLE as never, + targetDistanceStyle: DEFAULT_TARGET_STYLE, + }); + await manager.initialize(); + + const handler = weave._eventListeners['onStateMetadataChange'][0]; + handler(); + expect(destroyMock).toHaveBeenCalled(); // hideAllCustomGuides called + }); +}); + +// ─── initialize — window event listeners ───────────────────────────────────── + +describe('initialize window event listeners', () => { + it('pointermove without Alt does not call handleDistanceLine', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + const event = Object.assign(new Event('pointermove'), { altKey: false }); + window.dispatchEvent(event); + expect(manager.getSelectedGuide()).toBeNull(); + }); + + it('pointermove with Alt but not dragging does not throw', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + (manager as unknown as { isDragging: boolean }).isDragging = false; + const event = Object.assign(new Event('pointermove'), { altKey: true }); + window.dispatchEvent(event); + expect(manager.getSelectedGuide()).toBeNull(); + }); + + it('pointermove with Alt and dragging calls handleDistanceLine (with selected guide)', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + + const guide = makeGuide('g1'); + manager.saveCustomGuide(guide); + manager.selectGuide(guide); + (manager as unknown as { isDragging: boolean }).isDragging = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (manager.guideDistanceToTargetInfo as unknown as any).handleDistanceLine = vi.fn(); + + const event = Object.assign(new Event('pointermove'), { altKey: true }); + window.dispatchEvent(event); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((manager.guideDistanceToTargetInfo as unknown as any).handleDistanceLine).toHaveBeenCalled(); + }); + + it('keyup with Alt key calls cleanup', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + const event = new KeyboardEvent('keyup', { key: 'Alt' }); + window.dispatchEvent(event); + expect(manager.guideDistanceToTargetInfo.cleanup).toHaveBeenCalled(); + }); + + it('keyup with Option key calls cleanup', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + const event = new KeyboardEvent('keyup', { key: 'Option' }); + window.dispatchEvent(event); + expect(manager.guideDistanceToTargetInfo.cleanup).toHaveBeenCalled(); + }); + + it('keyup with non-Alt key does not call cleanup', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + (manager.guideDistanceToTargetInfo.cleanup as ReturnType).mockClear(); + const event = new KeyboardEvent('keyup', { key: 'Shift' }); + window.dispatchEvent(event); + expect(manager.guideDistanceToTargetInfo.cleanup).not.toHaveBeenCalled(); + }); + + it('keydown with Alt key when not dragging does not call handleDistanceLine', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + (manager as unknown as { isDragging: boolean }).isDragging = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (manager.guideDistanceToTargetInfo as unknown as any).handleDistanceLine = vi.fn(); + const event = new KeyboardEvent('keydown', { key: 'Alt' }); + window.dispatchEvent(event); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((manager.guideDistanceToTargetInfo as unknown as any).handleDistanceLine).not.toHaveBeenCalled(); + }); + + it('keydown with Alt key when dragging calls handleDistanceLine', async () => { + const { manager } = setup({ persistence: false }); + await manager.initialize(); + + const guide = makeGuide('g1'); + manager.saveCustomGuide(guide); + manager.selectGuide(guide); + (manager as unknown as { isDragging: boolean }).isDragging = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (manager.guideDistanceToTargetInfo as unknown as any).handleDistanceLine = vi.fn(); + + const event = new KeyboardEvent('keydown', { key: 'Alt' }); + window.dispatchEvent(event); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((manager.guideDistanceToTargetInfo as unknown as any).handleDistanceLine).toHaveBeenCalled(); + }); +}); + +// ─── editCustomGuide persist branch ────────────────────────────────────────── + +describe('editCustomGuide persist branch', () => { + it('calls saveMetadata when edited guide has persist=true', () => { + const { manager, weave } = setup({ persistence: true }); + const guide = makeGuide('g1', 'V', 'mainLayer', true); + manager.saveCustomGuide(guide); + weave.saveMetadata.mockClear(); + manager.editCustomGuide({ ...guide, value: 999 }); + expect(weave.saveMetadata).toHaveBeenCalled(); + }); + + it('does NOT call saveMetadata when edited guide has persist=false', () => { + const { manager, weave } = setup({ persistence: true }); + const guide = makeGuide('g1', 'V', 'mainLayer', false); + manager.saveCustomGuide(guide); + weave.saveMetadata.mockClear(); + manager.editCustomGuide({ ...guide, value: 999 }); + expect(weave.saveMetadata).not.toHaveBeenCalled(); + }); +}); + +// ─── deleteCustomGuide persist branch ──────────────────────────────────────── + +describe('deleteCustomGuide persist branch', () => { + it('calls saveMetadata when deleted guide has persist=true', () => { + const { manager, weave } = setup({ persistence: true }); + const guide = makeGuide('g1', 'V', 'mainLayer', true); + manager.saveCustomGuide(guide); + weave.saveMetadata.mockClear(); + manager.deleteCustomGuide(guide); + expect(weave.saveMetadata).toHaveBeenCalled(); + }); + + it('does NOT call saveMetadata when deleted guide has persist=false', () => { + const { manager, weave } = setup({ persistence: true }); + const guide = makeGuide('g1', 'V', 'mainLayer', false); + manager.saveCustomGuide(guide); + weave.saveMetadata.mockClear(); + manager.deleteCustomGuide(guide); + expect(weave.saveMetadata).not.toHaveBeenCalled(); + }); +}); + +// ─── stagePanChangeHandler / zoomChangeHandler ──────────────────────────────── + +describe('stagePanChangeHandler and zoomChangeHandler', () => { + it('fires renderAllVisibleCustomGuides via onStageMove event', () => { + const { manager, weave, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + manager.toggleCustomGuides('c1'); + const handler = weave._eventListeners['onStageMove']?.[0]; + handler?.(); + expect(layer.batchDraw).toHaveBeenCalled(); + }); + + it('fires renderAllVisibleCustomGuides via onZoomChange event', () => { + const { manager, weave, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + manager.toggleCustomGuides('c1'); + const handler = weave._eventListeners['onZoomChange']?.[0]; + handler?.(); + expect(layer.batchDraw).toHaveBeenCalled(); + }); +}); + +// ─── renderGuide (via renderCustomGuides) ──────────────────────────────────── + +describe('renderGuide via renderCustomGuides', () => { + it('renders a VERTICAL guide from mainLayer — creates new guide node', () => { + const { manager, stage, layer } = setup({ persistence: false }); + stage.findOne = vi.fn().mockReturnValue(null); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'mainLayer')); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + mainLayer: true, + }; + manager.renderCustomGuides('mainLayer'); + expect(layer.batchDraw).toHaveBeenCalled(); + }); + + it('renders a HORIZONTAL guide from mainLayer — creates new guide node', () => { + const { manager, stage, layer } = setup({ persistence: false }); + stage.findOne = vi.fn().mockReturnValue(null); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'H', 'mainLayer')); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + mainLayer: true, + }; + manager.renderCustomGuides('mainLayer'); + expect(layer.batchDraw).toHaveBeenCalled(); + }); + + it('applies VERTICAL container offset for non-mainLayer container', () => { + const { manager, stage, layer } = setup({ persistence: false }); + const containerMock = { + getClientRect: vi.fn().mockReturnValue({ x: 50, y: 30, width: 200, height: 150 }), + setAttrs: vi.fn(), + getAttr: vi.fn(), + }; + stage.findOne = vi.fn().mockReturnValue(containerMock); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'frameContainer')); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + frameContainer: true, + }; + manager.renderCustomGuides('frameContainer'); + expect(layer.batchDraw).toHaveBeenCalled(); + }); + + it('applies HORIZONTAL container offset for non-mainLayer container', () => { + const { manager, stage, layer } = setup({ persistence: false }); + const containerMock = { + getClientRect: vi.fn().mockReturnValue({ x: 50, y: 30, width: 200, height: 150 }), + setAttrs: vi.fn(), + getAttr: vi.fn(), + }; + stage.findOne = vi.fn().mockReturnValue(containerMock); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'H', 'frameContainer')); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + frameContainer: true, + }; + manager.renderCustomGuides('frameContainer'); + expect(layer.batchDraw).toHaveBeenCalled(); + }); + + it('updates existing guide node via setAttrs when already present in stage', () => { + const { manager, stage, layer } = setup({ persistence: false }); + const existingNode = { + setAttrs: vi.fn(), + getAttr: vi.fn().mockReturnValue({ containerId: 'mainLayer' }), + destroy: vi.fn(), + getClientRect: vi.fn().mockReturnValue({ x: 0, y: 0, width: 0, height: 0 }), + }; + stage.findOne = vi.fn().mockImplementation((selector: string) => { + if (selector === '#g1') return existingNode; + return null; + }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'mainLayer')); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + mainLayer: true, + }; + manager.renderCustomGuides('mainLayer'); + expect(existingNode.setAttrs).toHaveBeenCalled(); + }); + + it('renders guide using static kind style', () => { + const { manager, stage, layer } = setup({ persistence: false }); + stage.findOne = vi.fn().mockReturnValue(null); + layer.find = vi.fn().mockReturnValue([]); + const staticGuide = { + guideId: 'sg1', + orientation: GUIDE_ORIENTATION.VERTICAL, + value: 50, + kind: GUIDE_KIND.STATIC, + containerId: 'mainLayer', + persist: false, + }; + manager.saveCustomGuide(staticGuide as Guide); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + mainLayer: true, + }; + manager.renderCustomGuides('mainLayer'); + expect(layer.batchDraw).toHaveBeenCalled(); + }); + + it('renders selected guide node with selected state style', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + + const existingNode = { + setAttrs: vi.fn(), + getAttr: vi.fn(), + destroy: vi.fn(), + getClientRect: vi.fn().mockReturnValue({ x: 0, y: 0, width: 0, height: 0 }), + }; + stage.findOne = vi.fn().mockImplementation((selector: string) => { + if (selector === '#g1') return existingNode; + return null; + }); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + mainLayer: true, + }; + manager.renderCustomGuides('mainLayer'); + + const callArgs = existingNode.setAttrs.mock.calls[0]?.[0]; + // stroke should be the selected stroke (DEFAULT_STYLE.custom.selected.stroke) + expect(callArgs?.stroke).toBe('#00f'); + }); +}); + +// ─── toggleCustomGuides ─────────────────────────────────────────────────────── + +describe('toggleCustomGuides', () => { + it('sets container visibility to true on first toggle', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + manager.toggleCustomGuides('c1'); + expect(manager.isCustomGuidesVisible('c1')).toBe(true); + }); + + it('sets container visibility to false on second toggle', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + manager.toggleCustomGuides('c1'); + manager.toggleCustomGuides('c1'); + expect(manager.isCustomGuidesVisible('c1')).toBe(false); + }); + + it('emits onCustomGuidesChange with visibility on toggle', () => { + const { manager, weave, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + weave.emitEvent.mockClear(); + manager.toggleCustomGuides('c1'); + expect(weave.emitEvent).toHaveBeenCalledWith( + 'snappingManager:onCustomGuidesChange', + expect.objectContaining({ visibility: expect.any(Object) }) + ); + }); + + it('sets up stage events when first guide becomes visible', () => { + const { manager, stage, layer, weave } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + manager.toggleCustomGuides('c1'); + expect(stage.on).toHaveBeenCalledWith('pointerclick', expect.any(Function)); + expect(weave.addEventListener).toHaveBeenCalledWith('onNodesChange', expect.any(Function)); + expect(weave.addEventListener).toHaveBeenCalledWith('onStageMove', expect.any(Function)); + expect(weave.addEventListener).toHaveBeenCalledWith('onZoomChange', expect.any(Function)); + }); + + it('tears down stage events when all guides become hidden', () => { + const { manager, stage, layer, weave } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'V', 'c1')); + manager.toggleCustomGuides('c1'); // visible + manager.toggleCustomGuides('c1'); // hidden + expect(stage.off).toHaveBeenCalledWith('pointerclick', expect.any(Function)); + expect(weave.removeEventListener).toHaveBeenCalledWith('onNodesChange', expect.any(Function)); + expect(weave.removeEventListener).toHaveBeenCalledWith('onStageMove', expect.any(Function)); + expect(weave.removeEventListener).toHaveBeenCalledWith('onZoomChange', expect.any(Function)); + }); + + it('clears selectedGuide when toggling off', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'c1'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('c1'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + manager.toggleCustomGuides('c1'); + expect(manager.getSelectedGuide()).toBeNull(); + }); +}); + +// ─── onNodesSelectedChange (via onNodesChange listener) ─────────────────────── + +describe('onNodesSelectedChange', () => { + it('clears selectedGuide when nodes are selected', () => { + const { manager, weave, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'c1'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('c1'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + const handler = weave._eventListeners['onNodesChange']?.[0]; + handler?.([{ id: 'someNode' }]); + expect(manager.getSelectedGuide()).toBeNull(); + }); + + it('does nothing when nodes array is empty', () => { + const { manager, weave, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'c1'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('c1'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + const handler = weave._eventListeners['onNodesChange']?.[0]; + handler?.([]); + expect(manager.getSelectedGuide()).toBe(guide); + }); +}); + +// ─── pointerClickHandler ────────────────────────────────────────────────────── + +describe('pointerClickHandler', () => { + it('deselects guide when clicking on stage background (not dragging)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'c1'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('c1'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + const [, pointerclickHandler] = (stage.on as ReturnType).mock.calls.find( + ([event]: [string]) => event === 'pointerclick' + ) ?? []; + pointerclickHandler?.({ target: stage }); + expect(manager.getSelectedGuide()).toBeNull(); + }); + + it('does NOT deselect when target is not stage', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'c1'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('c1'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + const [, pointerclickHandler] = (stage.on as ReturnType).mock.calls.find( + ([event]: [string]) => event === 'pointerclick' + ) ?? []; + pointerclickHandler?.({ target: { id: () => 'someNode' } }); + expect(manager.getSelectedGuide()).toBe(guide); + }); + + it('does NOT deselect when dragging', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'c1'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('c1'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + (manager as unknown as { isDragging: boolean }).isDragging = true; + const [, pointerclickHandler] = (stage.on as ReturnType).mock.calls.find( + ([event]: [string]) => event === 'pointerclick' + ) ?? []; + pointerclickHandler?.({ target: stage }); + expect(manager.getSelectedGuide()).toBe(guide); + }); +}); + +// ─── deleteGuide ────────────────────────────────────────────────────────────── + +describe('deleteGuide', () => { + it('removes custom guide from state', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + layer.findOne = vi.fn().mockReturnValue(null); + manager.deleteGuide(guide); + expect(manager.getAllCustomGuides()).not.toHaveProperty('mainLayer'); + }); + + it('destroys the guide node from layer if it exists', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + const destroyMock = vi.fn(); + layer.findOne = vi.fn().mockReturnValue({ destroy: destroyMock }); + manager.deleteGuide(guide); + expect(destroyMock).toHaveBeenCalled(); + }); + + it('does not throw when guide node does not exist', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + layer.findOne = vi.fn().mockReturnValue(null); + expect(() => manager.deleteGuide(guide)).not.toThrow(); + }); + + it('clears selectedGuide when deleting the currently selected guide', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + layer.findOne = vi.fn().mockReturnValue(null); + manager.deleteGuide(guide); + expect(manager.getSelectedGuide()).toBeNull(); + }); + + it('does not clear selectedGuide when deleting a different guide', () => { + const { manager, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide1 = makeGuide('g1', 'V', 'mainLayer'); + const guide2 = makeGuide('g2', 'H', 'mainLayer'); + manager.saveCustomGuide(guide1); + manager.saveCustomGuide(guide2); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide1; + layer.findOne = vi.fn().mockReturnValue(null); + manager.deleteGuide(guide2); + expect(manager.getSelectedGuide()).toBe(guide1); + }); +}); + +// ─── arrowKeysHandler (via keydown on stage container) ─────────────────────── + +function getKeydownHandler(stage: ReturnType) { + const container = stage.container(); + const [, handler] = (container.addEventListener as ReturnType).mock.calls.find( + ([event]: [string]) => event === 'keydown' + ) ?? []; + return handler as ((e: Partial) => void) | undefined; +} + +describe('arrowKeysHandler', () => { + it('moves HORIZONTAL guide up (decreases value)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'H', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowUp', shiftKey: false }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value - 0.5); + }); + + it('moves HORIZONTAL guide down (increases value)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'H', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowDown', shiftKey: false }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value + 0.5); + }); + + it('moves VERTICAL guide left (decreases value)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowLeft', shiftKey: false }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value - 0.5); + }); + + it('moves VERTICAL guide right (increases value)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowRight', shiftKey: false }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value + 0.5); + }); + + it('ignores ArrowUp for a VERTICAL guide (wrong axis)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowUp', shiftKey: false }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value); + }); + + it('ignores ArrowLeft for a HORIZONTAL guide (wrong axis)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'H', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowLeft', shiftKey: false }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value); + }); + + it('uses shiftDelta when shift is pressed', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'H', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + const guideNode = { getAttr: vi.fn().mockReturnValue(guide), x: vi.fn(), y: vi.fn(), setAttrs: vi.fn() }; + stage.findOne = vi.fn().mockImplementation((sel: string) => (sel === `#${guide.guideId}` ? guideNode : null)); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + getKeydownHandler(stage)?.({ code: 'ArrowUp', shiftKey: true }); + expect(manager.getAllCustomGuides()['mainLayer']?.[0].value).toBe(guide.value - 10); + }); + + it('deletes guide on Backspace key', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + layer.findOne = vi.fn().mockReturnValue(null); + getKeydownHandler(stage)?.({ code: 'Backspace', shiftKey: false }); + expect(manager.getAllCustomGuides()).not.toHaveProperty('mainLayer'); + }); + + it('deletes guide on Delete key', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'V', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + layer.findOne = vi.fn().mockReturnValue(null); + getKeydownHandler(stage)?.({ code: 'Delete', shiftKey: false }); + expect(manager.getAllCustomGuides()).not.toHaveProperty('mainLayer'); + }); + + it('does nothing when no guide is selected (ArrowUp)', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + manager.saveCustomGuide(makeGuide('g1', 'H', 'mainLayer')); + manager.toggleCustomGuides('mainLayer'); + stage.findOne = vi.fn().mockReturnValue(null); + expect(() => getKeydownHandler(stage)?.({ code: 'ArrowUp', shiftKey: false })).not.toThrow(); + }); + + it('does nothing when guide node not found in stage', () => { + const { manager, stage, layer } = setup({ persistence: false }); + layer.find = vi.fn().mockReturnValue([]); + const guide = makeGuide('g1', 'H', 'mainLayer'); + manager.saveCustomGuide(guide); + manager.toggleCustomGuides('mainLayer'); + (manager as unknown as { selectedGuide: Guide }).selectedGuide = guide; + stage.findOne = vi.fn().mockReturnValue(null); + expect(() => getKeydownHandler(stage)?.({ code: 'ArrowUp', shiftKey: false })).not.toThrow(); + }); +}); + +// ─── createGuideNode event handlers (via renderCustomGuides) ───────────────── + +describe('createGuideNode event handlers', () => { + function setupWithCapturedHandlers( + guideOpts: { orientation?: 'H' | 'V'; kind?: string } = {} + ) { + const { manager, stage, layer, weave } = setup({ persistence: false }); + const capturedHandlers: Record unknown> = {}; + const guideNodeMock = { + on: vi.fn().mockImplementation( + (event: string, handler: (...args: unknown[]) => unknown) => { + capturedHandlers[event] = handler; + } + ), + x: vi.fn().mockReturnValue(100).mockReturnThis(), + y: vi.fn().mockReturnValue(100).mockReturnThis(), + getAttr: vi.fn(), + setAttrs: vi.fn(), + destroy: vi.fn(), + moveToTop: vi.fn(), + getClientRect: vi.fn().mockReturnValue({ x: 100, y: 100, width: 0, height: 0 }), + }; + (Konva as unknown as { Line: ReturnType }).Line.mockImplementationOnce( + () => guideNodeMock + ); + stage.findOne = vi.fn().mockReturnValue(null); + layer.find = vi.fn().mockReturnValue([]); + + // Provide a mock guideDistanceToTargetInfo so event handlers can call it + manager.guideDistanceToTargetInfo = { + handleTarget: vi.fn(), + cleanup: vi.fn(), + cleanupTarget: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleDistanceLine: vi.fn(), + } as unknown as typeof manager.guideDistanceToTargetInfo; + + const guide: Guide = { + guideId: 'g1', + orientation: guideOpts.orientation ?? GUIDE_ORIENTATION.VERTICAL, + value: 100, + kind: (guideOpts.kind ?? GUIDE_KIND.CUSTOM) as Guide['kind'], + containerId: 'mainLayer', + persist: false, + }; + manager.saveCustomGuide(guide); + (manager as unknown as { customGuidesVisible: Record }).customGuidesVisible = { + mainLayer: true, + }; + manager.renderCustomGuides('mainLayer'); + return { manager, stage, layer, weave, guide, capturedHandlers, guideNodeMock }; + } + + it('pointerover sets ew-resize cursor for VERTICAL custom guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + capturedHandlers['pointerover']?.(); + expect(stage.container().style.cursor).toBe('ew-resize'); + }); + + it('pointerover sets ns-resize cursor for HORIZONTAL custom guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'H' }); + capturedHandlers['pointerover']?.(); + expect(stage.container().style.cursor).toBe('ns-resize'); + }); + + it('pointerover sets pointer cursor for STATIC guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ + orientation: 'V', + kind: GUIDE_KIND.STATIC, + }); + capturedHandlers['pointerover']?.(); + expect(stage.container().style.cursor).toBe('pointer'); + }); + + it('pointerdown sets selectedGuide', () => { + const { manager, stage, capturedHandlers, guide } = setupWithCapturedHandlers({ orientation: 'V' }); + stage.find = vi.fn().mockReturnValue([]); + capturedHandlers['pointerdown']?.(); + expect(manager.getSelectedGuide()).toBe(guide); + }); + + it('pointerdown emits onCustomGuideSelected event', () => { + const { weave, stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + stage.find = vi.fn().mockReturnValue([]); + capturedHandlers['pointerdown']?.(); + expect(weave.emitEvent).toHaveBeenCalledWith( + 'snappingManager:onCustomGuideSelected', + expect.any(Object) + ); + }); + + it('pointerup sets ew-resize cursor for VERTICAL guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + capturedHandlers['pointerup']?.(); + expect(stage.container().style.cursor).toBe('ew-resize'); + }); + + it('pointerup sets ns-resize cursor for HORIZONTAL guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'H' }); + capturedHandlers['pointerup']?.(); + expect(stage.container().style.cursor).toBe('ns-resize'); + }); + + it('pointermove sets ew-resize cursor for VERTICAL guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + capturedHandlers['pointermove']?.(); + expect(stage.container().style.cursor).toBe('ew-resize'); + }); + + it('pointermove sets ns-resize cursor for HORIZONTAL guide', () => { + const { stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'H' }); + capturedHandlers['pointermove']?.(); + expect(stage.container().style.cursor).toBe('ns-resize'); + }); + + it('dragstart sets isDragging=true', () => { + const { manager, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + capturedHandlers['dragstart']?.(); + expect((manager as unknown as { isDragging: boolean }).isDragging).toBe(true); + }); + + it('dragmove constrains VERTICAL guide y-axis', () => { + const { capturedHandlers, guideNodeMock } = setupWithCapturedHandlers({ orientation: 'V' }); + capturedHandlers['dragstart']?.(); + capturedHandlers['dragmove']?.({ evt: { altKey: false } }); + expect(guideNodeMock.y).toHaveBeenCalled(); + }); + + it('dragmove constrains HORIZONTAL guide x-axis', () => { + const { capturedHandlers, guideNodeMock } = setupWithCapturedHandlers({ orientation: 'H' }); + capturedHandlers['dragstart']?.(); + capturedHandlers['dragmove']?.({ evt: { altKey: false } }); + expect(guideNodeMock.x).toHaveBeenCalled(); + }); + + it('dragend sets isDragging=false', () => { + const { manager, stage, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + stage.findOne = vi.fn().mockReturnValue(null); + capturedHandlers['dragstart']?.(); + capturedHandlers['dragend']?.(); + expect((manager as unknown as { isDragging: boolean }).isDragging).toBe(false); + }); + + it('dragend updates VERTICAL guide value via editCustomGuide', () => { + const { manager, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'V' }); + capturedHandlers['dragstart']?.(); + capturedHandlers['dragend']?.(); + const updatedGuide = manager.getAllCustomGuides()['mainLayer']?.[0]; + expect(updatedGuide).toBeDefined(); + expect(typeof updatedGuide?.value).toBe('number'); + }); + + it('dragend updates HORIZONTAL guide value via editCustomGuide', () => { + const { manager, capturedHandlers } = setupWithCapturedHandlers({ orientation: 'H' }); + capturedHandlers['dragstart']?.(); + capturedHandlers['dragend']?.(); + const updatedGuide = manager.getAllCustomGuides()['mainLayer']?.[0]; + expect(updatedGuide).toBeDefined(); + expect(typeof updatedGuide?.value).toBe('number'); + }); +}); diff --git a/code/packages/sdk/src/plugins/nodes-snapping/nodes-snapping.custom-guides.ts b/code/packages/sdk/src/plugins/nodes-snapping/nodes-snapping.custom-guides.ts index bfda939b9..9d7439b0e 100644 --- a/code/packages/sdk/src/plugins/nodes-snapping/nodes-snapping.custom-guides.ts +++ b/code/packages/sdk/src/plugins/nodes-snapping/nodes-snapping.custom-guides.ts @@ -222,6 +222,10 @@ export class WeaveNodesSnappingCustomGuides { saveCustomGuide(guide: Guide): void { const containerId = guide.containerId; + if (!this.customGuides) { + this.customGuides = {}; + } + if (!this.customGuides[containerId]) { this.customGuides[containerId] = []; } diff --git a/code/packages/sdk/src/types.ts b/code/packages/sdk/src/types.ts index 0cf9aa885..252c6d7ba 100644 --- a/code/packages/sdk/src/types.ts +++ b/code/packages/sdk/src/types.ts @@ -2,7 +2,11 @@ // // SPDX-License-Identifier: Apache-2.0 -import type { WeaveFont, WeaveLoggerConfig } from '@inditextech/weave-types'; +import type { + WeaveFont, + WeaveFontStyle, + WeaveLoggerConfig, +} from '@inditextech/weave-types'; import type { WeaveAction } from './actions/action'; import type { WeaveNode } from './nodes/node'; import type { WeavePlugin } from './plugins/plugin'; @@ -13,7 +17,20 @@ import type { WeaveStore } from './stores/store'; export type DOMElement = HTMLElement | Element | null; -export type WeaveFontsPreloadFunction = () => Promise; +export type WeaveFontFace = FontFaceDescriptors & { + source: string | BufferSource; +}; + +export type WeaveFontFamily = { + family: string; + fontFaces: WeaveFontFace[]; + offset: { x: number; y: number }; + supportedStyles: WeaveFontStyle[]; +}; + +export type WeaveFontsPreloadFunction = ( + loadFontsFamilies: (fontFamilies: WeaveFontFamily[]) => Promise +) => Promise; export type WeaveUpscaleConfig = { enabled?: boolean; diff --git a/code/packages/sdk/src/weave.ts b/code/packages/sdk/src/weave.ts index 959efff46..12fa1e740 100644 --- a/code/packages/sdk/src/weave.ts +++ b/code/packages/sdk/src/weave.ts @@ -311,6 +311,7 @@ export class Weave { } this.registerManager.reset(); + this.asyncManager.reset(); this.moduleLogger.info('Switching room instance'); @@ -339,6 +340,7 @@ export class Weave { // Start loading the fonts, this operation can be asynchronous await this.fontsManager.loadFonts(); + this.setupManager.setupLog(); // Setup stage 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 6a9649fe1..e142066e1 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 @@ -5,7 +5,7 @@ description: This version delivers new diagramming capabilities, including group ## Metadata -- **Release date**: TBD +- **Release date**: 2026-06-18 ### Added @@ -17,6 +17,7 @@ description: This version delivers new diagramming capabilities, including group ### Changed +- [#1089](https://github.com/InditexTech/weavejs/issues/1089) Refactor Image node and tools to avoid saving image fallback on the state - [#1090](https://github.com/InditexTech/weavejs/issues/1090) Remove usage of @syncedstore/core - [#1091](https://github.com/InditexTech/weavejs/issues/1091) Make Store Connection Lifecycle Asynchronous and Increase Azure Web PubSub Frame Size to 512 KB diff --git a/docs/content/docs/main/changelog/meta.json b/docs/content/docs/main/changelog/meta.json index 7c95b4362..348fa1159 100644 --- a/docs/content/docs/main/changelog/meta.json +++ b/docs/content/docs/main/changelog/meta.json @@ -1,5 +1,5 @@ { "title": "Changelog", "description": "Detailed changelog for Weave.js versions", - "pages": ["4.x", "3.x", "2.x", "1.x", "prerelease"] + "pages": ["5.x", "4.x", "3.x", "2.x", "1.x", "prerelease"] } diff --git a/docs/content/docs/main/release-notes/4.0.0.mdx b/docs/content/docs/main/release-notes/4.0.0.mdx new file mode 100644 index 000000000..906f092c9 --- /dev/null +++ b/docs/content/docs/main/release-notes/4.0.0.mdx @@ -0,0 +1,111 @@ +--- +title: v4.0.0 +description: Focus on unified snapping architecture and custom canvas guides. +--- + +## 🧲 Unified snapping and custom guides. + +**Release date:** May 15, 2026 + +This release focuses on **architectural unification of the snapping system** and the introduction of **custom canvas guides**. + +The two separate snapping plugins from previous versions have been merged into a single, coherent plugin that handles edge snapping, distance indicators, and custom guide lines from one place. This reduces configuration overhead and makes snapping behaviour easier to reason about and extend. + +--- + +## Changes + +### Unified Snapping Plugin + +The `WeaveNodesEdgeSnappingPlugin` and `WeaveNodesDistanceSnappingPlugin` have been **merged into a single `WeaveNodesSnappingPlugin`**. + +- One plugin replaces two, simplifying registration and configuration +- Edge snapping and distance guide rendering now share the same internal state and coordinate system +- Consistent snapping behaviour across all node types +- Reduced risk of the two plugins disagreeing about snapping targets + +[https://github.com/InditexTech/weavejs/issues/1041](https://github.com/InditexTech/weavejs/issues/1041) + +--- + +### Custom Guides Support + +A new `WeaveGuideToolAction` lets users draw **persistent ruler guides** directly on the canvas — horizontal or vertical lines that act as snapping targets for other nodes. + +- Place guides precisely anywhere on the canvas +- Guides participate in the unified snapping system +- Guides are stored in the shared Yjs document and synced to all collaborators in real time + +[https://github.com/InditexTech/weavejs/issues/1041](https://github.com/InditexTech/weavejs/issues/1041) + +--- + +## Breaking Changes + +### `WeaveNodesEdgeSnappingPlugin` and `WeaveNodesDistanceSnappingPlugin` removed + +Both plugins have been removed and replaced by the new `WeaveNodesSnappingPlugin`. + +#### Before (v3.x) + +```ts +import { + WeaveNodesEdgeSnappingPlugin, + WeaveNodesDistanceSnappingPlugin, +} from "@inditextech/weave-sdk"; + +const instance = new Weave({ + // ... + plugins: [ + new WeaveNodesEdgeSnappingPlugin(), + new WeaveNodesDistanceSnappingPlugin(), + ], +}); +``` + +#### After (v4.0.0) + +```ts +import { WeaveNodesSnappingPlugin } from "@inditextech/weave-sdk"; + +const instance = new Weave({ + // ... + plugins: [new WeaveNodesSnappingPlugin()], +}); +``` + +--- + +### Snapping metadata stored as plain objects + +Snapping guide metadata is now stored in the Yjs document as plain objects instead of serialized JSON strings. If your application reads snapping metadata directly from the Yjs document (e.g. to render custom overlays), update your reading logic accordingly. + +--- + +### `WeaveGuideToolAction` must be registered to use guides + +If your application does not register `WeaveGuideToolAction`, users will not be able to create custom guides. Register it alongside your other action handlers: + +```ts +import { + WeaveGuideToolAction, + WeaveNodesSnappingPlugin, +} from "@inditextech/weave-sdk"; + +const instance = new Weave({ + // ... + actions: [ + // ... other actions + new WeaveGuideToolAction(), + ], + plugins: [new WeaveNodesSnappingPlugin()], +}); +``` + +--- + +## Migration Steps + +1. **Replace both snapping plugin imports** — remove `WeaveNodesEdgeSnappingPlugin` and `WeaveNodesDistanceSnappingPlugin` from your imports and plugin list. Add `WeaveNodesSnappingPlugin` in their place. +2. **Register `WeaveGuideToolAction`** — if you want users to be able to create custom ruler guides, register the new action. +3. **Update any direct Yjs snapping metadata reads** — if your code reads raw snapping/guide metadata from the Yjs document, update it to handle plain objects instead of JSON strings. diff --git a/docs/content/docs/main/release-notes/5.0.0.mdx b/docs/content/docs/main/release-notes/5.0.0.mdx new file mode 100644 index 000000000..ee4d2b075 --- /dev/null +++ b/docs/content/docs/main/release-notes/5.0.0.mdx @@ -0,0 +1,231 @@ +--- +title: v5.0.0 +description: Focus on **diagramming capabilities, optimistic persistence, and a cleaner store architecture**. +--- + +## 🔷 Richer diagramming, faster initial load, and a cleaner store architecture. + +**Release date:** June 18, 2026 + +This release delivers significant new **diagramming capabilities** — editable groups, inline shape labels, and a full polygon node — alongside an **alpha server-side document API**. On the infrastructure side, store connections are now fully asynchronous, image fallback data is decoupled from the shared document state, and the `@syncedstore/core` dependency has been removed in favour of direct Yjs APIs. The Azure Web PubSub store now uses IndexedDB to render the canvas optimistically before the server connection is established. + +--- + +## Changes + +### Edit Elements Inside a Group Without Ungrouping + +Users can now **double-click into a group** to edit its individual child elements in place, without having to ungroup and re-group them. + +- Select and transform individual nodes within a group +- Full support for nested groups +- Grouping is preserved — no accidental destructuring of complex compositions + +[https://github.com/InditexTech/weavejs/issues/1039](https://github.com/InditexTech/weavejs/issues/1039) + +--- + +### Inline Text Labels on Shape Nodes + +`WeaveRectangleNode` and `WeaveEllipseNode` now support **inline text labels** rendered directly on the shape. + +- Add a label to any rectangle or ellipse without placing a separate text node +- Labels scale and move with the shape +- Configurable font, size, colour, and alignment + +[https://github.com/InditexTech/weavejs/issues/1101](https://github.com/InditexTech/weavejs/issues/1101) + +--- + +### Polygon Node and Drawing Tool + +A new `WeavePolygonNode` and `WeavePolygonToolAction` enable users to draw **arbitrary closed polygons** on the canvas. + +- Click to place vertices; close the shape to finish +- Full label support (same API as the new shape label system) +- Preset shapes available out of the box +- Participates in the standard selection, transform, and snapping pipeline + +[https://github.com/InditexTech/weavejs/issues/1104](https://github.com/InditexTech/weavejs/issues/1104) + +--- + +### IndexedDB Persistence for Optimistic Render (Azure Web PubSub) + +`WeaveStoreAzureWebPubsub` now uses **`y-indexeddb`** to persist the Yjs document locally in the browser. On subsequent visits, the canvas renders immediately from local storage while the WebSocket connection is established in the background. + +- Near-instant canvas load for returning users +- Eliminates the blank canvas flash on reconnect +- State is automatically reconciled once the server connection completes + +[https://github.com/InditexTech/weavejs/issues/1106](https://github.com/InditexTech/weavejs/issues/1106) + +--- + +### [Alpha] Server-Side Document Manipulation API + +A new low-level API allows **Node.js server code to read and write Weave document state** natively, without a browser or WebSocket connection. + +- Create, update, and delete nodes programmatically on the server +- Useful for seeding rooms, running migrations, or generating documents from external data sources +- Exported from the `./server` entry point of `@inditextech/weave-sdk` + +> **This API is in alpha.** Interfaces may change in a future minor or patch release. + +[https://github.com/InditexTech/weavejs/issues/1080](https://github.com/InditexTech/weavejs/issues/1080) + +--- + +### Bug Fixes + +- **Brush tool** — it was no longer possible to draw a dot or very small stroke on the canvas. This is now fixed. ([#1088](https://github.com/InditexTech/weavejs/issues/1088)) +- **WeaveTextNode** — the textarea edit overlay was misaligned due to a hardcoded border offset and an inaccurate `scrollHeight` approximation. The overlay now tracks the text node position correctly. ([#1098](https://github.com/InditexTech/weavejs/issues/1098)) + +--- + +## Breaking Changes + +### `WeaveStore.connect()` and `disconnect()` are now async + +The abstract methods `connect()` and `disconnect()` on `WeaveStore` now return `Promise` instead of `void`. Any **custom store implementations** must be updated to be `async`. + +#### Before (v4.x) + +```ts +class MyStore extends WeaveStore { + connect(extraParams?: unknown): void { + // synchronous setup + } + + disconnect(): void { + // synchronous teardown + } +} +``` + +#### After (v5.0.0) + +```ts +class MyStore extends WeaveStore { + async connect(extraParams?: unknown): Promise { + // async setup — await any async operations + } + + async disconnect(): Promise { + // async teardown + } +} +``` + +Also in this change: the Azure Web PubSub store's **maximum WebSocket frame size** has been increased from 64 KB to **512 KB**. + +[https://github.com/InditexTech/weavejs/issues/1091](https://github.com/InditexTech/weavejs/issues/1091) + +--- + +### Image fallback data no longer saved in shared document state + +The `WeaveImageNode` no longer writes image fallback data (base64 data URLs) into the shared Yjs document. This reduces document size and avoids flooding collaborators with binary data. Applications that relied on the old behaviour must adopt the new `imageFallback` configuration API to persist and retrieve fallback images from their own storage. + +#### Before (v4.x) + +Image fallback data was automatically stored in the room state and broadcast to all collaborators. + +#### After (v5.0.0) + +Configure the `imageFallback` option on `WeaveImageNode` to hook into your own persistence layer: + +```ts +import { WeaveImageNode } from '@inditextech/weave-sdk'; + +const imageNode = new WeaveImageNode({ + // ... other config + imageFallback: { + enabled: true, + getId: (params) => params.id as string, + getDataURL: (id) => myStorage.get(id) ?? '', + onPersist: (params, dataURL) => { + myStorage.set(params.id as string, dataURL); + }, + }, +}); +``` + +[https://github.com/InditexTech/weavejs/issues/1089](https://github.com/InditexTech/weavejs/issues/1089) + +--- + +### Font loading API: `fontsConfig` function now receives a helper argument + +If you use a **function** to configure fonts (as opposed to a static array), its signature has changed. The function now receives a `loadFontsFamilies` helper as its first argument, which handles `FontFace` loading and registration for you. + +#### Before (v4.x) + +```ts +const instance = new Weave({ + // ... + fonts: async () => { + // manually load and register FontFace objects + return [ + { id: 'MyFont', name: 'MyFont, sans-serif', offsetY: 0, supportedStyles: [] }, + ]; + }, +}); +``` + +#### After (v5.0.0) + +```ts +const instance = new Weave({ + // ... + fonts: async (loadFontsFamilies) => { + return loadFontsFamilies([ + { + family: 'MyFont', + offset: { y: 0 }, + supportedStyles: [], + fontFaces: [ + { source: 'url(/fonts/MyFont-Regular.woff2)', weight: '400' }, + { source: 'url(/fonts/MyFont-Bold.woff2)', weight: '700' }, + ], + }, + ]); + }, +}); +``` + +Passing a static array of `WeaveFont` objects is unaffected by this change. + +--- + +### `@syncedstore/core` removed + +The `@syncedstore/core` package is no longer a dependency of `@inditextech/weave-react`. If your application imported anything from `@syncedstore/core` directly (e.g. to observe Yjs state), migrate to the equivalent **Yjs** APIs. + +#### Before (v4.x) + +```ts +import { getYjsValue } from '@syncedstore/core'; + +const yjsMap = getYjsValue(store.state); +``` + +#### After (v5.0.0) + +```ts +// Use the Yjs doc directly — available from the store +const yjsDoc = store.getDoc(); +const yjsMap = yjsDoc.getMap('weave'); +``` + +[https://github.com/InditexTech/weavejs/issues/1090](https://github.com/InditexTech/weavejs/issues/1090) + +--- + +## Migration Steps + +1. **Update custom store implementations** — add `async` and return `Promise` from `connect()` and `disconnect()`. +2. **Adopt the `imageFallback` API** — if you rely on image fallback data being stored in the Yjs document, configure the new `imageFallback` option on `WeaveImageNode` to persist fallback images in your own storage. +3. **Update font loading functions** — if you use a `fontsConfig` function, update its signature to accept and use the `loadFontsFamilies` helper. +4. **Remove `@syncedstore/core`** — replace any direct usage with Yjs APIs accessed via `store.getDoc()`. +5. **Register new nodes and actions** (optional) — add `WeavePolygonNode` and `WeavePolygonToolAction` to unlock polygon drawing; no action needed if you do not want the feature. diff --git a/docs/content/docs/main/release-notes/index.mdx b/docs/content/docs/main/release-notes/index.mdx index 158615bd6..1dc4c84e8 100644 --- a/docs/content/docs/main/release-notes/index.mdx +++ b/docs/content/docs/main/release-notes/index.mdx @@ -5,6 +5,8 @@ description: Discover what's new in Weave.js ## Release versions +- [**5.0.0**](/docs/main/release-notes/5.0.0) +- [**4.0.0**](/docs/main/release-notes/4.0.0) - [**3.0.0**](/docs/main/release-notes/3.0.0) - [**2.0.0**](/docs/main/release-notes/2.0.0) - [**1.0.0**](/docs/main/release-notes/1.0.0) diff --git a/docs/content/docs/main/release-notes/meta.json b/docs/content/docs/main/release-notes/meta.json index feb4f9b03..e74114d4b 100644 --- a/docs/content/docs/main/release-notes/meta.json +++ b/docs/content/docs/main/release-notes/meta.json @@ -1,5 +1,5 @@ { "title": "Release notes", "description": "Learn about the features, improvements, and bug fixes in Weave.js releases", - "pages": ["3.0.0", "2.0.0", "1.0.0"] + "pages": ["5.0.0", "4.0.0", "3.0.0", "2.0.0", "1.0.0"] } diff --git a/docs/content/docs/sdk/api-reference/actions/image-tool.mdx b/docs/content/docs/sdk/api-reference/actions/image-tool.mdx index 0dcde6183..f06d0a845 100644 --- a/docs/content/docs/sdk/api-reference/actions/image-tool.mdx +++ b/docs/content/docs/sdk/api-reference/actions/image-tool.mdx @@ -68,6 +68,7 @@ type WeaveImageToolActionTriggerCommonParams = { imageId?: string; options?: ImageOptions; position?: Konva.Vector2d; + container?: Konva.Layer | Konva.Group; forceMainContainer?: boolean; }; @@ -78,12 +79,14 @@ type WeaveImageFile = { type WeaveImageURL = { url: string; - fallback: string; width: number; height: number; }; -type WeaveImageToolActionUploadFunction = (file: File) => Promise; +type WeaveImageToolActionUploadFunction = ( + file: File, + imageId: string, +) => Promise; type WeaveImageToolActionTriggerParams = ( | { @@ -129,6 +132,19 @@ type WeaveImageToolActionConfig = { type WeaveImageToolActionParams = { config: DeepPartial; }; + +type ImageToolActionData = { + props: WeaveElementAttributes; + imageId: string | null; + container: Konva.Layer | Konva.Node | undefined; + imageFile: WeaveImageFile | null; + imageURL: WeaveImageURL | null; + imageFallbackURL: string | null; + forceMainContainer: boolean; + clickPoint: Konva.Vector2d | null; + uploadType: WeaveImageToolActionUploadType | null; + uploadImageFunction: WeaveImageToolActionUploadFunction | null; +}; ``` ## Trigger function params @@ -188,7 +204,7 @@ on the `onDragStart` event you must call the `setDragAndDropProperties` method o properties: - `imageURL`: the image `WeaveImageURL` metadata of the image to add. -- `imageId`: (optional) the image id (external resource id) associated to the image. +- `imageId`: (optional) the image id (external resource id) associated to the image. Used for the image fallback maps normally. - `forceMainContainer`: (optional) defines if we should force to add the images on the main stage instead for example A frame. diff --git a/docs/content/docs/sdk/api-reference/meta.json b/docs/content/docs/sdk/api-reference/meta.json index 2672eec71..a62c0d606 100644 --- a/docs/content/docs/sdk/api-reference/meta.json +++ b/docs/content/docs/sdk/api-reference/meta.json @@ -1,5 +1,5 @@ { "title": "API Reference", "description": "The SDK api reference", - "pages": ["weave", "nodes", "plugins", "actions"] + "pages": ["weave", "nodes", "plugins", "actions", "state-api"] } diff --git a/docs/content/docs/sdk/api-reference/nodes/image.mdx b/docs/content/docs/sdk/api-reference/nodes/image.mdx index 34003fd67..7f4951ebd 100644 --- a/docs/content/docs/sdk/api-reference/nodes/image.mdx +++ b/docs/content/docs/sdk/api-reference/nodes/image.mdx @@ -55,6 +55,7 @@ type ImageProps = WeaveElementAttributes & { }; type WeaveImageState = { + status: "loaded" | "loading" | "error-fallback" | "error" | "idle"; loaded: boolean; error: boolean; }; @@ -68,12 +69,14 @@ type WeaveImageCache = pixelRatio: number; }; -type WeaveImageURLTransformerFunction = ( - url: string, - node?: Konva.Node, -) => string; +type WeaveImageCursors = { + loading: string; +}; type WeaveImageProperties = { + cleanup: { + intervalMs: number; + }; performance: { cache: WeaveImageCache; }; @@ -81,15 +84,37 @@ type WeaveImageProperties = { placeholder: { fill: string; }; + cursor: WeaveImageCursors; + }; + imageLoading: { + maxRetryAttempts: number; + retryDelayMs: number; }; crossOrigin: ImageCrossOrigin; transform?: WeaveNodeTransformerProperties; - urlTransformer?: WeaveImageURLTransformerFunction; + urlTransformer?: URLTransformerFunction; + imageFallback: + | { + enabled: true; + getId: (params: WeaveElementAttributes) => string; + getDataURL: (imageFallbackId: string) => string; + onPersist: (params: WeaveElementAttributes, dataURL: string) => void; + } + | { + enabled: false; + }; onDblClick?: (instance: WeaveImageNode, node: Konva.Group) => void; cropMode: { - grid: { + enabled: boolean; + triggers: { + ctrlCmd: boolean; + }; + gridLines: { enabled: boolean; }; + overlay: { + fill: string; + }; selection: { enabledAnchors: WeaveImageCropAnchorPosition[]; borderStroke: string; @@ -187,6 +212,26 @@ For `WeaveImageProperties`: "Function that receives the actual image node URL, and returns an URL. Useful for transformations of the URL.", type: "(url: string) => string", }, + ["imageFallback.enabled"]: { + description: + "Defines if we use the image fallback mechanism to provide an image fallback to the node while the real image is loading.", + type: "boolean", + }, + ["imageFallback.getId"]: { + description: + "Function that provided a specific node parameters, returns the resource id used by that node.", + type: "(params: WeaveElementAttributes) => string", + }, + ["imageFallback.getDataURL"]: { + description: + "Function that provided a resource id, returns de image fallback in data URL format for that resource.", + type: "boolean", + }, + ["imageFallback.onPersist"]: { + description: + "Function that provided a specific node parameters and a data URL as the image fallback for that node, allows developers to persist the mapping (id, data URL) to an external service, in order to later on use it on imageFallback.getDataURL", + type: "(params: WeaveElementAttributes, dataURL: string) => void", + }, ["onDblClick"]: { description: "Setup an callback that is called when an user double-click (or double tap) on the node", @@ -313,6 +358,17 @@ resetCrop(imageNode: Konva.Group): void Function that when is called reset the image cropping to its original size. +### forceLoadFallbackImage + +```ts +forceLoadFallbackImage(nodeInstance: WeaveElementInstance, dataURL: string): void +``` + +Forces a fallback image to load on the given node instance using the provided data URL. +Cancels any pending retry timers for that node and immediately caches and loads the fallback image. +Useful when you want to programmatically supply a local data URL as a replacement for an image that +failed to load from its remote URL. + ## Konva.Node Augmentation This Node extends the `Konva.Node` class to define several functions: diff --git a/docs/content/docs/sdk/api-reference/state-api/index.mdx b/docs/content/docs/sdk/api-reference/state-api/index.mdx new file mode 100644 index 000000000..fc82f8fbf --- /dev/null +++ b/docs/content/docs/sdk/api-reference/state-api/index.mdx @@ -0,0 +1,284 @@ +--- +title: Low-level State Manipulation +description: API reference for the WeaveStateManipulation class +--- + +import { TypeTable } from "fumadocs-ui/components/type-table"; + +## Overview + +The [WeaveStateManipulation](https://github.com/InditexTech/weavejs/blob/main/code/packages/sdk/src/state.manipulation.ts) +class is a collection of **static helpers** for reading and writing the Yjs document that backs +the collaborative canvas state. + +All canvas state in Weave.js is stored as two `Y.Map` roots inside a `Y.Doc`: + +- `weave` — the node tree (stage → layers → nodes) +- `weaveMetadata` — metadata not directly rendered + +`WeaveStateManipulation` gives you direct access to that tree, letting you build, +insert, update, delete, and query nodes at the Yjs level without going through the +higher-level `Weave` instance methods. + + + Prefer the `Weave` instance methods (`addNode`, `updateNode`, `removeNode`, + etc.) for everyday node management. Use `WeaveStateManipulation` only when you + need precise, transactional control over the Yjs document — for example when + writing a custom store, seeding initial state, or implementing a bulk-import + operation. + + +## Import + +```ts +import { WeaveStateManipulation } from "@inditextech/weave-sdk"; +``` + +## Static Methods + +### mapValueToYjs + +```ts +static mapValueToYjs(value: unknown): unknown +``` + +Converts any JavaScript value to its Yjs-compatible equivalent, recursively: + +| Input type | Output type | +| -------------------------------- | --------------------------------------- | +| `null` / `undefined` / primitive | returned as-is | +| `Array` | `Y.Array` (elements mapped recursively) | +| plain `object` | `Y.Map` (values mapped recursively) | + +#### Parameters + + + +#### Returns + +The Yjs-compatible representation of `value`. + +--- + +### mapPropsToYjs + +```ts +static mapPropsToYjs(props: Record): Y.Map +``` + +Converts a flat or nested props object into a `Y.Map`, mapping each value through +[`mapValueToYjs`](#mapvaluetoyjs). + +#### Parameters + +", + }, + }} +/> + +#### Returns + +`Y.Map` — a Yjs map with all props converted to Yjs types. + +--- + +### mapNodeToYjs + +```ts +static mapNodeToYjs(node: WeaveStateElement): { + nodeId: string; + element: Y.Map; +} +``` + +Converts a `WeaveStateElement` JSON model into a Yjs `Y.Map` structure ready to be +inserted into the document. Children are handled recursively as `Y.Array` elements. + +#### Parameters + + + +#### Returns + +An object with: + +- `nodeId` — the `key` of the input node +- `element` — the `Y.Map` representing the node, with `key`, `type`, and `props` fields + +--- + +### addElements + +```ts +static addElements( + layerYjsElement: Y.Map, + yjsElements: Y.Map[] +): void +``` + +Appends one or more Yjs element maps to the `children` array of the given layer element. + +#### Parameters + +", + }, + yjsElements: { + required: true, + description: "An array of Y.Map elements to append as children", + type: "Y.Map[]", + }, + }} +/> + +--- + +### updateElements + +```ts +static updateElements( + layerYjsElement: Y.Map, + yjsElements: { nodeId: string; element: Y.Map }[] +): void +``` + +Replaces existing child elements inside a layer by matching on `nodeId`. For each +entry, the old element at that position is deleted and the new one is inserted at +the same index. + +#### Parameters + +", + }, + yjsElements: { + required: true, + description: + "An array of objects with the target nodeId and the replacement Y.Map element", + type: "{ nodeId: string; element: Y.Map }[]", + }, + }} +/> + +--- + +### deleteElements + +```ts +static deleteElements( + layerYjsElement: Y.Map, + yjsElementsIds: string[] +): void +``` + +Removes child elements from a layer by their node ids. + +#### Parameters + +", + }, + yjsElementsIds: { + required: true, + description: "An array of node ids to remove from the layer's children", + type: "string[]", + }, + }} +/> + +--- + +### getYjsElement + +```ts +static getYjsElement(doc: Y.Doc, nodeId: string): Y.Map | null +``` + +Searches the `weave` root of the document for a node by its `id` prop. Looks in +both direct children of the stage and one level deeper (grandchildren / container +children). + +#### Parameters + + + +#### Returns + +The `Y.Map` for the matching node, or `null` if not found. + +--- + +### getNodesBoundingBox + +```ts +static getNodesBoundingBox(nodes: WeaveStateElement[]): BoundingBox +``` + +Computes the axis-aligned bounding box that contains all the provided nodes, +based on their `x`, `y`, `width`, and `height` props. + +#### Parameters + + + +#### Returns + +`BoundingBox` — `{ x, y, width, height }` of the combined bounding box. diff --git a/docs/content/docs/sdk/api-reference/weave.mdx b/docs/content/docs/sdk/api-reference/weave.mdx index 4131d455f..5d7628c35 100644 --- a/docs/content/docs/sdk/api-reference/weave.mdx +++ b/docs/content/docs/sdk/api-reference/weave.mdx @@ -67,8 +67,8 @@ const instance = new Weave(weaveConfig: WeaveConfig, stageConfig: Konva.StageCon }, ["weaveConfig.fonts"]: { description: - "An array that defines the Fonts to be registered and used on the instance", - type: "WeaveFont[]", + "An array of fonts to be registered and used on the instance, or a preload function that dynamically loads font families and returns the resolved fonts", + type: "WeaveFont[] | WeaveFontsPreloadFunction", }, ["weaveConfig.logger"]: { description: "Weave logger configuration", @@ -830,8 +830,25 @@ the room. export type WeaveFont = { id: string; name: string; + offsetY?: number; + supportedStyles?: WeaveFontStyle[]; }; +export type WeaveFontFace = FontFaceDescriptors & { + source: string | BufferSource; +}; + +export type WeaveFontFamily = { + family: string; + fontFaces: WeaveFontFace[]; + offset: { x: number; y: number }; + supportedStyles: WeaveFontStyle[]; +}; + +export type WeaveFontsPreloadFunction = ( + loadFontsFamilies: (fontFamilies: WeaveFontFamily[]) => Promise +) => Promise; + export type WeaveElementInstance = Konva.Layer | Konva.Group | Konva.Shape; export declare type WeaveElementAttributes = { @@ -917,7 +934,7 @@ export declare type WeaveConfig = { nodes?: WeaveNodeBase[]; actions?: WeaveActionBase[]; plugins?: WeavePluginBase[]; - fonts?: WeaveFont[]; + fonts?: WeaveFont[] | WeaveFontsPreloadFunction; callbacks?: WeaveCallbacks; logger?: WeaveLoggerConfig; }; diff --git a/docs/content/docs/sdk/api-reference/weave/index.mdx b/docs/content/docs/sdk/api-reference/weave/index.mdx index b697348eb..66de33319 100644 --- a/docs/content/docs/sdk/api-reference/weave/index.mdx +++ b/docs/content/docs/sdk/api-reference/weave/index.mdx @@ -73,8 +73,8 @@ const instance = new Weave(weaveConfig: WeaveConfig, stageConfig: Konva.StageCon }, ["weaveConfig.fonts"]: { description: - "An array that defines the Fonts to be registered and used on the instance", - type: "WeaveFont[]", + "An array of fonts to be registered and used on the instance, or a preload function that dynamically loads font families and returns the resolved fonts", + type: "WeaveFont[] | WeaveFontsPreloadFunction", }, ["weaveConfig.logger"]: { description: "Weave logger configuration", @@ -887,8 +887,25 @@ the room. export type WeaveFont = { id: string; name: string; + offsetY?: number; + supportedStyles?: WeaveFontStyle[]; }; +export type WeaveFontFace = FontFaceDescriptors & { + source: string | BufferSource; +}; + +export type WeaveFontFamily = { + family: string; + fontFaces: WeaveFontFace[]; + offset: { x: number; y: number }; + supportedStyles: WeaveFontStyle[]; +}; + +export type WeaveFontsPreloadFunction = ( + loadFontsFamilies: (fontFamilies: WeaveFontFamily[]) => Promise +) => Promise; + export type WeaveElementInstance = Konva.Layer | Konva.Group | Konva.Shape; export declare type WeaveElementAttributes = { @@ -974,7 +991,7 @@ export declare type WeaveConfig = { nodes?: WeaveNodeBase[]; actions?: WeaveActionBase[]; plugins?: WeavePluginBase[]; - fonts?: WeaveFont[]; + fonts?: WeaveFont[] | WeaveFontsPreloadFunction; callbacks?: WeaveCallbacks; logger?: WeaveLoggerConfig; }; diff --git a/docs/content/docs/store-azure-web-pubsub/api-reference/client/weave-store-azure-web-pubsub.mdx b/docs/content/docs/store-azure-web-pubsub/api-reference/client/weave-store-azure-web-pubsub.mdx index 7c6b9acd7..7c2b6a4c3 100644 --- a/docs/content/docs/store-azure-web-pubsub/api-reference/client/weave-store-azure-web-pubsub.mdx +++ b/docs/content/docs/store-azure-web-pubsub/api-reference/client/weave-store-azure-web-pubsub.mdx @@ -316,7 +316,7 @@ The `onNodeChange` event is called when there is a single node selected and the #### connect ```ts -connect(): void +connect(): Promise ``` The `connect` method is called when an user tries to join a room. @@ -324,7 +324,7 @@ The `connect` method is called when an user tries to join a room. #### disconnect ```ts -disconnect(): void +disconnect(): Promise ``` The `disconnect` method is called when an user leaves a room. diff --git a/docs/content/docs/store-websockets/api-reference/client/weave-store-websockets.mdx b/docs/content/docs/store-websockets/api-reference/client/weave-store-websockets.mdx index 0c87c8289..d230b011c 100644 --- a/docs/content/docs/store-websockets/api-reference/client/weave-store-websockets.mdx +++ b/docs/content/docs/store-websockets/api-reference/client/weave-store-websockets.mdx @@ -75,7 +75,7 @@ type WeaveStoreWebsocketsConnectionStatus = type WeaveStoreWebsocketsCallbacks = { onConnectionStatusChange?: ( - status: WeaveStoreWebsocketsConnectionStatus + status: WeaveStoreWebsocketsConnectionStatus, ) => void; }; @@ -144,7 +144,7 @@ The `onNodeChange` event is called when there is a single node selected and the #### connect ```ts -connect(): void +connect(): Promise ``` The `connect` method is called when an user tries to join a room. @@ -152,7 +152,7 @@ The `connect` method is called when an user tries to join a room. #### disconnect ```ts -disconnect(): void +disconnect(): Promise ``` The `disconnect` method is called when an user leaves a room.