diff --git a/.github/workflows/publish-assets.yml b/.github/workflows/publish-assets.yml index 5723ecc..4139fc4 100644 --- a/.github/workflows/publish-assets.yml +++ b/.github/workflows/publish-assets.yml @@ -9,6 +9,9 @@ on: - 'snapsort-v*' - 'snapsort-svelte-v*' - 'snapsort-react-v*' + - 'snapline-v*' + - 'snapline-svelte-v*' + - 'snapline-react-v*' jobs: publish: @@ -61,6 +64,18 @@ jobs: package_dir="assets/snapsort/react" tag_version="${GITHUB_REF_NAME#snapsort-react-v}" ;; + snapline-v*) + package_dir="assets/snapline/core" + tag_version="${GITHUB_REF_NAME#snapline-v}" + ;; + snapline-svelte-v*) + package_dir="assets/snapline/svelte" + tag_version="${GITHUB_REF_NAME#snapline-svelte-v}" + ;; + snapline-react-v*) + package_dir="assets/snapline/react" + tag_version="${GITHUB_REF_NAME#snapline-react-v}" + ;; *) echo "Unsupported release tag: $GITHUB_REF_NAME" exit 1 diff --git a/AGENTS.md b/AGENTS.md index 4b70033..86d0bdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,9 @@ Organized as npm workspaces following a consistent pattern: - See `assets/snapsort/AGENTS.md` ### 3. SnapLine -- **Packages:** `@snap-engine/snapline`, `@snap-engine/snapline-svelte` +- **Packages:** `@snap-engine/snapline`, `@snap-engine/snapline-svelte`, `@snap-engine/snapline-react` - **Purpose:** Node graph UI system -- **Status:** Experimental and private (not published) +- **Status:** Experimental public package - See `assets/snapline/AGENTS.md` ### 4. SnapZap @@ -180,12 +180,19 @@ git tag asset-base-svelte-v{version} git tag asset-base-react-v{version} git tag snapsort-v{version} git tag snapsort-svelte-v{version} +git tag snapsort-react-v{version} +git tag snapline-v{version} +git tag snapline-svelte-v{version} +git tag snapline-react-v{version} git push origin asset-base-v{version} git push origin asset-base-svelte-v{version} git push origin asset-base-react-v{version} git push origin snapsort-v{version} git push origin snapsort-svelte-v{version} git push origin snapsort-react-v{version} +git push origin snapline-v{version} +git push origin snapline-svelte-v{version} +git push origin snapline-react-v{version} ``` Push release tags one at a time and verify each publish workflow before sending the next tag. diff --git a/README.md b/README.md index 952a92a..57002c8 100755 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ > [!WARNING] > The engine is still in early stages of development. Expect frequent updates and breaking changes. -# Interactivity Engine for the Web +# SnapEngine: Interactivity Engine for the Web -SnapEngineJS is a collection of utilities for building interactive UI elements on the web. +SnapEngine is the shared, DOM-first foundation behind a growing family of +interaction tools for the web. It provides: - Input handling with a common API for mouse and touch events. - Collision detection for basic shapes and lines. @@ -15,15 +16,21 @@ SnapEngineJS is a collection of utilities for building interactive UI elements o - A visual debugger for inspecting engine internals. - Zero-dependency, framework-agnostic APIs. -See the [website](https://snap-engine-js.vercel.app) for details and documentation. +Most application developers should start with +[SnapSort](https://snapengine.dev/docs/snapsort/introduction), an +unstyled drag-and-drop toolkit built on SnapEngine. See the +[website](https://snapengine.dev) to explore the ecosystem. -## Installation +## Using SnapEngine Core directly + +Install Core directly when authoring an interaction tool or asset, or when an +application needs advanced custom behavior at the engine level. ```bash npm install @snap-engine/core ``` -## Quick Start +## Core example ```ts import { Engine, ElementObject } from "@snap-engine/core"; @@ -38,7 +45,7 @@ object.schedule(() => { object.worldPosition = [100, 200]; object.writeTransform(); }, { stage: "WRITE_1" }); -```` +``` ## License diff --git a/assets/asset-base/AGENTS.md b/assets/asset-base/AGENTS.md index 24cd7aa..ca2463e 100644 --- a/assets/asset-base/AGENTS.md +++ b/assets/asset-base/AGENTS.md @@ -78,6 +78,17 @@ asset-base/ **Configuration:** - `zoomLock?: boolean` - Disable zoom - `panLock?: boolean` - Disable pan +- `edgePan?: CameraEdgePanConfig` - Opt-in programmatic edge panning for drag owners + +**Edge-pan API:** +- `startEdgePan(pointerId, position, onFrame)` begins a pointer-owned request +- `updateEdgePan(pointerId, position)` updates the screen-space pointer +- `stopEdgePan(pointerId)` ends it + +The controller registers itself on `engine.edgePanController`. Motion is +continuous while the pointer remains in the configured edge zone, and +`onFrame` receives recomputed world coordinates after every camera move so a +drag owner can stay glued to a stationary pointer. ### Background **Extends:** `ElementObject` diff --git a/assets/asset-base/core/src/camera.ts b/assets/asset-base/core/src/camera.ts index bb0a8cb..a346f9f 100644 --- a/assets/asset-base/core/src/camera.ts +++ b/assets/asset-base/core/src/camera.ts @@ -4,29 +4,85 @@ import type { pointerUpProp, mouseWheelProp, pinchProp, + eventPosition, + EdgePanController, } from "@snap-engine/core"; import { ElementObject } from "@snap-engine/core"; import { Camera } from "@snap-engine/core"; import type { CameraConfig } from "@snap-engine/core"; -export type CameraControlConfig = { - zoomLock?: boolean; - panLock?: boolean; +/** What the mouse wheel / trackpad two-finger scroll does. */ +export type CameraWheelConfig = { /** - * Disables panning with a single pointer while leaving two-finger pinch panning - * intact. Use when the camera sits inside a scrollable page and a one-finger drag - * should scroll the page instead of moving the camera. - * - * Pass "touch" to restrict the lock to touch pointers, so a mouse drag still pans — - * on desktop a drag never scrolls the page, so it costs nothing to keep. + * What an unmodified wheel event does. "zoom" (default) zooms; "pan" is the + * trackpad "map" convention — two-finger scroll pans while a ctrl/cmd wheel + * (and trackpad pinch, which browsers report as a ctrl-wheel) still zooms. */ - pointerPanLock?: boolean | "touch"; + action?: "zoom" | "pan"; /** - * Requires a modifier key for wheel zoom. With "ctrlOrMeta", an unmodified wheel - * event is left alone so the page scrolls normally; trackpad pinch still zooms, - * because browsers report it as a wheel event with ctrlKey set. + * Requires a modifier key for wheel zoom. With "ctrlOrMeta", an unmodified + * wheel event is left alone so the page scrolls normally; trackpad pinch + * still zooms, because browsers report it as a wheel event with ctrlKey set. */ + zoomModifier?: "none" | "ctrlOrMeta"; + /** Multiplies wheel-zoom speed (default 1). */ + zoomSensitivity?: number; + /** Multiplies wheel-pan speed (default 1 = 1:1 screen pixels). */ + panSensitivity?: number; + /** + * Extra gain applied to trackpad pinch zoom (default 10). Chrome/Safari + * deliver a pinch as a ctrl-wheel whose deltaY is roughly an order of + * magnitude smaller than a mouse scroll notch; this brings it up to a + * comparable rate so pinch doesn't feel dead. + */ + pinchZoomGain?: number; +}; + +/** What pointer (mouse/touch drag) input does. */ +export type CameraPointerConfig = { + /** + * Which mouse button starts a pointer pan. "left" (the default) preserves the + * original behavior; "middle" frees the left button for other gestures (e.g. + * rubber-band select) while the middle button pans; "both" pans on either. + */ + panButton?: "left" | "middle" | "both"; + /** + * Disables panning with a single pointer while leaving two-finger pinch + * panning intact. Pass "touch" to restrict the lock to touch pointers. + */ + panLock?: boolean | "touch"; +}; + +export type CameraEdgePanConfig = { + /** Enables edge-panning for consumers that explicitly request it. */ + enabled?: boolean; + /** Screen-pixel width of the activation zone at each viewport edge. */ + edgeDistance?: number; + /** Maximum camera speed in screen pixels per second. */ + maxSpeed?: number; +}; + +export type CameraControlConfig = { + zoomLock?: boolean; + panLock?: boolean; + /** Wheel/trackpad behavior, grouped. Wins over the flat deprecated aliases. */ + wheel?: CameraWheelConfig; + /** Pointer behavior, grouped. Wins over the flat deprecated aliases. */ + pointer?: CameraPointerConfig; + /** Programmatic edge-pan behavior used by drag owners such as SnapLine. */ + edgePan?: CameraEdgePanConfig; + /** @deprecated Use `pointer.panLock` instead. */ + pointerPanLock?: boolean | "touch"; + /** @deprecated Use `wheel.zoomModifier` instead. */ wheelZoomModifier?: "none" | "ctrlOrMeta"; + /** @deprecated Use `wheel.action: "pan"` instead. */ + wheelPan?: boolean; + /** @deprecated Use `wheel.zoomSensitivity` instead. */ + zoomSensitivity?: number; + /** @deprecated Use `wheel.panSensitivity` instead. */ + wheelPanSensitivity?: number; + /** @deprecated Use `pointer.panButton` instead. */ + panButton?: "left" | "middle" | "both"; /** Options forwarded to the underlying Camera, e.g. zoomBounds and contentBounds. */ camera?: CameraConfig; }; @@ -36,8 +92,45 @@ const DEFAULT_CONFIG: CameraControlConfig = { panLock: false, pointerPanLock: false, wheelZoomModifier: "none", + wheelPan: false, + panButton: "left", + zoomSensitivity: 1, + wheelPanSensitivity: 1, }; +export interface ResolvedCameraOptions { + wheelAction: "zoom" | "pan"; + wheelZoomModifier: "none" | "ctrlOrMeta"; + zoomSensitivity: number; + wheelPanSensitivity: number; + pinchZoomGain: number; + panButton: "left" | "middle" | "both"; + pointerPanLock: boolean | "touch"; +} + +/** + * Resolves the effective camera options at READ time: a grouped key, when + * defined, wins over its flat deprecated alias; an undefined grouped key falls + * back to the flat key, then to the default. Resolution happens per-read (not + * at construction) because `config` is a public field that adapters reassign + * wholesale when props change. + */ +export function resolveCameraOptions( + config: CameraControlConfig, +): ResolvedCameraOptions { + return { + wheelAction: config.wheel?.action ?? (config.wheelPan ? "pan" : "zoom"), + wheelZoomModifier: + config.wheel?.zoomModifier ?? config.wheelZoomModifier ?? "none", + zoomSensitivity: config.wheel?.zoomSensitivity ?? config.zoomSensitivity ?? 1, + wheelPanSensitivity: + config.wheel?.panSensitivity ?? config.wheelPanSensitivity ?? 1, + pinchZoomGain: config.wheel?.pinchZoomGain ?? 10, + panButton: config.pointer?.panButton ?? config.panButton ?? "left", + pointerPanLock: config.pointer?.panLock ?? config.pointerPanLock ?? false, + }; +} + type PinchAnchor = { centerX: number; centerY: number; @@ -53,6 +146,13 @@ class CameraControl extends ElementObject { #mouseDownY: number; #panPointerId: number | null = null; #pinchAnchor: PinchAnchor | null = null; + #edgePanRequest: { + pointerId: number; + position: eventPosition; + onFrame: (position: eventPosition) => void; + } | null = null; + #edgePanFrameId: number | null = null; + #edgePanTimestamp: number | null = null; config: CameraControlConfig = {}; @@ -64,6 +164,7 @@ class CameraControl extends ElementObject { this.#mouseDownX = 0; this.#mouseDownY = 0; this.#state = "idle"; + this.engine.edgePanController = this as EdgePanController; this.event.global.pointerDown = this.onCursorDown; this.event.global.pointerMove = this.onCursorMove; this.event.global.pointerUp = this.onCursorUp; @@ -159,10 +260,140 @@ class CameraControl extends ElementObject { }); } + startEdgePan( + pointerId: number, + position: eventPosition, + onFrame: (position: eventPosition) => void, + ): void { + if (!this.config.edgePan?.enabled) { + return; + } + this.#edgePanRequest = { pointerId, position, onFrame }; + this.#edgePanTimestamp = null; + this.#scheduleEdgePanFrame(); + } + + updateEdgePan(pointerId: number, position: eventPosition): void { + if (!this.config.edgePan?.enabled) { + this.stopEdgePan(pointerId); + return; + } + if (!this.#edgePanRequest) { + return; + } + if (this.#edgePanRequest.pointerId !== pointerId) { + return; + } + this.#edgePanRequest.position = position; + this.#scheduleEdgePanFrame(); + } + + stopEdgePan(pointerId: number): void { + if ( + this.#edgePanRequest && + this.#edgePanRequest.pointerId !== pointerId + ) { + return; + } + this.#edgePanRequest = null; + this.#edgePanTimestamp = null; + if (this.#edgePanFrameId != null) { + cancelAnimationFrame(this.#edgePanFrameId); + this.#edgePanFrameId = null; + } + } + + #scheduleEdgePanFrame(): void { + if (this.#edgePanFrameId != null || !this.#edgePanRequest) { + return; + } + this.#edgePanFrameId = requestAnimationFrame(this.#runEdgePanFrame); + } + + #runEdgePanFrame = (timestamp: number): void => { + this.#edgePanFrameId = null; + const request = this.#edgePanRequest; + const camera = this.engine.camera; + const config = this.config.edgePan; + if (!request || !camera || !config?.enabled) { + this.#edgePanTimestamp = null; + return; + } + + const edgeDistance = Math.max(1, config.edgeDistance ?? 48); + const maxSpeed = Math.max(0, config.maxSpeed ?? 600); + const left = camera.containerOffsetX; + const top = camera.containerOffsetY; + const right = left + camera.cameraWidth; + const bottom = top + camera.cameraHeight; + const axisSpeed = (value: number, min: number, max: number): number => { + if (value < min + edgeDistance) { + return -Math.min(1, (min + edgeDistance - value) / edgeDistance); + } + if (value > max - edgeDistance) { + return Math.min(1, (value - (max - edgeDistance)) / edgeDistance); + } + return 0; + }; + const velocityX = + axisSpeed(request.position.screenX, left, right) * maxSpeed; + const velocityY = + axisSpeed(request.position.screenY, top, bottom) * maxSpeed; + const previousTimestamp = this.#edgePanTimestamp ?? timestamp; + const elapsedSeconds = + Math.min(32, Math.max(0, timestamp - previousTimestamp)) / 1000; + this.#edgePanTimestamp = timestamp; + + if ( + !this.config.panLock && + (velocityX !== 0 || velocityY !== 0) && + elapsedSeconds > 0 + ) { + camera.handlePan( + velocityX * elapsedSeconds, + velocityY * elapsedSeconds, + ); + this.paintCamera(); + request.onFrame( + this.#positionFromScreen( + request.position.screenX, + request.position.screenY, + ), + ); + } + + this.#scheduleEdgePanFrame(); + }; + + #positionFromScreen(screenX: number, screenY: number): eventPosition { + const camera = this.engine.camera; + if (!camera) { + return { + x: screenX, + y: screenY, + cameraX: screenX, + cameraY: screenY, + screenX, + screenY, + }; + } + const [cameraX, cameraY] = camera.getCameraFromScreen(screenX, screenY); + const [x, y] = camera.getWorldFromCamera(cameraX, cameraY); + return { x, y, cameraX, cameraY, screenX, screenY }; + } + // Event Handlers onCursorDown(prop: pointerDownProp) { - if (prop.event.button != 0) { + const options = resolveCameraOptions(this.config); + // Left button is 0, middle button is 1. The pan button is configurable so + // consumers can reserve the left button for another gesture. + const panButton = options.panButton; + const buttonPans = + (panButton === "left" || panButton === "both") && prop.event.button === 0 + ? true + : (panButton === "middle" || panButton === "both") && prop.event.button === 1; + if (!buttonPans) { return; } if (this.#state !== "idle") { @@ -171,13 +402,15 @@ class CameraControl extends ElementObject { if (this.config.panLock) { return; } - const pointerPanLock = this.config.pointerPanLock; + const pointerPanLock = options.pointerPanLock; if ( pointerPanLock === true || (pointerPanLock === "touch" && prop.event.pointerType === "touch") ) { return; } + // Gesture owners block the camera at the input-dispatch layer (pointer + // claims); this legacy boolean remains for third-party writers only. if (this.global.data.allowCameraControl === false) { return; } @@ -200,6 +433,8 @@ class CameraControl extends ElementObject { if (prop.event?.pointerId !== this.#panPointerId) { return; } + // Gesture owners block the camera at the input-dispatch layer (pointer + // claims); this legacy boolean remains for third-party writers only. if (this.global.data.allowCameraControl === false) { return; } @@ -214,6 +449,7 @@ class CameraControl extends ElementObject { } onCursorUp(prop: pointerUpProp) { + this.stopEdgePan(prop.event.pointerId); if (this.#state != "panning") { return; } @@ -231,15 +467,21 @@ class CameraControl extends ElementObject { } onZoom(prop: mouseWheelProp) { + const options = resolveCameraOptions(this.config); + const event = prop.event as WheelEvent; + const zoomIntent = event.ctrlKey || event.metaKey; + // Trackpad two-finger scroll pans; a modifier (and trackpad pinch, reported as + // a ctrl-wheel) falls through to zoom. + if (options.wheelAction === "pan" && !zoomIntent) { + this.panByWheel(prop, options.wheelPanSensitivity); + return; + } if (this.config.zoomLock) { return; } - if (this.config.wheelZoomModifier === "ctrlOrMeta") { - const event = prop.event as WheelEvent; - if (!event.ctrlKey && !event.metaKey) { - // Return without preventDefault so the page keeps scrolling. - return; - } + if (options.wheelZoomModifier === "ctrlOrMeta" && !zoomIntent) { + // Return without preventDefault so the page keeps scrolling. + return; } const camera = this.engine.camera!; if ( @@ -250,18 +492,60 @@ class CameraControl extends ElementObject { ) { return; } + // A trackpad pinch is a ctrl-wheel; Cmd+scroll is a meta-wheel with much larger + // deltas, so only the pinch gets the extra gain. Negate so pinch-out / scroll-up + // zooms in — the natural direction on every platform. + const pinch = event.ctrlKey && !event.metaKey; + const sensitivity = + options.zoomSensitivity * (pinch ? options.pinchZoomGain : 1); this.zoomBy( - prop.delta / 2000, + (-prop.delta * sensitivity) / 2000, prop.position.cameraX, prop.position.cameraY, ); prop.event.preventDefault(); } + private panByWheel(prop: mouseWheelProp, sensitivity: number) { + if (this.config.panLock) { + return; + } + // Gesture owners block the camera at the input-dispatch layer (pointer + // claims); this legacy boolean remains for third-party writers only. + if (this.global.data.allowCameraControl === false) { + return; + } + const camera = this.engine.camera; + if (!camera) { + return; + } + if ( + prop.position.screenX < camera.containerOffsetX || + prop.position.screenX > camera.containerOffsetX + camera.cameraWidth || + prop.position.screenY < camera.containerOffsetY || + prop.position.screenY > camera.containerOffsetY + camera.cameraHeight + ) { + return; + } + // Wheel deltas are screen pixels in the document-scroll sense (deltaY > 0 = + // scroll down); handlePan reads them the same way (positive = pan down) and + // divides by zoom, giving 1:1 screen-pixel panning like a pointer drag. + const event = prop.event as WheelEvent; + camera.handlePan(event.deltaX * sensitivity, event.deltaY * sensitivity); + this.style.transform = camera.canvasStyle as string; + this.schedule(() => this.writeTransform(), { + stage: "WRITE_2", + queueId: `${this.id}-transform`, + }); + prop.event.preventDefault(); + } + onPinchStart() { if (this.config.zoomLock && this.config.panLock) { return; } + // Gesture owners block the camera at the input-dispatch layer (pointer + // claims); this legacy boolean remains for third-party writers only. if (this.global.data.allowCameraControl === false) { return; } @@ -275,6 +559,8 @@ class CameraControl extends ElementObject { if (this.config.zoomLock && this.config.panLock) { return; } + // Gesture owners block the camera at the input-dispatch layer (pointer + // claims); this legacy boolean remains for third-party writers only. if (this.global.data.allowCameraControl === false) { return; } @@ -329,6 +615,16 @@ class CameraControl extends ElementObject { this.#pinchAnchor = null; } + destroy(removeDom: boolean = true) { + if (this.#edgePanRequest) { + this.stopEdgePan(this.#edgePanRequest.pointerId); + } + if (this.engine.edgePanController === this) { + this.engine.edgePanController = null; + } + super.destroy(removeDom); + } + #createPinchAnchor(center: { x: number; y: number }, distance: number) { const camera = this.engine.camera!; return { diff --git a/assets/asset-base/core/src/index.ts b/assets/asset-base/core/src/index.ts index 24147b4..a0b6f8d 100644 --- a/assets/asset-base/core/src/index.ts +++ b/assets/asset-base/core/src/index.ts @@ -1,3 +1,9 @@ -export { CameraControl } from "./camera"; -export type { CameraControlConfig } from "./camera"; +export { CameraControl, resolveCameraOptions } from "./camera"; +export type { + CameraControlConfig, + CameraWheelConfig, + CameraPointerConfig, + CameraEdgePanConfig, + ResolvedCameraOptions, +} from "./camera"; export { Background } from "./background"; diff --git a/assets/asset-base/react/src/Camera.tsx b/assets/asset-base/react/src/Camera.tsx index 3a33d79..4a7802b 100644 --- a/assets/asset-base/react/src/Camera.tsx +++ b/assets/asset-base/react/src/Camera.tsx @@ -11,6 +11,11 @@ import { type ReactNode, } from "react"; import { CameraControl } from "@snap-engine/asset-base"; +import type { + CameraEdgePanConfig, + CameraWheelConfig, + CameraPointerConfig, +} from "@snap-engine/asset-base"; import type { CameraConfig } from "@snap-engine/core"; import { useSnapEngine } from "./Engine"; @@ -40,6 +45,20 @@ export interface CameraProps pointerPanLock?: boolean | "touch"; /** Require ctrl/cmd for wheel zoom so unmodified scrolling pans the page. */ wheelZoomModifier?: "none" | "ctrlOrMeta"; + /** Pan on unmodified wheel (trackpad two-finger scroll); ctrl/cmd wheel zooms. */ + wheelPan?: boolean; + /** Multiplies wheel-zoom speed (default 1); trackpad pinch is scaled up further. */ + zoomSensitivity?: number; + /** Multiplies wheel-pan speed (default 1 = 1:1 screen pixels), independent of zoom. */ + wheelPanSensitivity?: number; + /** Which mouse button starts a pointer pan (default "left"). */ + panButton?: "left" | "middle" | "both"; + /** Wheel/trackpad behavior, grouped. Wins over the flat deprecated props. */ + wheel?: CameraWheelConfig; + /** Pointer behavior, grouped. Wins over the flat deprecated props. */ + pointer?: CameraPointerConfig; + /** Programmatic edge-pan behavior for drag owners such as SnapLine. */ + edgePan?: CameraEdgePanConfig; zoomLock?: boolean; } @@ -65,6 +84,13 @@ export const Camera = forwardRef(function Camera( panLock = false, pointerPanLock = false, wheelZoomModifier = "none", + wheelPan = false, + zoomSensitivity = 1, + wheelPanSensitivity = 1, + panButton = "left", + wheel = undefined, + pointer = undefined, + edgePan = undefined, style, zoomLock = false, ...divProps @@ -95,6 +121,13 @@ export const Camera = forwardRef(function Camera( zoomLock, pointerPanLock, wheelZoomModifier, + wheelPan, + zoomSensitivity, + wheelPanSensitivity, + panButton, + wheel, + pointer, + edgePan, camera: cameraConfig, }); ownedCameraControlRef.current = cameraControl; @@ -160,8 +193,28 @@ export const Camera = forwardRef(function Camera( zoomLock, pointerPanLock, wheelZoomModifier, + wheelPan, + zoomSensitivity, + wheelPanSensitivity, + panButton, + wheel, + pointer, + edgePan, }; - }, [activeCameraControl, panLock, zoomLock, pointerPanLock, wheelZoomModifier]); + }, [ + activeCameraControl, + panLock, + zoomLock, + pointerPanLock, + wheelZoomModifier, + wheelPan, + zoomSensitivity, + wheelPanSensitivity, + panButton, + wheel, + pointer, + edgePan, + ]); // Bounds often depend on measured layout, so keep applying them as they change. useEffect(() => { diff --git a/assets/asset-base/svelte/src/Camera.svelte b/assets/asset-base/svelte/src/Camera.svelte index 72aace0..bb0f0c3 100644 --- a/assets/asset-base/svelte/src/Camera.svelte +++ b/assets/asset-base/svelte/src/Camera.svelte @@ -9,6 +9,11 @@ } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { CameraControl as CameraControlObject } from "@snap-engine/asset-base"; + import type { + CameraEdgePanConfig, + CameraWheelConfig, + CameraPointerConfig, + } from "@snap-engine/asset-base"; import type { CameraConfig, Engine } from "@snap-engine/core"; type CameraProps = Omit, "children"> & { @@ -20,6 +25,20 @@ pointerPanLock?: boolean | "touch"; /** Require ctrl/cmd for wheel zoom so unmodified scrolling pans the page. */ wheelZoomModifier?: "none" | "ctrlOrMeta"; + /** Pan on unmodified wheel (trackpad two-finger scroll); ctrl/cmd wheel zooms. */ + wheelPan?: boolean; + /** Multiplies wheel-zoom speed (default 1); trackpad pinch is scaled up further. */ + zoomSensitivity?: number; + /** Multiplies wheel-pan speed (default 1 = 1:1 screen pixels), independent of zoom. */ + wheelPanSensitivity?: number; + /** Which mouse button starts a pointer pan (default "left"). */ + panButton?: "left" | "middle" | "both"; + /** Wheel/trackpad behavior, grouped. Wins over the flat deprecated props. */ + wheel?: CameraWheelConfig; + /** Pointer behavior, grouped. Wins over the flat deprecated props. */ + pointer?: CameraPointerConfig; + /** Programmatic edge-pan behavior for drag owners such as SnapLine. */ + edgePan?: CameraEdgePanConfig; /** Options forwarded to the underlying Camera, e.g. zoomBounds and contentBounds. */ cameraConfig?: CameraConfig; cameraControl?: CameraControlObject | null; @@ -34,6 +53,13 @@ panLock = false, pointerPanLock = false, wheelZoomModifier = "none", + wheelPan = false, + zoomSensitivity = 1, + wheelPanSensitivity = 1, + panButton = "left", + wheel = undefined, + pointer = undefined, + edgePan = undefined, cameraConfig, cameraControl = $bindable(null), style = "", @@ -51,6 +77,13 @@ zoomLock, pointerPanLock, wheelZoomModifier, + wheelPan, + zoomSensitivity, + wheelPanSensitivity, + panButton, + wheel, + pointer, + edgePan, camera: cameraConfig, })); const cameraControlInstance = @@ -74,6 +107,13 @@ zoomLock, pointerPanLock, wheelZoomModifier, + wheelPan, + zoomSensitivity, + wheelPanSensitivity, + panButton, + wheel, + pointer, + edgePan, }; }); diff --git a/assets/snapline/AGENTS.md b/assets/snapline/AGENTS.md index 5729308..f123d37 100644 --- a/assets/snapline/AGENTS.md +++ b/assets/snapline/AGENTS.md @@ -4,8 +4,9 @@ Node-based graph UI system for creating visual programming interfaces, node editors, and flow-based applications. -SnapLine is experimental. Its core, Svelte, and React workspace packages are -private and are intentionally excluded from npm publish workflows. +SnapLine is experimental and published as synchronized core, Svelte, and React +packages. Breaking changes are allowed before 1.0 and should replace obsolete +APIs directly rather than adding compatibility shims. ## Packages @@ -15,11 +16,13 @@ private and are intentionally excluded from npm publish workflows. **Dependencies:** `@snap-engine/core` **Exports:** -- `NodeComponent` - Graph node with connectors +- `NodeComponent` - Graph node with connectors (opt-in eight-direction resize) - `ConnectorComponent` - Input/output connector - `LineComponent` - Visual connection line +- `GroupNodeComponent` - Resizable box that carries the nodes inside it - `RectSelectComponent` - Rectangle selection tool -- `GlobalManager` - Shared state manager +- `PlacementController` - Headless pointer-follow placement state machine +- `snapline-globals` - Typed accessors for the shared `global.data` registries ### @snap-engine/snapline-svelte **Location:** `svelte/src/` @@ -28,9 +31,19 @@ private and are intentionally excluded from npm publish workflows. **Exports:** - `Node.svelte` - Node component +- `Group.svelte` - Exclusive nested group component - `Connector.svelte` - Connector component - `Line.svelte` - Connection line component - `Select.svelte` - Rectangle selection component +- `Placement.svelte` - Placement controller binding and preview + +### @snap-engine/snapline-react +**Location:** `react/src/` +**Language:** React/TypeScript +**Dependencies:** `@snap-engine/snapline`, `@snap-engine/core` + +Exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, and +`Placement`, with forwarded refs to core objects where applicable. ## File Structure @@ -148,6 +161,127 @@ snapline/ **Props:** None +## DOM ownership contract (framework-cooperative rendering) + +Mirrors the SnapSort rule: when a framework binding (Svelte/React) is in use, +**structural DOM (adding, moving, removing, or reparenting elements) is +framework-owned.** Core never inserts or removes elements. Core MAY directly +write **transforms, `data-*` attributes, and property tweaks on existing +elements** — frameworks recover fine from property changes. + +Concretely: + +- **Node width/height** are framework-rendered: core fires + `callbacks.onSizeChange({node, width, height})` during a resize drag and the adapter binds + the size as state. Core only updates its collision hitboxes synchronously + (`setSizeState`). The connector/line re-glue closes itself through the + ResizeObserver after the framework's DOM write reflows. +- **Initial node geometry** is explicit: after assigning a committed framework + element, adapters call `syncDomGeometry()`. ResizeObserver remains the + ongoing invalidation path, not the initial-mount handshake. +- **The rubber-band selection box** is framework-rendered: core fires + `callbacks.onRectChange({x, y, width, height, visible})` and the adapter + draws (and can restyle/replace) the box. Deliberately NO flush handshake — + the box visual is not paint-atomic. +- **Node drag transforms, `data-selected` attributes, and line SVG transforms** + stay engine-written (property writes on existing elements). +- **Adapters must render node/group elements with + `position: absolute; transform-origin: top left`** (and ideally + `will-change: transform`) — core no longer seeds base styles. +- SnapLine has **no `flushMutation`/`settleMutation` equivalent and must not + grow one**: unlike SnapSort's FLIP pipeline, none of SnapLine's delegated + visuals are paint-atomic. + +### Callback conventions + +Domain/lifecycle callbacks live in `EventProxyFactory` dictionaries — +Configuration owns plain callback objects. Node callbacks report drag, +selection, resize, and line-list events; group callbacks report membership +deltas; selection callbacks decide whether rubber-band selection may start; +connector callbacks provide connection policy plus drag/candidate/connect/ +disconnect lifecycle events. Events carry component references, metadata, and +explicit origins/reasons so consumers never need teardown heuristics. + +SnapLine deliberately does not define port types, graph-document mutations, +palette contents, or node factories. Consumers express those policies through +metadata and predicates such as `canConnect`, `canContain`, and `canStart`. +`PlacementController` similarly computes preview/commit coordinates but leaves +rendering and creation to framework adapters and consumer callbacks. +Raw input/DOM plumbing stays on the `event.*` slots. + +### NodeManager (engine-scoped registry) + +`core/src/node-manager.ts` is the per-engine registry of live SnapLine nodes +and connectors, lazy-created by `getNodeManager(engine)` the first time any +component registers (constructors register, `destroy()` unregisters — no +adapter wiring). `query.ts` enumeration delegates to it, and it hosts +engine-scoped facilities: the controlled-edges controller today, layout +helpers that walk `nodes` tomorrow. GlobalManager is application-wide, so the +managers live in `SnapLineSharedData.nodeManagers` keyed by engine. + +### Controlled edges (EdgeSyncController) + +`core/src/edge-sync.ts` + the `EdgeSync` adapter components implement the +controlled-edges contract: the CONSUMER's edge document is the only edge +authority; SnapLine reconciles rendered lines to it (`sync()`, hydrating +missing lines with origin `"hydration"`) and translates gestures into +semantic intents (`onEdgeConnect` for gesture connects, `onEdgeDisconnect` +for gesture and replacement disconnects). Programmatic, hydration, and +teardown changes never forward as intents, and sync never forwards its own +mutations (`#syncing` guard). Edges exist in exactly two representations — +consumer document and rendered lines; `NodeManager` holds membership only and +the controller stores no edges (`getEdges()` is consulted fresh). Intents fire +synchronously inside the drop dispatch; adapters reconcile in a microtask of +the same task, so accept and reject paths both resolve before the frame +paints WITHOUT any paint-atomic flush contract (the no-flushMutation rule +above still holds). Consumers should write their document synchronously +inside intent handlers; deferred stores degrade to a one-frame pending state, +never an inconsistent one. + +### Shared global registries + +Everything SnapLine stores on the engine's shared `global.data` bag is declared +in `core/src/snapline-globals.ts` (`SnapLineSharedData`) and accessed through +its typed helpers. Engine core's `input.ts` reads `resizeHandles` duck-typed +(it cannot import snapline) — keep the two shapes in sync. + +### Pointer claims (camera blocking) + +Gesture owners block the camera (and any other GLOBAL input listener) at the +input-dispatch layer, not through a side-channel flag: call +`engine.input.claimPointer(pointerId)` from the gesture's **pointerDown** +handler (claiming at dragStart is legal, but the camera may already have +panned by the 3px drag-start threshold). While a pointer is claimed: + +- global `pointerDown`/`pointerMove`/`dragStart`/`drag` for that pointer are + not delivered; owner dispatch is unaffected; +- global pinches involving the claimed pointer are suppressed; +- global `mouseWheel` is suppressed while ANY claim is held (no camera + wheel-pan mid-drag); +- end events (`pointerUp`/`dragEnd`/`pinchEnd`) ALWAYS deliver, so a global + listener that engaged before a late claim can terminate cleanly. + +Claims are anchored to the input layer's per-pointer records and **die with +the gesture** (pointer up/cancel) — there is no release call to pair, and a +destroyed owner cannot strand a claim. The deprecated `allowCameraControl` +boolean remains readable by the camera for third-party writers only. + +### Group invariants + +- Ordinary nodes use center containment; nested groups require full-bounds + containment. `canContain` may reject any otherwise eligible node or group. +- Each node has at most one resolved direct parent group. `members` and + membership callbacks describe that direct relation; `descendants` exposes + the recursive tree. Consumers may replace innermost-parent selection through + `setGroupMembershipResolver`. +- Drag carry recursively flattens and deduplicates nested group descendants. +- Equal-size group candidates use stable IDs as a deterministic tie-breaker; + membership cycles are always rejected. +- Carried group members are moved via transform parenting only — they are never + added to `global.data.select`, so a group drag does not alter the selection. +- `attachTransformToGroup`/`detachTransformFromGroup` are the public + transform-only reparent seam used by the group carry. + ## Key Concepts ### Property System @@ -165,6 +299,16 @@ snapline/ - `-1`: Unlimited connections - `0`: No incoming (output only) - `N`: Maximum N incoming connections +- A new connection to a full finite input evicts the oldest live incoming + line(s) required to make room. Disconnect callbacks fire before the new + connect callbacks. + +### Camera edge-pan + +When an engine exposes an enabled `edgePanController`, connector-line drags and +node/group move drags request edge-panning automatically. The controller feeds +updated world coordinates back into the active drag every frame. Selection and +resize gestures intentionally do not edge-pan. ## Dependencies diff --git a/assets/snapline/core/README.md b/assets/snapline/core/README.md index 89abb5f..a025ea7 100644 --- a/assets/snapline/core/README.md +++ b/assets/snapline/core/README.md @@ -1,22 +1,68 @@ # @snap-engine/snapline -Core TypeScript logic for SnapEngine node graph interfaces. +Framework-neutral node graph interaction primitives for SnapEngine. + +SnapLine provides draggable and resizable nodes, connector policy, SVG line +geometry, rectangle selection, exclusive nested groups, engine queries, and +headless palette placement. Applications retain ownership of graph documents, +node types, validation, persistence, and styling. ## Install ```bash -npm install @snap-engine/snapline @snap-engine/core +npm install @snap-engine/core @snap-engine/snapline ``` -## Includes - -- `NodeComponent` -- `ConnectorComponent` -- `LineComponent` -- `RectSelectComponent` +## Entry points -## Usage +- `@snap-engine/snapline` +- `@snap-engine/snapline/node` +- `@snap-engine/snapline/connector` +- `@snap-engine/snapline/line` +- `@snap-engine/snapline/select` +- `@snap-engine/snapline/group` +- `@snap-engine/snapline/placement` +- `@snap-engine/snapline/query` ```ts -import { NodeComponent } from "@snap-engine/snapline"; -``` \ No newline at end of file +import { + GroupNodeComponent, + NodeComponent, + getParentGroup, + setGroupMembershipResolver, +} from "@snap-engine/snapline"; +``` + +After assigning a Vanilla-rendered element, call `syncDomGeometry()`. Svelte +and React adapters perform that synchronization automatically. + +When another interaction system applies transient transforms inside a node, +call `connector.requestDomGeometrySync()` for each affected connector. The +request is coalesced into the next read/write cycle and updates every connected +line without coupling SnapLine to the external system. + +Surface strategies decouple connection hit testing from visible connector +elements. They can activate from a node border, rank shape-specific target +hits, and resolve preview and settled anchors from cached geometry. Independent +connector capabilities allow the same logical surface to start and accept +connections. `onPointerDown` runs when a connector claims the primary pointer, +before the drag threshold, so consumers can preserve click selection or other +gesture-start UI for headless surfaces. + +Call `connector.updateConfig(...)` to change callbacks, metadata, policy, +surface strategies, collider radius, edge-pan behavior, or the line class +without replacing the connector or its existing lines. `name` is +construction-only because it is the connector's key in its parent node. + +For a framework-owned graph, use `onConnectionRequest` to create the domain +edge and return its stable ID as an opaque line payload. Pass that payload +directly when hydrating with `connectToConnector`; programmatic connections do +not invoke the creation request. + +Groups maintain an exclusive direct parent. Ordinary nodes use center +containment, nested groups use full-bounds containment, and the smallest safe +candidate wins unless an engine-level resolver overrides it. + +SnapLine `0.3` is experimental and may make breaking changes before `1.0`. + +Full documentation: https://snapengine.dev/docs/snapline/introduction diff --git a/assets/snapline/core/package.json b/assets/snapline/core/package.json index 0a533df..98c721f 100644 --- a/assets/snapline/core/package.json +++ b/assets/snapline/core/package.json @@ -1,7 +1,6 @@ { "name": "@snap-engine/snapline", - "private": true, - "version": "0.2.0", + "version": "0.3.0", "repository": { "type": "git", "url": "git+https://github.com/tfukaza/SnapEngineJS.git", @@ -16,7 +15,10 @@ "./node": "./src/node.ts", "./connector": "./src/connector.ts", "./line": "./src/line.ts", - "./select": "./src/select.ts" + "./select": "./src/select.ts", + "./group": "./src/group.ts", + "./placement": "./src/placement.ts", + "./query": "./src/query.ts" }, "files": [ "src", diff --git a/assets/snapline/core/src/connector.ts b/assets/snapline/core/src/connector.ts index 02172b5..2dcd761 100644 --- a/assets/snapline/core/src/connector.ts +++ b/assets/snapline/core/src/connector.ts @@ -1,36 +1,217 @@ import { ElementObject, BaseObject } from "@snap-engine/core"; -import { NodeComponent } from "./node"; +import type { + dragEndProp, + dragProp, + dragStartProp, + eventPosition, + pointerDownProp, + pointerUpProp, +} from "@snap-engine/core"; +import { CircleCollider } from "@snap-engine/core/collision"; +import type { NodeComponent } from "./node"; import { LineComponent } from "./line"; -import type { pointerDownProp, dragProp, dragEndProp } from "@snap-engine/core"; -import { - Collider, - CircleCollider, - PointCollider, -} from "@snap-engine/core/collision"; -import { EventProxyFactory } from "@snap-engine/core"; +import { getNodeManager } from "./snapline-globals"; +import { getSourceSurfaces } from "./snapline-globals"; + +export type SnapLineMetadata = Record; +export type ConnectionOrigin = "gesture" | "programmatic" | "hydration"; +export type DisconnectReason = + | "gesture" + | "replacement" + | "programmatic" + | "teardown"; +export type ConnectorRole = "source" | "target"; +export type ConnectorLinePhase = + | "source-start" + | "preview-free" + | "preview-target" + | "drop" + | "connected"; + +export interface ConnectorPoint { + x: number; + y: number; +} + +export interface ConnectorNormal { + x: number; + y: number; +} + +export interface ConnectorAnchor extends ConnectorPoint { + normal?: ConnectorNormal; +} + +/** Cached world-space bounds derived from the parent node's collision box. */ +export interface ConnectorGeometrySnapshot { + connector: ConnectorComponent; + node: NodeComponent; + x: number; + y: number; + width: number; + height: number; + left: number; + right: number; + top: number; + bottom: number; + center: ConnectorAnchor; + scaleX: number; + scaleY: number; +} + +export interface ConnectorHit { + anchor: ConnectorAnchor; + distance: number; + priority?: number; + payload?: unknown; +} + +export interface ConnectorCandidate { + connector: ConnectorComponent; + hit: ConnectorHit; +} + +export interface ConnectorSurfaceHitTestEvent { + connector: ConnectorComponent; + position: eventPosition; + geometry: ConnectorGeometrySnapshot; + phase: ConnectorLinePhase; +} + +export interface ConnectorAnchorEvent { + connector: ConnectorComponent; + peer: ConnectorComponent | null; + line: LineComponent; + role: ConnectorRole; + phase: ConnectorLinePhase; + position: ConnectorPoint; + geometry: ConnectorGeometrySnapshot; + peerGeometry: ConnectorGeometrySnapshot | null; + hit: ConnectorHit | null; +} + +export interface ConnectorSurfaceStrategy { + sourceHitTest?: ( + event: ConnectorSurfaceHitTestEvent, + ) => ConnectorHit | null | false | void; + targetHitTest?: ( + event: ConnectorSurfaceHitTestEvent, + ) => ConnectorHit | null | false | void; + resolveAnchor?: ( + event: ConnectorAnchorEvent, + ) => ConnectorAnchor | null | void; +} + +export interface ConnectorCapabilities { + source: boolean; + target: boolean; + maxIncoming: number; + reconnect: boolean; + allowParallel: boolean; +} + +export interface ConnectorPairEvent { + source: ConnectorComponent; + target: ConnectorComponent; +} + +export interface ConnectorCandidateEvent { + source: ConnectorComponent; + /** Legacy convenience reference. */ + candidate: ConnectorComponent | null; + resolvedCandidate: ConnectorCandidate | null; + line: LineComponent | null; +} + +export interface ConnectorConnectionEvent extends ConnectorPairEvent { + connector: ConnectorComponent; + peer: ConnectorComponent; + line: LineComponent; + role: ConnectorRole; + origin: ConnectionOrigin; +} + +export interface ConnectorDisconnectionEvent + extends Omit { + reason: DisconnectReason; +} + +export interface ConnectorDragEvent { + connector: ConnectorComponent; + position: eventPosition; + pointerId: number; +} + +export interface ConnectorPointerEvent extends ConnectorDragEvent { + originalEvent: PointerEvent; +} + +export interface ConnectorConnectionRequestEvent extends ConnectorPairEvent { + line: LineComponent; + candidate: ConnectorCandidate; + position: eventPosition; +} + +export type ConnectorConnectionRequestResult = + | false + | void + | { payload?: unknown }; + +export interface ConnectorCallbacks { + canConnect?: (event: ConnectorPairEvent) => boolean; + /** Gesture-only and source-only canonical-model creation seam. */ + onConnectionRequest?: ( + event: ConnectorConnectionRequestEvent, + ) => ConnectorConnectionRequestResult; + /** Fires when this connector claims a primary pointer, before drag threshold. */ + onPointerDown?: (event: ConnectorPointerEvent) => void; + onDragStart?: (event: ConnectorDragEvent) => void; + onCandidateChange?: (event: ConnectorCandidateEvent) => void; + onConnect?: (event: ConnectorConnectionEvent) => void; + onDisconnect?: (event: ConnectorDisconnectionEvent) => void; + onDragEnd?: (event: ConnectorDragEvent & { connected: boolean }) => void; +} enum ConnectorState { IDLE, + ARMED, DRAGGING, } export interface ConnectorConfig { name?: string; + /** @deprecated Prefer capabilities.maxIncoming. */ maxConnectors?: number; + /** @deprecated Prefer capabilities.source/target. */ allowDragOut?: boolean; + capabilities?: Partial; + surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; lineClass?: typeof LineComponent; colliderRadius?: number; + metadata?: SnapLineMetadata; + callbacks?: ConnectorCallbacks; + /** Allows this connector gesture to use the engine's configured edge pan. */ + edgePan?: boolean; } -interface ConnectorCallback { - onConnectOutgoing: null | ((connector: ConnectorComponent) => void); - onConnectIncoming: null | ((connector: ConnectorComponent) => void); - onDisconnectOutgoing: null | ((connector: ConnectorComponent) => void); - onDisconnectIncoming: null | ((connector: ConnectorComponent) => void); +export type ConnectorConfigUpdate = Partial>; + +export interface ConnectorResolvedHit { + candidate: ConnectorCandidate; + strategy: ConnectorSurfaceStrategy | null; + strategyIndex: number; +} + +interface ArmedConnection { + pointerId: number; + sourceHit: ConnectorHit | null; + sourceStrategy: ConnectorSurfaceStrategy | null; + reconnectLine: LineComponent | null; } class ConnectorComponent extends ElementObject { #config: ConnectorConfig; + #capabilities: Readonly; #name: string; #prop: { [key: string]: any }; #outgoingLines: LineComponent[]; @@ -38,13 +219,15 @@ class ConnectorComponent extends ElementObject { #state: ConnectorState = ConnectorState.IDLE; #hitCircle: CircleCollider; - #mouseHitBox: PointCollider; - #targetConnector: ConnectorComponent | null = null; - #localCenter: { x: number; y: number }; + #candidate: ConnectorResolvedHit | null = null; + #dragLine: LineComponent | null = null; + #edgePanPointerId: number | null = null; + #localCenter: ConnectorPoint; #hasMeasuredCenter = false; - - #connectorCallback: ConnectorCallback | null = null; + #armed: ArmedConnection | null = null; + #cancelledPointers = new Set(); + #callbacks: ConnectorCallbacks; get parent(): NodeComponent { return super.parent as NodeComponent; @@ -64,27 +247,30 @@ class ConnectorComponent extends ElementObject { this.#prop = {}; this.#outgoingLines = []; this.#incomingLines = []; - this.#config = config; + this.#config = { ...config }; + this.#capabilities = Object.freeze(resolveCapabilities(this.#config)); this.#name = config.name || this.id || ""; + this.#callbacks = config.callbacks ?? {}; + this.#localCenter = { x: 0, y: 0 }; + this.transformMode = "none"; this.event.input.pointerDown = this.onCursorDown; + this.event.input.pointerUp = this.#onPointerUp; + this.event.input.dragStart = this.#onDragStart; + this.event.input.drag = this.runDragOutLine; + this.event.input.dragEnd = this.endDragOutLine; + this.globalInput.pointerUp = this.#onPointerUp; this.#hitCircle = new CircleCollider( engine, this, 0, 0, - config.colliderRadius ?? 30, + this.#config.colliderRadius ?? 30, ); this.addCollider(this.#hitCircle); + this.#syncSourceSurfaceRegistration(); + getNodeManager(this.engine).registerConnector(this); - this.#mouseHitBox = new PointCollider(engine, this, 0, 0); - this.addCollider(this.#mouseHitBox); - - this.#targetConnector = null; - this.#localCenter = { x: 0, y: 0 }; - this.transformMode = "none"; - - // Center colliders on the connector element once DOM is assigned this.event.dom.onAssignDom = () => { this.schedule( () => { @@ -92,15 +278,8 @@ class ConnectorComponent extends ElementObject { }, { stage: "READ_1" }, ); + this.scheduleAllLineWrites(); }; - - this.#connectorCallback = { - onConnectOutgoing: null, - onConnectIncoming: null, - onDisconnectOutgoing: null, - onDisconnectIncoming: null, - }; - this.#connectorCallback = EventProxyFactory(this, this.#connectorCallback); } get name(): string { @@ -111,10 +290,30 @@ class ConnectorComponent extends ElementObject { return this.#config; } + get capabilities(): Readonly { + return this.#capabilities; + } + + get surfaceStrategies(): readonly ConnectorSurfaceStrategy[] { + return this.#config.surfaceStrategies ?? []; + } + get prop(): { [key: string]: any } { return this.#prop; } + get metadata(): SnapLineMetadata { + return this.#config.metadata ?? {}; + } + + get callbacks(): ConnectorCallbacks { + return this.#callbacks; + } + + set callbacks(callbacks: ConnectorCallbacks) { + this.updateConfig({ callbacks }); + } + get outgoingLines(): LineComponent[] { return this.#outgoingLines; } @@ -128,7 +327,20 @@ class ConnectorComponent extends ElementObject { } set targetConnector(value: ConnectorComponent | null) { - this.#targetConnector = value; + const resolved = value + ? { + candidate: { + connector: value, + hit: { + anchor: value.center, + distance: 0, + }, + }, + strategy: value.#defaultAnchorStrategy(), + strategyIndex: -1, + } + : null; + this.#setCandidate(resolved); } get numIncomingLines(): number { @@ -139,7 +351,58 @@ class ConnectorComponent extends ElementObject { return this.#outgoingLines.length; } - get center(): { x: number; y: number } { + /** + * Updates runtime connector policy without replacing the connector or its + * existing topology. `name` remains construction-only because it keys the + * connector in its parent node. + */ + updateConfig(config: ConnectorConfigUpdate): void { + this.#config = { ...this.#config, ...config }; + this.#callbacks = this.#config.callbacks ?? {}; + this.#capabilities = Object.freeze(resolveCapabilities(this.#config)); + this.#hitCircle.radius = this.#config.colliderRadius ?? 30; + this.#syncSourceSurfaceRegistration(); + this.scheduleAllLineWrites(); + } + + /** + * Attaches or detaches the optional visible port without destroying the + * logical connector. Framework adapters use this for live `virtual` changes. + */ + bindElement(element: HTMLElement | null): void { + if (this.element === element) return; + if (this.element) { + this.destroyDom(false); + this.#hasMeasuredCenter = false; + } + if (element) { + this.element = element; + } else { + this.#hasMeasuredCenter = false; + this.scheduleAllLineWrites(); + } + } + + get geometry(): ConnectorGeometrySnapshot { + const bounds = this.parent.hitBox.getWorldBoundsSnapshot(); + return { + connector: this, + node: this.parent, + x: bounds.left, + y: bounds.top, + width: bounds.width, + height: bounds.height, + left: bounds.left, + right: bounds.right, + top: bounds.top, + bottom: bounds.bottom, + center: { x: bounds.centerX, y: bounds.centerY }, + scaleX: bounds.scaleX, + scaleY: bounds.scaleY, + }; + } + + get center(): ConnectorAnchor { if (this.#hasMeasuredCenter && this.parent) { const parentTransform = this.parent.worldTransform; return { @@ -147,18 +410,12 @@ class ConnectorComponent extends ElementObject { y: parentTransform.y + this.#localCenter.y * parentTransform.scaleY, }; } - - return this.measureDomCenter(); + if (this.element) return this.measureDomCenter(); + return this.geometry.center; } - measureDomCenter(): { x: number; y: number } { - if (!this.element) { - const prop = this.getDomProperty("READ_1"); - return { - x: prop.x + prop.width / 2, - y: prop.y + prop.height / 2, - }; - } + measureDomCenter(): ConnectorAnchor { + if (!this.element) return this.geometry.center; const rect = this.element.getBoundingClientRect(); const screenX = rect.left + rect.width / 2; @@ -175,16 +432,13 @@ class ConnectorComponent extends ElementObject { return { x: worldX, y: worldY }; } - return { - x: screenX, - y: screenY, - }; + return { x: screenX, y: screenY }; } - measureLocalCenter(stage: "READ_1" | "READ_2" | "READ_3" | null = null) { - if (!this.element || !this.parent) { - return; - } + measureLocalCenter( + stage: "READ_1" | "READ_2" | "READ_3" | null = null, + ): void { + if (!this.element || !this.parent) return; const prop = this.readDom({ unapplyTransform: false }, stage); const parentTransform = this.parent.worldTransform; @@ -193,7 +447,8 @@ class ConnectorComponent extends ElementObject { const localLeft = (prop.x - parentTransform.x) / scaleX; const localTop = (prop.y - parentTransform.y) / scaleY; const localCenterX = (prop.x + prop.width / 2 - parentTransform.x) / scaleX; - const localCenterY = (prop.y + prop.height / 2 - parentTransform.y) / scaleY; + const localCenterY = + (prop.y + prop.height / 2 - parentTransform.y) / scaleY; this.localTransform = { x: localLeft, y: localTop }; this.#localCenter = { x: localCenterX, y: localCenterY }; @@ -202,69 +457,165 @@ class ConnectorComponent extends ElementObject { x: localCenterX - localLeft, y: localCenterY - localTop, }; - this.#mouseHitBox.localTransform = { - x: localCenterX - localLeft, - y: localCenterY - localTop, - }; } - get connectorCallback(): ConnectorCallback { - return this.#connectorCallback!; + requestDomGeometrySync(): boolean { + if (!this.element?.isConnected || !this.parent) return false; + + this.schedule( + () => { + if (!this.element?.isConnected || !this.parent) return; + this.measureLocalCenter("READ_1"); + }, + { + stage: "READ_1", + queueId: `${this.id}-dom-geometry`, + }, + ); + for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { + line.schedule( + () => { + line.moveLineToConnectorTransform(); + line.writeTransform(); + }, + { + stage: "WRITE_1", + queueId: `${line.id}-dom-geometry`, + }, + ); + } + return true; } onCursorDown(prop: pointerDownProp): void { - const currentIncomingLines = this.#incomingLines.filter( - (i) => !i.isDeleteRequested, - ); - // Skip if it's not a left click - if (prop.event.button != 0) { + if (prop.event.button !== 0) return; + const sourceHit = this.#resolveOwnSourceHit(prop.position, "source-start"); + this.armSurfaceGesture(prop, sourceHit); + } + + armSurfaceGesture( + prop: pointerDownProp, + sourceHit: ConnectorResolvedHit | null, + ): void { + if (prop.event.button !== 0) return; + const currentIncomingLines = this.#liveIncomingLines(); + if (this.#capabilities.reconnect && currentIncomingLines.length > 0) { + const line = currentIncomingLines[0]; + const source = line.start; + this.engine.input.setPointerDragOwner(prop.event.pointerId, source); + source.#arm(prop, { + sourceHit: null, + sourceStrategy: null, + reconnectLine: line, + }); return; } - if (currentIncomingLines.length > 0) { - this.startPickUpLine(currentIncomingLines[0], prop); - return; + + if (!this.#capabilities.source) return; + this.#arm(prop, { + sourceHit: sourceHit?.candidate.hit ?? null, + sourceStrategy: sourceHit?.strategy ?? this.#defaultAnchorStrategy(), + reconnectLine: null, + }); + } + + #arm( + prop: pointerDownProp, + options: Omit, + ): void { + if (this.#state !== ConnectorState.IDLE) return; + this.#state = ConnectorState.ARMED; + this.#armed = { + pointerId: prop.event.pointerId, + ...options, + }; + this.#cancelledPointers.delete(prop.event.pointerId); + this.engine.input.claimPointer(prop.event.pointerId); + this.#callbacks.onPointerDown?.({ + connector: this, + position: prop.position, + pointerId: prop.event.pointerId, + originalEvent: prop.event, + }); + } + + #onPointerUp(prop: pointerUpProp): void { + const pointerId = prop.event.pointerId; + if (this.#armed?.pointerId !== pointerId) return; + if (prop.event.type === "pointercancel") { + this.#cancelledPointers.add(pointerId); } - if (this.#config.allowDragOut) { - this.startDragOutLine(prop); + if (this.#state === ConnectorState.ARMED) { + this.#resetGesture(); } } - deleteLine(i: number): LineComponent | null { - if (this.#outgoingLines.length == 0 || i < 0) { - return null; + #onDragStart(prop: dragStartProp): void { + if ( + this.#state !== ConnectorState.ARMED || + this.#armed?.pointerId !== prop.pointerId + ) { + return; } - const line = this.#outgoingLines[i]; - if (!line) { - return null; + const armed = this.#armed; + let line = armed.reconnectLine; + if (line) { + this.#detachLineForReconnect(line); + line.clearTarget(); + } else { + line = this.createLine(); + line.setSourceSurfaceContext(armed.sourceStrategy, armed.sourceHit); + this.#outgoingLines.unshift(line); } + + this.#dragLine = line; + this.#state = ConnectorState.DRAGGING; + this.#setCandidate(null); + line.setPhase("source-start"); + line.setPreviewPosition(prop.start); + this.parent.updateNodeLineList(); + this.parent.scheduleLineWrites(); + this.#callbacks.onDragStart?.({ + connector: this, + position: prop.start, + pointerId: prop.pointerId, + }); + line.setPhase("preview-free"); + } + + deleteLine( + i: number, + reason: DisconnectReason = "programmatic", + ): LineComponent | null { + if (this.#outgoingLines.length === 0 || i < 0) return null; + const line = this.#outgoingLines[i]; + if (!line) return null; + const target = line.target; if (target) { target.#incomingLines = target.#incomingLines.filter( (incomingLine) => incomingLine !== line, ); - this.#connectorCallback?.onDisconnectOutgoing?.(target); - target.#connectorCallback?.onDisconnectIncoming?.(this); + this.#emitDisconnect(target, line, reason); } - line.destroy(); + line.destroy(false); this.#outgoingLines.splice(i, 1); - if (this.parent) { - this.parent.updateNodeLineList(); - } + this.parent?.updateNodeLineList(); return line; } - deleteAllLines() { + deleteAllLines(reason: DisconnectReason = "programmatic"): void { for (const line of [...this.#outgoingLines]) { - this.deleteLine(this.#outgoingLines.indexOf(line)); + this.deleteLine(this.#outgoingLines.indexOf(line), reason); } for (const line of [...this.#incomingLines]) { - line.start.deleteLine(line.start.outgoingLines.indexOf(line)); + line.start.deleteLine(line.start.outgoingLines.indexOf(line), reason); } this.#incomingLines = []; } - updateAllLines() { + scheduleAllLineWrites(): void { for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { line.schedule( () => { @@ -279,295 +630,754 @@ class ConnectorComponent extends ElementObject { } } - writeAllLines() { + writeAllLinesNow(): void { for (const line of [...this.#outgoingLines, ...this.#incomingLines]) { line.moveLineToConnectorTransform(); line.writeTransform(); } } - assignToNode(parent: NodeComponent) { + assignToNode(parent: NodeComponent): void { this.parent = parent; - let parent_ref = this.parent as NodeComponent; - parent_ref._prop[this.#name] = null; - this.#prop = parent_ref._prop; - parent_ref._connectors[this.#name] = this; + const parentRef = this.parent; + parentRef._prop[this.#name] = null; + this.#prop = parentRef._prop; + parentRef._connectors[this.#name] = this; this.#outgoingLines = []; this.#incomingLines = []; - if (parent_ref.global && this.global == null) { - this.global = parent_ref.global; + if (parentRef.global && this.global == null) { + this.global = parentRef.global; } } createLine(): LineComponent { - let line: LineComponent; - if (this.#config.lineClass) { - line = new this.#config.lineClass(this.engine, this); - } else { - line = new LineComponent(this.engine, this); - } + const line = this.#config.lineClass + ? new this.#config.lineClass(this.engine, this) + : new LineComponent(this.engine, this); + line.setSourceSurfaceContext(this.#defaultAnchorStrategy(), null); return line; } + /** @deprecated Pointer-down now only arms; retained for source compatibility. */ startDragOutLine(prop: pointerDownProp): void { - let newLine = this.createLine(); - newLine.setLineEnd(prop.position.x, prop.position.y); - newLine.setLineStartAtConnector(); - - this.#outgoingLines.unshift(newLine); - - this.parent.updateNodeLines(); - this.parent.updateNodeLineList(); - - this.#state = ConnectorState.DRAGGING; - this.#targetConnector = null; - // this.event.input.drag = null; - this.event.input.drag = this.runDragOutLine; - // this.globalInput.pointerUp = this.endDragOutLine; - this.event.input.dragEnd = this.endDragOutLine; - - this.#mouseHitBox.event.collider.onCollide = ( - _: Collider, - __: Collider, - ) => { - // console.log("onCollide", this.id); - this.findClosestConnector(); - }; - this.#mouseHitBox.event.collider.onEndContact = ( - _: Collider, - otherObject: Collider, - ) => { - if (this.#targetConnector?.id == otherObject.parent.id) { - this.#targetConnector = null; - } - }; + this.armSurfaceGesture( + prop, + this.#resolveOwnSourceHit(prop.position, "source-start"), + ); + } - this.runDragOutLine({ - position: prop.position, - start: { - x: this.worldTransform.x, - y: this.worldTransform.y, - }, - delta: { - x: prop.position.x - this.worldTransform.x, - y: prop.position.y - this.worldTransform.y, - }, - } as dragProp); - } - - findClosestConnector() { - let connectorCollider: Array = Array.from( - this.#mouseHitBox.currentCollisions, - ).filter((c) => c.parent instanceof ConnectorComponent); - let connectors: Array = connectorCollider - .map((c) => c.parent as ConnectorComponent) - .sort((a, b) => { - const centerA = a.center; - const centerB = b.center; - const mouseWorld = this.#mouseHitBox.worldTransform; - const [mouseX, mouseY] = [mouseWorld.x, mouseWorld.y]; - let da = Math.sqrt( - Math.pow(centerA.x - mouseX, 2) + Math.pow(centerA.y - mouseY, 2), - ); - let db = Math.sqrt( - Math.pow(centerB.x - mouseX, 2) + Math.pow(centerB.y - mouseY, 2), - ); - return da - db; - }); - if (connectors.length > 0) { - this.#targetConnector = connectors[0]; - } else { - this.#targetConnector = null; + findClosestConnector(): void { + if (!this.#dragLine) { + this.#setCandidate(null); + return; } + const position = { + ...this.#dragLine.endAnchor, + cameraX: this.#dragLine.endAnchor.x, + cameraY: this.#dragLine.endAnchor.y, + screenX: this.#dragLine.endAnchor.x, + screenY: this.#dragLine.endAnchor.y, + }; + this.#setCandidate(this.#resolveTargetAtPoint(position, "preview-target")); } findClosestConnectorAtPoint( - position: { x: number; y: number }, + position: ConnectorPoint, ): ConnectorComponent | null { - const objectTable = this.global.getEngineObjectTable(this.engine); - const connectors = Object.values(objectTable) - .filter((object): object is ConnectorComponent => { - return object instanceof ConnectorComponent && object.id !== this.id; - }) - .filter((connector) => this.canConnectToConnector(connector)); - - let closestConnector: ConnectorComponent | null = null; - let closestDistance = Number.POSITIVE_INFINITY; - for (const connector of connectors) { - const center = connector.center; - const distance = Math.hypot(center.x - position.x, center.y - position.y); - const hitRadius = connector.config.colliderRadius ?? 30; - if (distance <= hitRadius && distance < closestDistance) { - closestConnector = connector; - closestDistance = distance; - } - } - return closestConnector; + return this.findCandidateAtPoint(position)?.connector ?? null; + } + + findCandidateAtPoint( + position: ConnectorPoint, + phase: "preview-target" | "drop" = "preview-target", + ): ConnectorCandidate | null { + return ( + this.#resolveTargetAtPoint(asEventPosition(position), phase)?.candidate ?? + null + ); + } + + resolveSourceHit(position: eventPosition): ConnectorResolvedHit | null { + return this.#resolveOwnSourceHit(position, "source-start"); } canConnectToConnector(connector: ConnectorComponent): boolean { - if (connector.id === this.id || connector.config.allowDragOut) { + if ( + connector.id === this.id || + !this.#capabilities.source || + !connector.#capabilities.target || + connector.#capabilities.maxIncoming === 0 + ) { return false; } - const currentIncomingLines = connector.incomingLines.filter( - (i) => !i.isDeleteRequested, - ); - if (currentIncomingLines.some((i) => i.start == this)) { + const hasParallel = connector + .#liveIncomingLines() + .some((line) => line.start === this); + if ( + hasParallel && + !( + this.#capabilities.allowParallel && + connector.#capabilities.allowParallel + ) + ) { return false; } - const maxConnectors = connector.config.maxConnectors ?? 1; - return maxConnectors < 0 || currentIncomingLines.length < maxConnectors; + const event = { source: this, target: connector }; + return ( + this.#callbacks.canConnect?.(event) !== false && + connector.#callbacks.canConnect?.(event) !== false + ); } - runDragOutLine(prop: dragProp) { - if (this.#state != ConnectorState.DRAGGING) { + runDragOutLine(prop: dragProp): void { + if ( + this.#state !== ConnectorState.DRAGGING || + this.#armed?.pointerId !== prop.pointerId + ) { return; } - if (this.#outgoingLines.length == 0) { - console.error(`Error: Outgoing lines is empty`); - return; + if (this.#config.edgePan !== false) { + const controller = this.engine.edgePanController; + if (this.#edgePanPointerId == null) { + this.#edgePanPointerId = prop.pointerId; + controller?.startEdgePan(prop.pointerId, prop.position, (position) => + this.#moveDraggedLine(position), + ); + } else { + controller?.updateEdgePan(prop.pointerId, prop.position); + } } - this.#mouseHitBox.worldTransform = { - x: prop.position.x, - y: prop.position.y, - }; - this.#targetConnector = this.findClosestConnectorAtPoint(prop.position); - let line = this.#outgoingLines[0]; + this.#moveDraggedLine(prop.position); + } - if (this.#targetConnector) { - const result = this.hoverWhileDragging(this.#targetConnector); - if (result) { - line.setLineEnd(result[0], result[1]); - line.setLineStartAtConnector(); - line.schedule(() => line.writeTransform(), { - stage: "WRITE_2", - queueId: `${line.id}-transform`, - }); - return; - } - } - line.setLineEnd(prop.position.x, prop.position.y); - line.setLineStartAtConnector(); - this.parent.updateNodeLines(); + #moveDraggedLine(position: eventPosition): void { + if (this.#state !== ConnectorState.DRAGGING || !this.#dragLine) return; + + const candidate = this.#resolveTargetAtPoint(position, "preview-target"); + this.#setCandidate(candidate); + this.#dragLine.setPhase(candidate ? "preview-target" : "preview-free"); + this.#dragLine.setPreviewPosition(position); + this.#dragLine.schedule(() => this.#dragLine?.writeTransform(), { + stage: "WRITE_2", + queueId: `${this.#dragLine.id}-transform`, + }); } hoverWhileDragging( targetConnector: ConnectorComponent, ): [number, number] | void { - if (!(targetConnector instanceof ConnectorComponent)) { + if (!(targetConnector instanceof ConnectorComponent) || !this.#dragLine) { return; } - if (targetConnector == null) { + const anchor = targetConnector.resolveAnchor({ + line: this.#dragLine, + role: "target", + phase: "preview-target", + peer: this, + position: this.geometry.center, + hit: this.#candidate?.candidate.hit ?? null, + strategy: this.#candidate?.strategy ?? null, + }); + return [anchor.x, anchor.y]; + } + + endDragOutLine(prop: dragEndProp): void { + if ( + this.#state !== ConnectorState.DRAGGING || + this.#armed?.pointerId !== prop.pointerId + ) { return; } - if (targetConnector.id == this.id) { + const line = this.#dragLine; + if (!line) { + this.#resetGesture(); return; } - const connectorCenter = targetConnector.center; - return [connectorCenter.x, connectorCenter.y]; - } + if (this.#cancelledPointers.has(prop.pointerId)) { + this.#discardDraggedLine(line, prop, false); + return; + } - endDragOutLine(prop: dragEndProp) { - this.#targetConnector = this.findClosestConnectorAtPoint(prop.end); - if ( - this.#targetConnector && - this.#targetConnector instanceof ConnectorComponent - ) { - const target = this.#targetConnector; - if (target == null) { - console.error(`Error: target is null`); - this._endLineDragCleanup(); - return; + const candidate = this.#resolveTargetAtPoint(prop.end, "drop"); + this.#setCandidate(candidate); + line.setPhase("drop"); + line.setPreviewPosition(prop.end); + + let connected = false; + if (candidate) { + let request: ConnectorConnectionRequestResult; + try { + request = this.#callbacks.onConnectionRequest?.({ + source: this, + target: candidate.candidate.connector, + line, + candidate: candidate.candidate, + position: prop.end, + }); + } catch (error) { + this.#discardDraggedLine(line, prop, false); + throw error; } - if (this.connectToConnector(target, this.#outgoingLines[0]) == false) { - this._endLineDragCleanup(); - this.deleteLine(0); - return; + if (request !== false) { + const connectionOptions: Parameters< + ConnectorComponent["connectToConnector"] + >[0] = { + target: candidate.candidate.connector, + line, + origin: "gesture", + candidate, + }; + if ( + request && + typeof request === "object" && + Object.prototype.hasOwnProperty.call(request, "payload") + ) { + connectionOptions.payload = request.payload; + } + connected = this.connectToConnector(connectionOptions); } - - target.#prop[target.#name] = this.#prop[this.#name]; - - this.#outgoingLines[0].setLineEndAtConnector(); - } else { - this.deleteLine(0); } - if (this.parent) { - this.parent.updateNodeLines(); + + if (!connected) { + this.#discardDraggedLine(line, prop, false); + return; } - this._endLineDragCleanup(); + candidate!.candidate.connector.#prop[candidate!.candidate.connector.#name] = + this.#prop[this.#name]; + this.parent.scheduleLineWrites(); + this.#callbacks.onDragEnd?.({ + connector: this, + position: prop.end, + pointerId: prop.pointerId, + connected: true, + }); + this.#resetGesture(); } - _endLineDragCleanup() { - this.#state = ConnectorState.IDLE; - this.event.input.drag = null; - this.event.input.dragEnd = null; - this.parent.updateNodeLineList(); - this.#targetConnector = null; - this.#mouseHitBox.event.collider.onCollide = null; - this.#mouseHitBox.event.collider.onEndContact = null; - this.#mouseHitBox.localTransform = { x: 0, y: 0 }; - } - - startPickUpLine(line: LineComponent, prop: pointerDownProp) { - const startConnector = line.start; - startConnector.disconnectFromConnector(this); - this.engine?.input.setPointerDragOwner(prop.event.pointerId, startConnector); - startConnector.targetConnector = this; - startConnector.startDragOutLine(prop); - this.#state = ConnectorState.DRAGGING; + _endLineDragCleanup(): void { + this.#resetGesture(); } - connectToConnector( - connector: ConnectorComponent, - line: LineComponent | null, - ): boolean { - if (!this.canConnectToConnector(connector)) { - return false; + startPickUpLine(line: LineComponent, prop: pointerDownProp): void { + this.engine.input.setPointerDragOwner(prop.event.pointerId, line.start); + line.start.#arm(prop, { + sourceHit: null, + sourceStrategy: null, + reconnectLine: line, + }); + } + + connectToConnector(options: { + target: ConnectorComponent; + line?: LineComponent | null; + origin?: ConnectionOrigin; + payload?: unknown; + candidate?: ConnectorResolvedHit | null; + }): boolean { + const { + target, + line: requestedLine = null, + origin = "programmatic", + candidate = null, + } = options; + let line = requestedLine; + const hasPayload = Object.prototype.hasOwnProperty.call(options, "payload"); + if (line && line.start !== this) return false; + + const alreadyConnected = + line?.target === target && + this.#outgoingLines.includes(line) && + target.#incomingLines.includes(line); + if (alreadyConnected && line) { + if (hasPayload) line.setPayload(options.payload); + line.connectTarget( + target, + candidate?.candidate ?? null, + candidate?.strategy ?? target.#defaultAnchorStrategy(), + ); + line.writeTransform(); + return true; + } + + if (!this.canConnectToConnector(target)) return false; + + const maxIncoming = target.#capabilities.maxIncoming; + if (maxIncoming > 0) { + const currentIncomingLines = target.#liveIncomingLines(); + const removeCount = Math.max( + 0, + currentIncomingLines.length - maxIncoming + 1, + ); + for (const incomingLine of currentIncomingLines.slice(0, removeCount)) { + incomingLine.start.deleteLine( + incomingLine.start.outgoingLines.indexOf(incomingLine), + "replacement", + ); + } } if (line == null) { line = this.createLine(); this.#outgoingLines.unshift(line); + } else if (!this.#outgoingLines.includes(line)) { + this.#outgoingLines.unshift(line); } - line.target = connector; - if (!connector.incomingLines.includes(line)) { - connector.incomingLines.push(line); + const previousTarget = line.target; + if (previousTarget) { + previousTarget.#incomingLines = previousTarget.#incomingLines.filter( + (incomingLine) => incomingLine !== line, + ); + line.clearTarget(); + this.#emitDisconnect(previousTarget, line, "programmatic"); } - line.setLineStartAtConnector(); - line.setLineEndAtConnector(); - this.parent.updateNodeLineList(); + // Payload and anchors are authoritative before render/connect callbacks. + if (hasPayload) line.setPayload(options.payload); + line.connectTarget( + target, + candidate?.candidate ?? null, + candidate?.strategy ?? target.#defaultAnchorStrategy(), + ); + if (!target.#incomingLines.includes(line)) { + target.#incomingLines.push(line); + } + line.writeTransform(); - this.#connectorCallback?.onConnectOutgoing?.(connector); - connector.#connectorCallback?.onConnectIncoming?.(this); + this.parent.updateNodeLineList(); + this.#emitConnect(target, line, origin); this.parent.setProp(this.#name, this.#prop[this.#name]); - return true; } - disconnectFromConnector(connector: ConnectorComponent) { + disconnectFromConnector( + connector: ConnectorComponent, + reason: DisconnectReason = "programmatic", + ): void { const lineIndex = this.#outgoingLines.findIndex( - (line) => line.target == connector, + (line) => line.target === connector, ); - if (lineIndex !== -1) { - this.deleteLine(lineIndex); + if (lineIndex !== -1) this.deleteLine(lineIndex, reason); + } + + resolveAnchor({ + line, + role, + phase, + peer, + position, + hit, + strategy, + }: { + line: LineComponent; + role: ConnectorRole; + phase: ConnectorLinePhase; + peer: ConnectorComponent | null; + position: ConnectorPoint; + hit: ConnectorHit | null; + strategy: ConnectorSurfaceStrategy | null; + }): ConnectorAnchor { + const currentStrategy = + strategy && this.surfaceStrategies.includes(strategy) + ? strategy + : this.#defaultAnchorStrategy(); + const resolved = currentStrategy?.resolveAnchor?.({ + connector: this, + peer, + line, + role, + phase, + position, + geometry: this.geometry, + peerGeometry: peer?.geometry ?? null, + hit, + }); + if (resolved && isFinitePoint(resolved)) return cloneAnchor(resolved); + if ( + hit && + (currentStrategy != null || phase !== "connected") && + isFinitePoint(hit.anchor) + ) { + return cloneAnchor(hit.anchor); } + return cloneAnchor(this.center); } - destroy() { - this.deleteAllLines(); + destroy(): void { + if (this.#edgePanPointerId != null) { + this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); + this.#edgePanPointerId = null; + } + this.#resetGesture(); + this.deleteAllLines("teardown"); + getNodeManager(this.engine).unregisterConnector(this); if (this.parent?._connectors[this.#name] === this) { delete this.parent._connectors[this.#name]; } + this.#removeSourceSurfaceRegistration(); + this.globalInput.pointerUp = null; super.destroy(); } + + #resolveOwnSourceHit( + position: eventPosition, + phase: ConnectorLinePhase, + ): ConnectorResolvedHit | null { + const hits: ConnectorResolvedHit[] = []; + for (const [strategyIndex, strategy] of this.surfaceStrategies.entries()) { + const hit = normalizeHit( + strategy.sourceHitTest?.({ + connector: this, + position, + geometry: this.geometry, + phase, + }), + ); + if (hit) { + hits.push({ + candidate: { connector: this, hit }, + strategy, + strategyIndex, + }); + } + } + return pickResolvedHit(hits); + } + + #resolveTargetAtPoint( + position: eventPosition, + phase: "preview-target" | "drop", + ): ConnectorResolvedHit | null { + const hits: ConnectorResolvedHit[] = []; + for (const connector of registeredConnectors(this.engine)) { + if ( + connector.engine !== this.engine || + !this.canConnectToConnector(connector) + ) { + continue; + } + + for (const [ + strategyIndex, + strategy, + ] of connector.surfaceStrategies.entries()) { + const hit = normalizeHit( + strategy.targetHitTest?.({ + connector, + position, + geometry: connector.geometry, + phase, + }), + ); + if (hit) { + hits.push({ + candidate: { connector, hit }, + strategy, + strategyIndex, + }); + } + } + + if (connector.#hasOrdinaryPortGeometry()) { + const center = connector.center; + const distance = Math.hypot( + center.x - position.x, + center.y - position.y, + ); + if (distance <= (connector.#config.colliderRadius ?? 30)) { + hits.push({ + candidate: { + connector, + hit: { anchor: center, distance }, + }, + strategy: connector.#defaultAnchorStrategy(), + strategyIndex: Number.MAX_SAFE_INTEGER, + }); + } + } + } + return pickResolvedHit(hits); + } + + #setCandidate(candidate: ConnectorResolvedHit | null): void { + const previous = this.#candidate; + if ( + previous?.candidate.connector === candidate?.candidate.connector && + previous?.strategy === candidate?.strategy && + previous?.candidate.hit.anchor.x === candidate?.candidate.hit.anchor.x && + previous?.candidate.hit.anchor.y === candidate?.candidate.hit.anchor.y && + previous?.candidate.hit.payload === candidate?.candidate.hit.payload && + previous?.candidate.hit.distance === candidate?.candidate.hit.distance && + previous?.candidate.hit.priority === candidate?.candidate.hit.priority + ) { + return; + } + this.#candidate = candidate; + this.#targetConnector = candidate?.candidate.connector ?? null; + this.#dragLine?.setCandidate( + candidate?.candidate ?? null, + candidate?.strategy ?? null, + ); + this.#callbacks.onCandidateChange?.({ + source: this, + candidate: candidate?.candidate.connector ?? null, + resolvedCandidate: candidate?.candidate ?? null, + line: this.#dragLine, + }); + } + + #detachLineForReconnect(line: LineComponent): void { + const target = line.target; + if (!target) return; + target.#incomingLines = target.#incomingLines.filter( + (incomingLine) => incomingLine !== line, + ); + line.target = null; + this.#emitDisconnect(target, line, "gesture"); + } + + #discardDraggedLine( + line: LineComponent, + prop: dragEndProp, + connected: boolean, + ): void { + const index = this.#outgoingLines.indexOf(line); + if (index !== -1) this.deleteLine(index, "gesture"); + this.#callbacks.onDragEnd?.({ + connector: this, + position: prop.end, + pointerId: prop.pointerId, + connected, + }); + this.#resetGesture(); + } + + #resetGesture(): void { + if (this.#edgePanPointerId != null) { + this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); + this.#edgePanPointerId = null; + } + if (this.#armed) { + this.#cancelledPointers.delete(this.#armed.pointerId); + } + this.#state = ConnectorState.IDLE; + this.#armed = null; + this.#dragLine = null; + this.#setCandidate(null); + } + + #defaultAnchorStrategy(): ConnectorSurfaceStrategy | null { + return ( + this.surfaceStrategies.find( + (strategy) => strategy.resolveAnchor != null, + ) ?? null + ); + } + + #syncSourceSurfaceRegistration(): void { + const sourceSurfaces = getSourceSurfaces(this.global); + const index = sourceSurfaces.indexOf(this); + const shouldRegister = + this.#capabilities.source && + this.surfaceStrategies.some((strategy) => strategy.sourceHitTest); + if (shouldRegister && index === -1) { + sourceSurfaces.push(this); + } else if (!shouldRegister && index !== -1) { + sourceSurfaces.splice(index, 1); + } + } + + #removeSourceSurfaceRegistration(): void { + const sourceSurfaces = getSourceSurfaces(this.global); + const index = sourceSurfaces.indexOf(this); + if (index !== -1) sourceSurfaces.splice(index, 1); + } + + #hasOrdinaryPortGeometry(): boolean { + return this.element != null || this.#hasMeasuredCenter; + } + + #liveIncomingLines(): LineComponent[] { + return this.#incomingLines.filter((line) => !line.isDeleteRequested); + } + + #emitConnect( + target: ConnectorComponent, + line: LineComponent, + origin: ConnectionOrigin, + ): void { + this.#callbacks.onConnect?.({ + source: this, + target, + connector: this, + peer: target, + line, + role: "source", + origin, + }); + target.#callbacks.onConnect?.({ + source: this, + target, + connector: target, + peer: this, + line, + role: "target", + origin, + }); + getNodeManager(this.engine).edgeSync?.notifyConnect({ + source: this, + target, + connector: this, + peer: target, + line, + role: "source", + origin, + }); + } + + #emitDisconnect( + target: ConnectorComponent, + line: LineComponent, + reason: DisconnectReason, + ): void { + this.#callbacks.onDisconnect?.({ + source: this, + target, + connector: this, + peer: target, + line, + role: "source", + reason, + }); + target.#callbacks.onDisconnect?.({ + source: this, + target, + connector: target, + peer: this, + line, + role: "target", + reason, + }); + getNodeManager(this.engine).edgeSync?.notifyDisconnect({ + source: this, + target, + connector: this, + peer: target, + line, + role: "source", + reason, + }); + } +} + +function resolveCapabilities(config: ConnectorConfig): ConnectorCapabilities { + const legacyMaxIncoming = config.maxConnectors ?? 1; + const legacySource = config.allowDragOut ?? false; + return { + source: config.capabilities?.source ?? legacySource, + target: + config.capabilities?.target ?? (!legacySource && legacyMaxIncoming !== 0), + maxIncoming: config.capabilities?.maxIncoming ?? legacyMaxIncoming, + reconnect: config.capabilities?.reconnect ?? true, + allowParallel: config.capabilities?.allowParallel ?? false, + }; +} + +function normalizeHit( + hit: ConnectorHit | null | false | void, +): ConnectorHit | null { + if (!hit || !isFinitePoint(hit.anchor) || !Number.isFinite(hit.distance)) { + return null; + } + return { + ...hit, + anchor: cloneAnchor(hit.anchor), + }; +} + +function isFinitePoint(point: ConnectorPoint): boolean { + return Number.isFinite(point.x) && Number.isFinite(point.y); +} + +function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { + return { + x: anchor.x, + y: anchor.y, + ...(anchor.normal + ? { normal: { x: anchor.normal.x, y: anchor.normal.y } } + : {}), + }; +} + +function asEventPosition(position: ConnectorPoint): eventPosition { + const value = position as Partial; + return { + x: position.x, + y: position.y, + cameraX: value.cameraX ?? position.x, + cameraY: value.cameraY ?? position.y, + screenX: value.screenX ?? position.x, + screenY: value.screenY ?? position.y, + }; +} + +function compareResolvedHits( + a: ConnectorResolvedHit, + b: ConnectorResolvedHit, +): number { + const priority = + (b.candidate.hit.priority ?? 0) - (a.candidate.hit.priority ?? 0); + if (priority !== 0) return priority; + const distance = a.candidate.hit.distance - b.candidate.hit.distance; + if (distance !== 0) return distance; + const connectorOrder = a.candidate.connector.id.localeCompare( + b.candidate.connector.id, + ); + if (connectorOrder !== 0) return connectorOrder; + return a.strategyIndex - b.strategyIndex; +} + +function pickResolvedHit( + hits: ConnectorResolvedHit[], +): ConnectorResolvedHit | null { + hits.sort(compareResolvedHits); + return hits[0] ?? null; +} + +export function resolveConnectorSourceAtPoint( + engine: any, + position: eventPosition, + node?: NodeComponent, +): ConnectorResolvedHit | null { + const hits: ConnectorResolvedHit[] = []; + for (const surface of getSourceSurfaces(engine.global)) { + const connector = surface as ConnectorComponent; + if ( + connector.engine !== engine || + (node && connector.parent !== node) || + !connector.capabilities.source + ) { + continue; + } + const resolved = connector.resolveSourceHit(position); + if (resolved) hits.push(resolved); + } + return pickResolvedHit(hits); +} + +function registeredConnectors(engine: any): ConnectorComponent[] { + const objectTable = engine.global?.getEngineObjectTable?.(engine); + if (!objectTable) return []; + return Object.values(objectTable).filter( + (object): object is ConnectorComponent => + object instanceof ConnectorComponent && !object.isDeleteRequested, + ); } export { ConnectorComponent }; diff --git a/assets/snapline/core/src/edge-sync.ts b/assets/snapline/core/src/edge-sync.ts new file mode 100644 index 0000000..d517162 --- /dev/null +++ b/assets/snapline/core/src/edge-sync.ts @@ -0,0 +1,224 @@ +import type { + ConnectorComponent, + ConnectorConnectionEvent, + ConnectorDisconnectionEvent, +} from "./connector"; +import type { LineComponent } from "./line"; +import { getNodeManager } from "./snapline-globals"; + +export interface EdgeEndpoint { + node: string; + port: string; +} + +export interface EdgeLike { + from: EdgeEndpoint; + to: EdgeEndpoint; +} + +export interface EdgeConnectIntentEvent { + from: EdgeEndpoint; + to: EdgeEndpoint; + source: ConnectorComponent; + target: ConnectorComponent; + line: LineComponent; + origin: "gesture"; +} + +export interface EdgeDisconnectIntentEvent { + from: EdgeEndpoint; + to: EdgeEndpoint; + source: ConnectorComponent; + target: ConnectorComponent; + line: LineComponent; + reason: "gesture" | "replacement"; +} + +export interface EdgeSyncCallbacks { + onEdgeConnect?: (event: EdgeConnectIntentEvent) => void; + onEdgeDisconnect?: (event: EdgeDisconnectIntentEvent) => void; +} + +type EdgeSyncEngine = { global: { data: any } | null }; + +export interface EdgeSyncConfig { + engine: EdgeSyncEngine; + // Maps a connector to its semantic endpoint, or null for connectors that + // are not part of the consumer's edge document (their lines are left + // untouched by sync and never produce intents). + identity: (connector: ConnectorComponent) => EdgeEndpoint | null; + // The consumer's current edge list — the single source of truth. Consulted + // fresh on every sync; the controller never stores edges. + getEdges: () => readonly EdgeLike[]; + callbacks?: EdgeSyncCallbacks; +} + +// Control-character separator: consumer node/port ids are free to contain +// ':' or other printable punctuation. +const KEY_SEPARATOR = ""; + +function endpointKey(endpoint: EdgeEndpoint): string { + return endpoint.node + KEY_SEPARATOR + endpoint.port; +} + +function edgeKey(from: EdgeEndpoint, to: EdgeEndpoint): string { + return endpointKey(from) + KEY_SEPARATOR + endpointKey(to); +} + +// Controlled-edges controller: the consumer owns the edge document, SnapLine +// owns keeping rendered lines reconciled to it and translating gestures into +// semantic intents. +// +// Contract: gesture connects and gesture/replacement disconnects forward as +// intents; programmatic, hydration, and teardown changes never do. Intents +// fire synchronously inside the drop dispatch — a consumer that writes its +// document synchronously in the intent (and whose adapter reconciles in the +// same task) keeps the accept AND reject paths paint-atomic. `sync()` is +// idempotent, skips in-flight drag lines, and never forwards intents for its +// own mutations. +export class EdgeSyncController { + #config: EdgeSyncConfig; + #syncing = false; + #syncQueued = false; + #disposed = false; + + constructor(config: EdgeSyncConfig) { + this.#config = config; + const manager = getNodeManager(config.engine); + if (manager.edgeSync && manager.edgeSync !== this) { + console.warn( + "SnapLine: replacing an existing EdgeSyncController for this engine.", + ); + } + manager.edgeSync = this; + } + + get callbacks(): EdgeSyncCallbacks { + return this.#config.callbacks ?? {}; + } + + dispose(): void { + this.#disposed = true; + const manager = getNodeManager(this.#config.engine); + if (manager.edgeSync === this) manager.edgeSync = null; + } + + // @internal Called by NodeManager when a connector registers after this + // controller exists (a node mounted). Coalesced into one microtask so a + // mounting batch reconciles once, before the frame paints. + connectorRegistered(): void { + if (this.#syncQueued) return; + this.#syncQueued = true; + queueMicrotask(() => { + this.#syncQueued = false; + if (!this.#disposed) this.sync(); + }); + } + + // Reconcile rendered lines to the consumer's edge list. Safe to call at any + // time: in-flight drag lines (no target yet) and foreign lines (either + // endpoint without identity) are left alone, and mutations made here never + // forward as intents. + sync(): void { + if (this.#syncing) return; + this.#syncing = true; + try { + const manager = getNodeManager(this.#config.engine); + const identity = this.#config.identity; + const connectors = manager.connectors; + + const identities = new Map(); + const byKey = new Map(); + for (const connector of connectors) { + const endpoint = identity(connector); + identities.set(connector, endpoint); + if (endpoint) byKey.set(endpointKey(endpoint), connector); + } + + const edges = this.#config.getEdges(); + const edgeKeys = new Set( + edges.map((edge) => edgeKey(edge.from, edge.to)), + ); + + for (const connector of connectors) { + const fromEndpoint = identities.get(connector); + if (!fromEndpoint) continue; + for (const line of [...connector.outgoingLines]) { + if (line.isDeleteRequested) continue; + const target = line.target; + if (!target) continue; // in-flight drag line + const toEndpoint = identities.get(target) ?? identity(target); + if (!toEndpoint) continue; // foreign line — not ours to manage + if (!edgeKeys.has(edgeKey(fromEndpoint, toEndpoint))) { + const index = connector.outgoingLines.indexOf(line); + if (index !== -1) connector.deleteLine(index, "programmatic"); + } + } + } + + for (const edge of edges) { + const from = byKey.get(endpointKey(edge.from)); + const to = byKey.get(endpointKey(edge.to)); + if (!from || !to) continue; // endpoint not mounted yet; next sync + const exists = from.outgoingLines.some( + (line) => !line.isDeleteRequested && line.target === to, + ); + if (exists) continue; + // Document-driven restoration. A false return means a canConnect + // predicate rejected the edge; the consumer owns document validity, + // so leave the document alone. + from.connectToConnector({ target: to, origin: "hydration" }); + } + } finally { + this.#syncing = false; + } + } + + // @internal Called from ConnectorComponent's emit sites. + notifyConnect(event: ConnectorConnectionEvent): void { + if (this.#syncing || event.origin !== "gesture") return; + const endpoints = this.#endpoints(event.source, event.target); + if (!endpoints) return; + this.callbacks.onEdgeConnect?.({ + ...endpoints, + line: event.line, + origin: "gesture", + }); + } + + // @internal Called from ConnectorComponent's emit sites. + notifyDisconnect(event: ConnectorDisconnectionEvent): void { + if (this.#syncing) { + if (event.reason === "replacement") { + console.warn( + "SnapLine EdgeSync: sync evicted a live line via replacement — " + + "the edge document exceeds a connector's incoming capacity.", + ); + } + return; + } + if (event.reason !== "gesture" && event.reason !== "replacement") return; + const endpoints = this.#endpoints(event.source, event.target); + if (!endpoints) return; + this.callbacks.onEdgeDisconnect?.({ + ...endpoints, + line: event.line, + reason: event.reason, + }); + } + + #endpoints( + source: ConnectorComponent, + target: ConnectorComponent, + ): { + from: EdgeEndpoint; + to: EdgeEndpoint; + source: ConnectorComponent; + target: ConnectorComponent; + } | null { + // ConnectorPairEvent's source/target are already in wire direction. + const from = this.#config.identity(source); + const to = this.#config.identity(target); + return from && to ? { from, to, source, target } : null; + } +} diff --git a/assets/snapline/core/src/group.ts b/assets/snapline/core/src/group.ts new file mode 100644 index 0000000..40b5573 --- /dev/null +++ b/assets/snapline/core/src/group.ts @@ -0,0 +1,413 @@ +import type { + BaseObject, + Engine, + eventPosition, +} from "@snap-engine/core"; +import { NodeComponent, mergeConfig, type NodeConfig } from "./node"; +import { getGroups, snapData } from "./snapline-globals"; + +export interface GroupConfig extends NodeConfig { + width?: number; + height?: number; + /** Additional eligibility filter applied after geometric containment. */ + canContain?: (event: GroupContainEvent) => boolean; + groupCallbacks?: GroupCallbacks; +} + +export interface GroupContainEvent { + group: GroupNodeComponent; + node: NodeComponent; + centerContained: boolean; + boundsContained: boolean; +} + +export interface GroupMembershipEvent { + group: GroupNodeComponent; + added: readonly NodeComponent[]; + removed: readonly NodeComponent[]; + /** Direct members only. Use `group.descendants` for the complete subtree. */ + members: readonly NodeComponent[]; +} + +export interface GroupCallbacks { + onMembershipChange?: (event: GroupMembershipEvent) => void; +} + +export interface GroupMembershipResolutionEvent { + node: NodeComponent; + /** Safe eligible candidates, ordered from innermost to outermost. */ + candidates: readonly GroupNodeComponent[]; + defaultParent: GroupNodeComponent | null; +} + +export type GroupMembershipResolver = ( + event: GroupMembershipResolutionEvent, +) => GroupNodeComponent | null; + +const DEFAULT_GROUP_CONFIG = { + width: 400, + height: 300, + minWidth: 160, + minHeight: 120, +} satisfies GroupConfig; + +const parentGroups = new WeakMap(); +const membershipResolvers = new WeakMap(); +const reconcilingEngines = new WeakSet(); + +type Bounds = ReturnType< + NodeComponent["hitBox"]["getWorldBoundsSnapshot"] +>; + +function boundsArea(bounds: Bounds): number { + return Math.max(0, bounds.right - bounds.left) * + Math.max(0, bounds.bottom - bounds.top); +} + +function containsBounds(container: Bounds, child: Bounds): boolean { + return ( + child.left >= container.left && + child.right <= container.right && + child.top >= container.top && + child.bottom <= container.bottom + ); +} + +function stableGroupOrder( + left: GroupNodeComponent, + right: GroupNodeComponent, +): number { + const areaDelta = + boundsArea(left.hitBox.getWorldBoundsSnapshot()) - + boundsArea(right.hitBox.getWorldBoundsSnapshot()); + return areaDelta || String(left.id).localeCompare(String(right.id)); +} + +function groupsForEngine(group: GroupNodeComponent): GroupNodeComponent[] { + return getGroups(group.global).filter( + (candidate): candidate is GroupNodeComponent => + candidate instanceof GroupNodeComponent && + candidate.engine === group.engine, + ); +} + +function nodesForEngine(group: GroupNodeComponent): NodeComponent[] { + const table = group.global.getEngineObjectTable(group.engine); + return Object.values(table).filter( + (object): object is NodeComponent => object instanceof NodeComponent, + ); +} + +function resolveParent( + node: NodeComponent, + candidates: GroupNodeComponent[], + engine: object, +): GroupNodeComponent | null { + candidates.sort(stableGroupOrder); + const defaultParent = candidates[0] ?? null; + const resolver = membershipResolvers.get(engine); + if (!resolver) return defaultParent; + + const resolved = resolver({ node, candidates, defaultParent }); + if (resolved === null || candidates.includes(resolved)) return resolved; + + console.warn( + "SnapLine group membership resolver returned a group outside its eligible candidates; using the default parent.", + { node, resolved, candidates }, + ); + return defaultParent; +} + +function wouldCreateGroupCycle( + node: GroupNodeComponent, + parent: GroupNodeComponent, + nextParents: Map, +): boolean { + let ancestor: GroupNodeComponent | undefined = parent; + const visited = new Set(); + while (ancestor && !visited.has(ancestor)) { + if (ancestor === node) return true; + visited.add(ancestor); + ancestor = nextParents.get(ancestor); + } + return false; +} + +function reconcileMembership( + source: GroupNodeComponent, + fireDelta: boolean, +): void { + const engine = source.engine as object; + if (reconcilingEngines.has(engine)) return; + reconcilingEngines.add(engine); + + try { + const groups = groupsForEngine(source); + const nextMembers = new Map< + GroupNodeComponent, + Set + >(groups.map((group) => [group, new Set()])); + const nextParents = new Map(); + + const nodes = nodesForEngine(source); + const groupNodes = [...groups].sort(stableGroupOrder); + const ordinaryNodes = nodes.filter( + (node) => !(node instanceof GroupNodeComponent), + ); + + // Resolve the group forest first. A proposed edge can point at a group that + // has already chosen another parent, so walking the partial parent map is + // enough to reject the edge that would close any cycle. + for (const node of groupNodes) { + const candidates = groups.filter( + (group) => + group !== node && + group.allowsMembership(node) && + !wouldCreateGroupCycle(node, group, nextParents), + ); + const parent = resolveParent(node, candidates, engine); + if (!parent) continue; + nextMembers.get(parent)?.add(node); + nextParents.set(node, parent); + } + + // Ordinary nodes cannot form membership cycles. They choose the innermost + // eligible group after the group hierarchy is settled. + for (const node of ordinaryNodes) { + const candidates = groups.filter((group) => + group.allowsMembership(node) + ); + const parent = resolveParent(node, candidates, engine); + if (!parent) continue; + nextMembers.get(parent)?.add(node); + nextParents.set(node, parent); + } + + const deltas = groups.map((group) => { + const previous = group.members; + const next = nextMembers.get(group) ?? new Set(); + return { + group, + next, + added: [...next].filter((node) => !previous.has(node)), + removed: [...previous].filter((node) => !next.has(node)), + }; + }); + + for (const node of nodes) { + const parent = nextParents.get(node); + if (parent) parentGroups.set(node, parent); + else parentGroups.delete(node); + } + for (const { group, next } of deltas) group.setResolvedMembers(next); + + if (fireDelta) { + for (const { group, next, added, removed } of deltas) { + if (!added.length && !removed.length) continue; + group.groupCallbacks.onMembershipChange?.({ + group, + added, + removed, + members: [...next], + }); + } + } + } finally { + reconcilingEngines.delete(engine); + } +} + +/** Return the node's settled, exclusive direct parent group. */ +export function getParentGroup( + node: NodeComponent, +): GroupNodeComponent | null { + return parentGroups.get(node) ?? null; +} + +/** + * Override automatic innermost-group selection for one engine. + * The resolver may return one of `event.candidates` or `null`. + */ +export function setGroupMembershipResolver( + engine: Engine, + resolver: GroupMembershipResolver, +): () => void { + membershipResolvers.set(engine, resolver); + const refresh = () => { + const global = engine.global; + const source = global + ? getGroups(global).find( + (group): group is GroupNodeComponent => + group instanceof GroupNodeComponent && group.engine === engine, + ) + : undefined; + source?.refreshMembership(true); + }; + refresh(); + + return () => { + if (membershipResolvers.get(engine) !== resolver) return; + membershipResolvers.delete(engine); + refresh(); + }; +} + +// A resizable box with settled geometric membership. Membership is exclusive: +// each node has one direct parent, while nested groups form a recursive tree. +class GroupNodeComponent extends NodeComponent { + #members: Set = new Set(); + #carry: NodeComponent[] = []; + #carryOrigins = new Map(); + #carryGroupOrigin = { x: 0, y: 0 }; + #groupCallbacks: GroupCallbacks; + #groupConfig: GroupConfig; + + constructor(engine: any, parent: BaseObject | null, config: GroupConfig = {}) { + const merged = mergeConfig( + { ...DEFAULT_GROUP_CONFIG }, + config, + ); + super(engine, parent, { ...merged, resizable: true }); + this.#groupConfig = merged; + this.#groupCallbacks = merged.groupCallbacks ?? {}; + getGroups(this.global).push(this); + } + + get groupCallbacks(): GroupCallbacks { + return this.#groupCallbacks; + } + + /** Direct settled members. */ + get members(): ReadonlySet { + return this.#members; + } + + /** Every settled member below this group, recursively and without duplicates. */ + get descendants(): ReadonlySet { + const result = new Set(); + const visit = (group: GroupNodeComponent): void => { + for (const member of group.#members) { + if (result.has(member)) continue; + result.add(member); + if (member instanceof GroupNodeComponent) visit(member); + } + }; + visit(this); + return result; + } + + get parentGroup(): GroupNodeComponent | null { + return getParentGroup(this); + } + + /** @internal Used by the engine-wide exclusive-membership reconciliation. */ + setResolvedMembers(members: Set): void { + this.#members = members; + } + + writeTransformAndLines(): void { + this.writeTransformRecursive(); + } + + allowsMembership(node: NodeComponent): boolean { + const box = this.hitBox.getWorldBoundsSnapshot(); + const nodeBounds = node.hitBox.getWorldBoundsSnapshot(); + const centerContained = + nodeBounds.centerX >= box.left && + nodeBounds.centerX <= box.right && + nodeBounds.centerY >= box.top && + nodeBounds.centerY <= box.bottom; + const boundsContained = containsBounds(box, nodeBounds); + + // Ordinary nodes use center containment. A nested group must fit completely + // so partially overlapping peers cannot become a parent/child pair. + if ( + node instanceof GroupNodeComponent ? !boundsContained : !centerContained + ) { + return false; + } + + return ( + this.#groupConfig.canContain?.({ + group: this, + node, + centerContained, + boundsContained, + }) ?? true + ); + } + + refreshMembership(fireDelta: boolean): void { + reconcileMembership(this, fireDelta); + } + + setSizeState(width: number, height: number): void { + super.setSizeState(width, height); + this.refreshMembership(true); + } + + beginSelectionDrag(position: eventPosition): void { + super.beginSelectionDrag(position); + this.#carryGroupOrigin = { + x: this.worldTransform.x, + y: this.worldTransform.y, + }; + this.#carry = [...this.descendants]; + this.#carryOrigins.clear(); + for (const member of this.#carry) { + this.#carryOrigins.set(member, { + x: member.worldTransform.x, + y: member.worldTransform.y, + }); + member.attachTransformToGroup(this); + } + } + + containsSelectionDragNode(node: NodeComponent): boolean { + return this.descendants.has(node); + } + + selectionDragNodes(): NodeComponent[] { + return [...new Set([this, ...this.#carry])]; + } + + finishSelectionDrag(): void { + const dx = this.worldTransform.x - this.#carryGroupOrigin.x; + const dy = this.worldTransform.y - this.#carryGroupOrigin.y; + for (const member of this.#carry) { + member.detachTransformFromGroup(); + const origin = this.#carryOrigins.get(member); + if (origin) { + member.worldTransform = { + x: origin.x + dx, + y: origin.y + dy, + }; + } + member.schedule(() => member.writeTransformAndLines(), { + stage: "WRITE_2", + queueId: `${member.id}-transform`, + }); + } + this.#carry = []; + this.#carryOrigins.clear(); + } + + destroy(): void { + snapData(this.global).groups = getGroups(this.global).filter( + (group) => group !== (this as unknown), + ); + for (const member of this.#carry) member.detachTransformFromGroup(); + this.#carry = []; + this.#carryOrigins.clear(); + parentGroups.delete(this); + + const remaining = getGroups(this.global).find( + (group): group is GroupNodeComponent => + group instanceof GroupNodeComponent && group.engine === this.engine, + ); + remaining?.refreshMembership(true); + super.destroy(); + } +} + +export { GroupNodeComponent }; diff --git a/assets/snapline/core/src/index.ts b/assets/snapline/core/src/index.ts index 7d92f65..d1c2116 100644 --- a/assets/snapline/core/src/index.ts +++ b/assets/snapline/core/src/index.ts @@ -1,6 +1,103 @@ -export { NodeComponent } from "./node"; -export type { NodeConfig } from "./node"; -export { ConnectorComponent } from "./connector"; -export type { ConnectorConfig } from "./connector"; +export { + NodeComponent, + DEFAULT_RESIZE_CURSORS, + DEFAULT_RESIZE_HANDLE_THICKNESS, + RESIZE_HANDLES, +} from "./node"; +export type { + NodeConfig, + NodeCallbacks, + NodeDragCommitEvent, + NodeDragPositionEvent, + NodeLinesEvent, + NodePointerEvent, + NodePosition, + ResolvedNodeDragPosition, + NodeResizeEvent, + NodeResizeHandleEvent, + NodeSelectionEvent, + NodeSelectionModeEvent, + ResizeHandle, + SelectionMode, +} from "./node"; +export { ConnectorComponent, resolveConnectorSourceAtPoint } from "./connector"; +export type { + ConnectionOrigin, + ConnectorAnchor, + ConnectorAnchorEvent, + ConnectorCallbacks, + ConnectorCapabilities, + ConnectorCandidateEvent, + ConnectorCandidate, + ConnectorConfig, + ConnectorConfigUpdate, + ConnectorConnectionEvent, + ConnectorConnectionRequestEvent, + ConnectorConnectionRequestResult, + ConnectorDisconnectionEvent, + ConnectorDragEvent, + ConnectorGeometrySnapshot, + ConnectorHit, + ConnectorLinePhase, + ConnectorNormal, + ConnectorPairEvent, + ConnectorPoint, + ConnectorPointerEvent, + ConnectorResolvedHit, + ConnectorRole, + ConnectorSurfaceHitTestEvent, + ConnectorSurfaceStrategy, + DisconnectReason, + SnapLineMetadata, +} from "./connector"; export { LineComponent } from "./line"; +export { + GroupNodeComponent, + getParentGroup, + setGroupMembershipResolver, +} from "./group"; +export type { + GroupCallbacks, + GroupConfig, + GroupContainEvent, + GroupMembershipEvent, + GroupMembershipResolutionEvent, + GroupMembershipResolver, +} from "./group"; export { RectSelectComponent } from "./select"; +export type { + SelectCallbacks, + SelectChangeEvent, + SelectConfig, + SelectRect, + SelectStartEvent, +} from "./select"; +export { + getConnectors, + getGroupNodes, + getNodes, + getSelectedNodes, +} from "./query"; +export { PlacementController } from "./placement"; +export type { + PlacementAnchor, + PlacementCallbacks, + PlacementCancelEvent, + PlacementConfig, + PlacementEvent, + PlacementPoint, + PlacementSize, + PlacementSnapshot, +} from "./placement"; +export { NodeManager } from "./node-manager"; +export type { EdgeSyncLike } from "./node-manager"; +export { getNodeManager } from "./snapline-globals"; +export { EdgeSyncController } from "./edge-sync"; +export type { + EdgeConnectIntentEvent, + EdgeDisconnectIntentEvent, + EdgeEndpoint, + EdgeLike, + EdgeSyncCallbacks, + EdgeSyncConfig, +} from "./edge-sync"; diff --git a/assets/snapline/core/src/line.ts b/assets/snapline/core/src/line.ts index b8e2e0f..bf43bbf 100644 --- a/assets/snapline/core/src/line.ts +++ b/assets/snapline/core/src/line.ts @@ -1,5 +1,13 @@ import { ElementObject, BaseObject } from "@snap-engine/core"; -import { ConnectorComponent } from "./connector"; +import type { + ConnectorAnchor, + ConnectorCandidate, + ConnectorComponent, + ConnectorHit, + ConnectorLinePhase, + ConnectorPoint, + ConnectorSurfaceStrategy, +} from "./connector"; class LineComponent extends ElementObject { endWorldX: number; @@ -7,7 +15,18 @@ class LineComponent extends ElementObject { start: ConnectorComponent; target: ConnectorComponent | null; + payload: unknown; + startAnchor: ConnectorAnchor; + endAnchor: ConnectorAnchor; + phase: ConnectorLinePhase; + candidate: ConnectorCandidate | null; + #renderCallbacks: Set<(line: LineComponent) => void>; + #sourceStrategy: ConnectorSurfaceStrategy | null = null; + #sourceHit: ConnectorHit | null = null; + #targetStrategy: ConnectorSurfaceStrategy | null = null; + #targetHit: ConnectorHit | null = null; + #previewPosition: ConnectorPoint | null = null; constructor(engine: any, parent: BaseObject) { super(engine, parent); @@ -17,6 +36,11 @@ class LineComponent extends ElementObject { this.start = parent as unknown as ConnectorComponent; this.target = null; + this.payload = undefined; + this.startAnchor = { x: 0, y: 0 }; + this.endAnchor = { x: 0, y: 0 }; + this.phase = "source-start"; + this.candidate = null; this.#renderCallbacks = new Set(); this.transformMode = "direct"; @@ -29,31 +53,122 @@ class LineComponent extends ElementObject { }; } - requestRender() { + requestRender(): void { for (const callback of this.#renderCallbacks) { callback(this); } } - setLineStartAtConnector() { - const center = this.start.center; - this.setLineStart(center.x, center.y); + setSourceSurfaceContext( + strategy: ConnectorSurfaceStrategy | null, + hit: ConnectorHit | null, + ): void { + this.#sourceStrategy = strategy; + this.#sourceHit = hit; } - setLineEndAtConnector() { - if (this.target) { - const center = this.target.center; - this.setLineEnd(center.x, center.y); - } + setCandidate( + candidate: ConnectorCandidate | null, + strategy: ConnectorSurfaceStrategy | null = null, + ): void { + this.candidate = candidate; + this.#targetStrategy = strategy; + this.#targetHit = candidate?.hit ?? null; + this.requestRender(); + } + + setPhase(phase: ConnectorLinePhase): void { + if (this.phase === phase) return; + this.phase = phase; + this.requestRender(); + } + + setPayload(payload: unknown): void { + this.payload = payload; + this.requestRender(); + } + + setPreviewPosition(position: ConnectorPoint): void { + this.#previewPosition = position; + // Pointer and edge-pan updates are committed through the engine's write + // phase by ConnectorComponent. Updating the model here but deferring the + // render callback keeps the line and camera transform in the same frame; + // rendering immediately leaves the preview one camera frame behind during + // continuous edge-pan. + this.updateAnchors(false); + } + + connectTarget( + target: ConnectorComponent, + candidate: ConnectorCandidate | null = this.candidate, + strategy: ConnectorSurfaceStrategy | null = this.#targetStrategy, + ): void { + this.target = target; + this.#targetStrategy = strategy; + this.#targetHit = candidate?.hit ?? this.#targetHit; + this.candidate = null; + this.phase = "connected"; + this.updateAnchors(); + } + + clearTarget(): void { + this.target = null; + this.candidate = null; + this.#targetStrategy = null; + this.#targetHit = null; + this.phase = "preview-free"; + this.requestRender(); + } + + setLineStartAtConnector(): void { + const peer = this.target ?? this.candidate?.connector ?? null; + const peerGeometry = peer?.geometry ?? null; + const position = + peerGeometry?.center ?? this.#previewPosition ?? this.endAnchor; + const anchor = this.start.resolveAnchor({ + line: this, + role: "source", + phase: this.phase, + peer, + position, + hit: this.#sourceHit, + strategy: this.#sourceStrategy, + }); + this.setLineStartAnchor(anchor); + } + + setLineEndAtConnector(): void { + const target = this.target ?? this.candidate?.connector ?? null; + if (!target) return; + const anchor = target.resolveAnchor({ + line: this, + role: "target", + phase: this.phase, + peer: this.start, + position: this.start.geometry.center, + hit: this.#targetHit, + strategy: this.#targetStrategy, + }); + this.setLineEndAnchor(anchor); + } + + setLineStart(startPositionX: number, startPositionY: number): void { + this.setLineStartAnchor({ x: startPositionX, y: startPositionY }); } - setLineStart(startPositionX: number, startPositionY: number) { - this.worldTransform = { x: startPositionX, y: startPositionY }; + setLineEnd(endWorldX: number, endWorldY: number): void { + this.setLineEndAnchor({ x: endWorldX, y: endWorldY }); } - setLineEnd(endWorldX: number, endWorldY: number) { - this.endWorldX = endWorldX; - this.endWorldY = endWorldY; + setLineStartAnchor(anchor: ConnectorAnchor): void { + this.startAnchor = cloneAnchor(anchor); + this.worldTransform = { x: anchor.x, y: anchor.y }; + } + + setLineEndAnchor(anchor: ConnectorAnchor): void { + this.endAnchor = cloneAnchor(anchor); + this.endWorldX = anchor.x; + this.endWorldY = anchor.y; } setLinePosition( @@ -61,24 +176,74 @@ class LineComponent extends ElementObject { startWorldY: number, endWorldX: number, endWorldY: number, - ) { + ): void { this.setLineStart(startWorldX, startWorldY); this.setLineEnd(endWorldX, endWorldY); } - moveLineToConnectorTransform() { - this.setLineStartAtConnector(); - if (!this.target) { - // this.setLineEnd(this.global.cursor.worldX, this.global.cursor.worldY); - } else { - this.setLineEndAtConnector(); + updateAnchors(requestRender = true): void { + const target = this.target ?? this.candidate?.connector ?? null; + if (!target) { + const preview = this.#previewPosition ?? this.endAnchor; + const startAnchor = this.start.resolveAnchor({ + line: this, + role: "source", + phase: this.phase, + peer: null, + position: preview, + hit: this.#sourceHit, + strategy: this.#sourceStrategy, + }); + this.setLineStartAnchor(startAnchor); + this.setLineEndAnchor(preview); + if (requestRender) this.requestRender(); + return; } + + const sourceGeometry = this.start.geometry; + const targetGeometry = target.geometry; + const sourceAnchor = this.start.resolveAnchor({ + line: this, + role: "source", + phase: this.phase, + peer: target, + position: targetGeometry.center, + hit: this.#sourceHit, + strategy: this.#sourceStrategy, + }); + const targetAnchor = target.resolveAnchor({ + line: this, + role: "target", + phase: this.phase, + peer: this.start, + position: sourceGeometry.center, + hit: this.#targetHit, + strategy: this.#targetStrategy, + }); + this.setLineStartAnchor(sourceAnchor); + this.setLineEndAnchor(targetAnchor); + if (requestRender) this.requestRender(); } - writeTransform() { - super.writeTransform(); + moveLineToConnectorTransform(): void { + this.updateAnchors(); + } + + writeTransform(): void { + // A logical/headless line can exist before a framework mounts its SVG. + if (this.element) super.writeTransform(); this.requestRender(); } } +function cloneAnchor(anchor: ConnectorAnchor): ConnectorAnchor { + return { + x: anchor.x, + y: anchor.y, + ...(anchor.normal + ? { normal: { x: anchor.normal.x, y: anchor.normal.y } } + : {}), + }; +} + export { LineComponent }; diff --git a/assets/snapline/core/src/node-manager.ts b/assets/snapline/core/src/node-manager.ts new file mode 100644 index 0000000..b45ce45 --- /dev/null +++ b/assets/snapline/core/src/node-manager.ts @@ -0,0 +1,69 @@ +import type { + ConnectorComponent, + ConnectorConnectionEvent, + ConnectorDisconnectionEvent, +} from "./connector"; +import type { NodeComponent } from "./node"; + +// Structural stand-in for EdgeSyncController so the manager (and the emit +// sites that reach it) never value-import edge-sync — edge-sync imports the +// manager accessor, not the reverse. +export interface EdgeSyncLike { + notifyConnect(event: ConnectorConnectionEvent): void; + notifyDisconnect(event: ConnectorDisconnectionEvent): void; + /** A connector registered after the controller — reconcile so document + * edges whose endpoints just mounted get their lines. */ + connectorRegistered?(): void; +} + +// Engine-scoped registry of every live SnapLine node and connector. +// +// Created lazily by `getNodeManager(engine)` the first time any SnapLine +// component registers, so it exists exactly when SnapLine is in use — no +// adapter wiring required, vanilla consumers included. Components register in +// their constructors and unregister in `destroy()`. +// +// Beyond enumeration (which `query.ts` delegates to), the manager is the home +// for engine-scoped SnapLine facilities: the controlled-edges controller +// today (`edgeSync`), layout helpers that need to walk `nodes` tomorrow. +export class NodeManager { + readonly engine: unknown; + #nodes = new Set(); + #connectors = new Set(); + + // Engine-scoped controlled-edges controller. Registered by + // EdgeSyncController's constructor; the connector emit sites forward + // connection events through it. + edgeSync: EdgeSyncLike | null = null; + + constructor(engine: unknown) { + this.engine = engine; + } + + registerNode(node: NodeComponent): void { + this.#nodes.add(node); + } + + unregisterNode(node: NodeComponent): void { + this.#nodes.delete(node); + } + + registerConnector(connector: ConnectorComponent): void { + this.#connectors.add(connector); + this.edgeSync?.connectorRegistered?.(); + } + + unregisterConnector(connector: ConnectorComponent): void { + this.#connectors.delete(connector); + } + + // Live nodes in registration order. Returns a copy, never internal state. + get nodes(): readonly NodeComponent[] { + return [...this.#nodes]; + } + + // Live connectors in registration order. Returns a copy. + get connectors(): readonly ConnectorComponent[] { + return [...this.#connectors]; + } +} diff --git a/assets/snapline/core/src/node.ts b/assets/snapline/core/src/node.ts index b2c1430..dd0dc24 100644 --- a/assets/snapline/core/src/node.ts +++ b/assets/snapline/core/src/node.ts @@ -1,5 +1,8 @@ import { BaseObject, ElementObject } from "@snap-engine/core"; -import { ConnectorComponent } from "./connector"; +import { + ConnectorComponent, + resolveConnectorSourceAtPoint, +} from "./connector"; import { LineComponent } from "./line"; import type { pointerUpProp, @@ -7,34 +10,352 @@ import type { dragStartProp, dragProp, dragEndProp, + eventPosition, + pointerMoveProp, } from "@snap-engine/core"; import { RectCollider } from "@snap-engine/core/collision"; +import { getSelectList, getGroups, getNodeManager, getResizeHandles, snapData } from "./snapline-globals"; +import type { SnapLineMetadata } from "./connector"; + +export type ResizeHandle = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; + +export const RESIZE_HANDLES: readonly ResizeHandle[] = [ + "n", + "ne", + "e", + "se", + "s", + "sw", + "w", + "nw", +]; + +export const DEFAULT_RESIZE_CURSORS: Readonly> = { + n: "ns-resize", + ne: "nesw-resize", + e: "ew-resize", + se: "nwse-resize", + s: "ns-resize", + sw: "nesw-resize", + w: "ew-resize", + nw: "nwse-resize", +}; export interface NodeConfig { lockPosition?: boolean; + /** Enables resize handles. All four sides and corners are enabled by default. */ + resizable?: boolean; + minWidth?: number; + minHeight?: number; + /** + * Total thickness of each virtual edge/corner resize hitbox. Half of the + * hitbox sits inside the node boundary and half outside. + */ + resizeHandleThickness?: number; + /** Enabled handles. `true` means all eight; an array enables only those handles. */ + resizeHandles?: true | readonly ResizeHandle[]; + /** Per-handle CSS cursor overrides. */ + resizeCursors?: Partial>; + metadata?: SnapLineMetadata; + callbacks?: NodeCallbacks; + /** Allows this node gesture to use the engine's configured edge pan. */ + edgePan?: boolean; +} + +/** Shared by core hitboxes and adapter resize-handle visuals. */ +export const DEFAULT_RESIZE_HANDLE_THICKNESS = 14; + +const DEFAULT_NODE_CONFIG: Required = { + lockPosition: false, + resizable: false, + minWidth: 0, + minHeight: 0, + resizeHandleThickness: DEFAULT_RESIZE_HANDLE_THICKNESS, + resizeHandles: true, + resizeCursors: {}, + metadata: {}, + callbacks: {}, + edgePan: true, +}; + +/** Object-spread merge that ignores undefined values (adapters forward + * possibly-undefined props, which must not shadow the defaults). */ +export function mergeConfig(defaults: T, config: Partial): T { + const merged = { ...defaults }; + for (const key of Object.keys(config) as (keyof T)[]) { + const value = config[key]; + if (value !== undefined) merged[key] = value as T[keyof T]; + } + return merged; +} + +/** Consumer policy and lifecycle surfaces. Callbacks receive event objects so + * new context can be added without growing positional signatures. */ +export interface NodePosition { + node: NodeComponent; + x: number; + y: number; +} + +export interface NodePointerEvent { + node: NodeComponent; + pointerId: number; + position: eventPosition; + originalEvent?: PointerEvent; +} + +export interface NodeDragCommitEvent extends NodePointerEvent { + nodes: NodePosition[]; +} + +export interface NodeDragPositionEvent { + node: NodeComponent; + x: number; + y: number; + startX: number; + startY: number; + position: eventPosition; +} + +export interface ResolvedNodeDragPosition { + x: number; + y: number; +} + +export interface NodeResizeEvent { + node: NodeComponent; + handle: ResizeHandle | null; + x: number; + y: number; + width: number; + height: number; +} + +export interface NodeResizeHandleEvent { + node: NodeComponent; + handle: ResizeHandle | null; + cursor: string | null; +} + +export interface NodeSelectionEvent { + node: NodeComponent; + selected: boolean; + selection: readonly NodeComponent[]; +} + +export type SelectionMode = "replace" | "add" | "toggle"; + +export interface NodeSelectionModeEvent { + node: NodeComponent; + selected: boolean; + selection: readonly NodeComponent[]; + originalEvent: PointerEvent; +} + +export interface NodeLinesEvent { + node: NodeComponent; + lines: readonly LineComponent[]; +} + +export interface NodeCallbacks { + canStartDrag?: (event: NodePointerEvent) => boolean; + /** Resolves a proposed live node position before its world transform changes. */ + resolveDragPosition?: ( + event: NodeDragPositionEvent, + ) => ResolvedNodeDragPosition; + /** Consumer-defined pointer selection policy; SnapLine owns no modifier keys. */ + resolveSelectionMode?: (event: NodeSelectionModeEvent) => SelectionMode; + onDragStart?: (event: NodePointerEvent) => void; + onDrag?: (event: NodePointerEvent) => void; + onDragCommit?: (event: NodeDragCommitEvent) => void; + onSelectionChange?: (event: NodeSelectionEvent) => void; + /** Live size updates during a resize drag — the adapter renders width/height. */ + onSizeChange?: (event: NodeResizeEvent) => void; + /** Final size at resize-drag end — the consumer persists it. */ + onResizeCommit?: (event: NodeResizeEvent) => void; + /** The resize handle currently hovered, or null after leaving it. */ + onResizeHandleChange?: (event: NodeResizeHandleEvent) => void; + /** The set of outgoing lines changed — the adapter re-renders its line list. */ + onLinesChanged?: (event: NodeLinesEvent) => void; +} + +const CORNER_HANDLES = new Set(["ne", "se", "sw", "nw"]); +class ResizeHandleCollider extends RectCollider { + readonly handle: ResizeHandle; + readonly cursor: string; + + constructor( + engine: any, + parent: NodeComponent, + handle: ResizeHandle, + cursor: string, + ) { + super(engine, parent, 0, 0, 0, 0); + this.handle = handle; + this.cursor = cursor; + } +} + +class ResizeHoverController extends BaseObject { + #count = 0; + #node: NodeComponent | null = null; + #handle: ResizeHandleCollider | null = null; + #target: HTMLElement | null = null; + #previousNodeCursor = ""; + #previousContainerCursor = ""; + #previousTargetCursor = ""; + + constructor(engine: any) { + super(engine, null); + this.event.global.pointerMove = this.#onPointerMove; + } + + retain(): void { + this.#count++; + } + + release(node: NodeComponent): void { + this.#count--; + if (this.#node === node) this.clear(); + if (this.#count <= 0) { + resizeHoverControllers.delete(this.engine); + this.destroy(); + } + } + + activate(handle: ResizeHandleCollider, target?: EventTarget | null): void { + const node = handle.parent as NodeComponent; + const element = target instanceof HTMLElement ? target : null; + if (this.#handle === handle && this.#target === element) return; + this.#restoreCss(); + const nodeElement = node.element; + const container = this.engine.containerElement as HTMLElement | null; + this.#node = node; + this.#handle = handle; + this.#target = element; + this.#previousNodeCursor = nodeElement?.style.cursor ?? ""; + this.#previousContainerCursor = container?.style.cursor ?? ""; + this.#previousTargetCursor = element?.style.cursor ?? ""; + nodeElement?.setAttribute("data-snapline-resize-handle", handle.handle); + if (nodeElement) nodeElement.style.cursor = handle.cursor; + if (container) container.style.cursor = handle.cursor; + if (element) element.style.cursor = handle.cursor; + node.callbacks.onResizeHandleChange?.({ + node, + handle: handle.handle, + cursor: handle.cursor, + }); + } + + clear(): void { + if (!this.#node) return; + const node = this.#node; + this.#restoreCss(); + this.#node = null; + this.#handle = null; + this.#target = null; + node.callbacks.onResizeHandleChange?.({ node, handle: null, cursor: null }); + } + + #restoreCss(): void { + const nodeElement = this.#node?.element; + const container = this.engine.containerElement as HTMLElement | null; + nodeElement?.removeAttribute("data-snapline-resize-handle"); + if (nodeElement) nodeElement.style.cursor = this.#previousNodeCursor; + if (container) container.style.cursor = this.#previousContainerCursor; + if (this.#target) this.#target.style.cursor = this.#previousTargetCursor; + } + + #onPointerMove(prop: pointerMoveProp): void { + const handle = findResizeHandle(this.engine, prop.position); + if (!handle) { + this.clear(); + return; + } + this.activate(handle, prop.event?.target); + } +} + +const resizeHoverControllers = new WeakMap(); + +function hoverController(engine: any): ResizeHoverController { + let controller = resizeHoverControllers.get(engine); + if (!controller) { + controller = new ResizeHoverController(engine); + resizeHoverControllers.set(engine, controller); + } + return controller; +} + +function findResizeHandle( + engine: any, + position: eventPosition, + node?: NodeComponent, +): ResizeHandleCollider | null { + let winner: ResizeHandleCollider | null = null; + for (const collider of getResizeHandles(engine.global)) { + if (!(collider instanceof ResizeHandleCollider) || collider.engine !== engine) continue; + if (node && collider.parent !== node) continue; + if (!collider.containsWorldPoint(position.x, position.y)) continue; + if (!winner || CORNER_HANDLES.has(collider.handle) || !CORNER_HANDLES.has(winner.handle)) { + winner = collider; + } + } + return winner; } class NodeComponent extends ElementObject { - _config: NodeConfig; + #config: Required; _connectors: { [key: string]: ConnectorComponent }; _components: { [key: string]: ElementObject }; - _nodeWidth = 0; - _nodeHeight = 0; _dragStartX = 0; _dragStartY = 0; _prop: { [key: string]: any }; _propSetCallback: { [key: string]: (value: any) => void }; _nodeStyle: any; - _lineListCallback: ((lines: LineComponent[]) => void) | null; - _hitBox: RectCollider; + #hitBox: RectCollider; _selected: boolean; _mouseDownX: number; _mouseDownY: number; _hasMoved: boolean; + #resizeHitBoxes = new Map(); + #resizeHandles: readonly ResizeHandle[]; + #resizeHandleThickness: number; + #resizeHoverController: ResizeHoverController | null = null; + #activeResizeHandle: ResizeHandle | null = null; + /** Read by GroupNodeComponent to distinguish a resize from a move drag. */ + protected _resizing = false; + #resizeArmed = false; + #resizeStartW = 0; + #resizeStartH = 0; + #resizeStartX = 0; + #resizeStartY = 0; + #callbacks: NodeCallbacks; + #edgePanPointerId: number | null = null; + #dragHandles = new Set(); + #dragPointerId: number | null = null; + #dragRoots: NodeComponent[] = []; + #dragCommitNodes: NodeComponent[] = []; + #lastDragPosition: eventPosition | null = null; + #pointerSelectionMode: SelectionMode = "replace"; + #selectedAtPointerDown = false; constructor(engine: any, parent: BaseObject | null, config: NodeConfig = {}) { super(engine, parent); - this._config = config; + this.#config = mergeConfig(DEFAULT_NODE_CONFIG, config); + this.#callbacks = this.#config.callbacks; + getNodeManager(this.engine).registerNode(this); + const resizeEnabled = config.resizable === true || config.resizeHandles !== undefined; + this.#resizeHandles = !resizeEnabled + ? [] + : config.resizeHandles !== undefined + ? config.resizeHandles === true + ? RESIZE_HANDLES + : [...new Set(config.resizeHandles)] + : RESIZE_HANDLES; + this.#resizeHandleThickness = + config.resizeHandleThickness ?? + DEFAULT_RESIZE_HANDLE_THICKNESS; this._connectors = {}; this._components = {}; @@ -44,7 +365,6 @@ class NodeComponent extends ElementObject { this._mouseDownY = 0; this._prop = {}; this._propSetCallback = {}; - this._lineListCallback = null; this.transformMode = "direct"; this.event.input.pointerDown = this.onCursorDown; @@ -52,50 +372,76 @@ class NodeComponent extends ElementObject { this.event.input.drag = this.onDrag; this.event.input.dragEnd = this.onDragEnd; this.event.input.pointerUp = this.onUp; - this._hitBox = new RectCollider(this.engine, this, 0, 0, 0, 0); - this.addCollider(this._hitBox); + this.#hitBox = new RectCollider(this.engine, this, 0, 0, 0, 0); + this.addCollider(this.#hitBox); + + // Resize surfaces are virtual colliders. Engine input resolves them before + // DOM ownership, so handles can straddle the element and work for groups + // whose body intentionally has pointer-events:none. + if (this.#resizeHandles.length > 0) { + this.#resizeHoverController = hoverController(this.engine); + this.#resizeHoverController.retain(); + for (const handle of this.#resizeHandles) { + const collider = new ResizeHandleCollider( + this.engine, + this, + handle, + this.#config.resizeCursors[handle] ?? DEFAULT_RESIZE_CURSORS[handle], + ); + this.#resizeHitBoxes.set(handle, collider); + this.addCollider(collider); + getResizeHandles(this.global).push(collider); + } + this.#positionResizeHitBoxes(0, 0); + } this._selected = false; this._hasMoved = false; - this.event.dom.onResize = () => { - this.schedule( - () => { - const property = this.readDom({ unapplyTransform: false }, "READ_1"); - this._hitBox.width = property.width; - this._hitBox.height = property.height; - for (const connector of Object.values(this._connectors)) { - connector.measureLocalCenter("READ_1"); - } - }, - { stage: "READ_1" }, - ); - for (const line of [ - ...this.getAllOutgoingLines(), - ...this.getAllIncomingLines(), - ]) { - line.schedule( - () => { - line.moveLineToConnectorTransform(); // Move lines to the saved position of connectors - line.setLineEndAtConnector(); - line.writeDom(); - line.writeTransform(); - }, - { stage: "WRITE_1" }, - ); - } - }; + // Whenever the DOM box changes size (ResizeObserver) re-measure + re-glue. + this.event.dom.onResize = () => this.syncDomGeometry(); - this.style = { - willChange: "transform", - position: "absolute", - transformOrigin: "top left", - }; + // Base positioning styles are framework-owned: adapters must render the + // element with `position: absolute; transform-origin: top left` (see the + // ownership note in assets/snapline/AGENTS.md). // Initialize global select list if needed - if (!this.global.data.select) { - this.global.data.select = []; - } + getSelectList(this.global); + } + + get config(): Required { + return this.#config; + } + + get callbacks(): NodeCallbacks { + return this.#callbacks; + } + + set callbacks(callbacks: NodeCallbacks) { + this.#callbacks = callbacks; + } + + get metadata(): SnapLineMetadata { + return this.#config.metadata; + } + + get resizeHandles(): readonly ResizeHandle[] { + return this.#resizeHandles; + } + + get resizeHandleThickness(): number { + return this.#resizeHandleThickness; + } + + registerDragHandle(element: HTMLElement): () => void { + this.#dragHandles.add(element); + return () => this.#dragHandles.delete(element); + } + + /** The node's collision footprint (world = worldTransform + width/height). + * Read by groups for geometric membership. */ + get hitBox(): RectCollider { + return this.#hitBox; } setStartPositions() { @@ -104,27 +450,30 @@ class NodeComponent extends ElementObject { } setSelected(selected: boolean) { - if (!this.global.data.select) { - this.global.data.select = []; - } this._selected = selected; this.dataAttribute = { selected: String(selected), "snapline-state": selected ? "focus" : "idle", }; + const selectList = getSelectList(this.global); if (selected) { - if (!this.global.data.select.includes(this)) { - this.global.data.select.push(this); + if (!selectList.includes(this)) { + selectList.push(this); } } else { - this.global.data.select = this.global.data.select.filter( - (node: NodeComponent) => node.id !== this.id, + snapData(this.global).select = selectList.filter( + (node) => node.id !== this.id, ); } this.schedule(() => this.writeDom(), { stage: "WRITE_1", queueId: `${this.id}-selected`, }); + this.#callbacks.onSelectionChange?.({ + node: this, + selected, + selection: [...getSelectList(this.global)], + }); } _filterDeletedLines(svgLines: LineComponent[]) { @@ -136,79 +485,366 @@ class NodeComponent extends ElementObject { } } - updateNodeLines(): void { + /** Schedules a WRITE_2 write for every line on every connector of this node. */ + scheduleLineWrites(): void { for (const connector of Object.values(this._connectors)) { - connector.updateAllLines(); + connector.scheduleAllLineWrites(); } } - writeNodeLines(): void { + /** Synchronously writes every line on every connector (call inside a WRITE stage). */ + writeLinesNow(): void { for (const connector of Object.values(this._connectors)) { - connector.writeAllLines(); + connector.writeAllLinesNow(); + } + } + + // Re-measure the node box + each connector's local center (READ_1) and re-glue + // every incoming/outgoing line (WRITE_1). This is the "same handling as a move + // plus a size re-measure": moving a node keeps connector local centers valid, + // but resizing invalidates them, so they must be re-read. Shared by the + // ResizeObserver and the JS-driven setSize; stable queueIds collapse a + // same-frame double-fire (idempotent when it runs twice across frames). + syncDomGeometry(): void { + if (!this.element) { + throw new Error("Cannot sync node geometry before assigning its DOM element"); + } + this.schedule( + () => { + const property = this.readDom({ unapplyTransform: false }, "READ_1"); + this.#hitBox.width = property.width; + this.#hitBox.height = property.height; + this.#positionResizeHitBoxes(property.width, property.height); + for (const connector of Object.values(this._connectors)) { + connector.measureLocalCenter("READ_1"); + } + }, + { stage: "READ_1", queueId: `${this.id}-remeasure` }, + ); + for (const line of [ + ...this.getAllOutgoingLines(), + ...this.getAllIncomingLines(), + ]) { + line.schedule( + () => { + line.moveLineToConnectorTransform(); + line.setLineEndAtConnector(); + line.writeDom(); + line.writeTransform(); + }, + { stage: "WRITE_1", queueId: `${line.id}-reglue` }, + ); + } + } + + // State-only half of a size change: clamps to min and synchronously updates + // the collision footprint + resize hitbox so the hit test and group + // containment stay correct mid-drag. Never touches the DOM — the element's + // width/height are framework-owned (rendered by the adapter). + setSizeState(width: number, height: number): void { + const w = Math.max(this.#config.minWidth, width); + const h = Math.max(this.#config.minHeight, height); + this.#hitBox.width = w; + this.#hitBox.height = h; + this.#positionResizeHitBoxes(w, h); + } + + // Drives the node's size from JS (resize handle): updates state, then asks the + // framework to render the new width/height via onSizeChange. The connector/line + // re-glue closes itself — the adapter's DOM write triggers the ResizeObserver, + // which runs syncDomGeometry AFTER the browser reflows (no handshake needed: + // the box repaint is not paint-atomic). + setSize(width: number, height: number, handle: ResizeHandle | null = null): void { + this.setSizeState(width, height); + this.#callbacks.onSizeChange?.({ + node: this, + handle, + x: this.worldTransform.x, + y: this.worldTransform.y, + width: this.#hitBox.width, + height: this.#hitBox.height, + }); + } + + #positionResizeHitBoxes(width: number, height: number): void { + const t = Math.max(0, this.#resizeHandleThickness); + const half = t / 2; + const horizontalLength = Math.max(0, width - t); + const verticalLength = Math.max(0, height - t); + const geometry: Record = { + n: [half, -half, horizontalLength, t], + ne: [width - half, -half, t, t], + e: [width - half, half, t, verticalLength], + se: [width - half, height - half, t, t], + s: [half, height - half, horizontalLength, t], + sw: [-half, height - half, t, t], + w: [-half, half, t, verticalLength], + nw: [-half, -half, t, t], + }; + for (const [handle, collider] of this.#resizeHitBoxes) { + const [x, y, colliderWidth, colliderHeight] = geometry[handle]; + collider.localTransform = { x, y }; + collider.width = colliderWidth; + collider.height = colliderHeight; + } + } + + // Applies one side/corner resize while keeping the opposite edges fixed. + #applyResizeDrag(dx: number, dy: number): void { + const handle = this.#activeResizeHandle; + if (!handle) return; + const west = handle === "w" || handle === "nw" || handle === "sw"; + const east = handle === "e" || handle === "ne" || handle === "se"; + const north = handle === "n" || handle === "ne" || handle === "nw"; + const south = handle === "s" || handle === "se" || handle === "sw"; + const proposedWidth = west + ? this.#resizeStartW - dx + : east + ? this.#resizeStartW + dx + : this.#resizeStartW; + const proposedHeight = north + ? this.#resizeStartH - dy + : south + ? this.#resizeStartH + dy + : this.#resizeStartH; + const width = Math.max(this.#config.minWidth, proposedWidth); + const height = Math.max(this.#config.minHeight, proposedHeight); + const x = west + ? this.#resizeStartX + (this.#resizeStartW - width) + : this.#resizeStartX; + const y = north + ? this.#resizeStartY + (this.#resizeStartH - height) + : this.#resizeStartY; + this.worldTransform = { x, y }; + this.setSize(width, height, handle); + if (west || north) { + this.schedule(() => this.writeTransformAndLines(), { + stage: "WRITE_2", + queueId: `${this.id}-transform`, + }); } } writeTransformAndLines(): void { this.writeTransform(); - this.writeNodeLines(); + this.writeLinesNow(); } - updateNodeLineList(): void { - if (this._lineListCallback) { - // console.log("updateNodeLineList", this.id); - this._lineListCallback(this.getAllOutgoingLines()); - } + // Called when a parent (e.g. a group) cascades a transform write down the + // transform graph: paint this node + recurse to its transform-children, then + // re-glue this node's own lines (the pure-transform cascade can't, since a + // line's two ends live on two different nodes). + writeTransformRecursive(): void { + super.writeTransformRecursive(); + this.writeLinesNow(); + } + + // Transform-only (re)parenting used by group carry: the public/DOM graph is + // left alone, so members stay flat siblings in the adapter's node list. + attachTransformToGroup(group: NodeComponent): void { + this.setTransformParent(group, true); + } + + detachTransformFromGroup(): void { + this.detachTransformParent(true); } - setLineListCallback(callback: (lines: LineComponent[]) => void) { - this._lineListCallback = callback; + updateNodeLineList(): void { + const lines = this.getAllOutgoingLines(); + this.#callbacks.onLinesChanged?.({ node: this, lines }); } onCursorDown(e: pointerDownProp): void { + // Authorization belongs to one pointer gesture. Clear any stale permission + // before evaluating this pointerdown (including non-primary buttons). + this.#dragPointerId = null; if (e.event.button != 0) { return; } + // Resize is a separate primitive from node dragging and remains available + // even when the consumer registered a narrow drag handle. + const resizeHandle = findResizeHandle(this.engine, e.position, this); + this.#resizeArmed = resizeHandle != null; + this.#activeResizeHandle = resizeHandle?.handle ?? null; + if (resizeHandle) { + this.#resizeHoverController?.activate(resizeHandle, e.event.target); + } else { + const source = resolveConnectorSourceAtPoint( + this.engine, + e.position, + this, + ); + if (source) { + this.engine.input.setPointerDragOwner( + e.event.pointerId, + source.candidate.connector, + ); + source.candidate.connector.armSurfaceGesture(e, source); + return; + } + } + const target = e.event.target as Node | null; + const dragAllowed = + this.#resizeArmed || + ((this.#dragHandles.size === 0 || + [...this.#dragHandles].some((handle) => target && handle.contains(target))) && + this.#callbacks.canStartDrag?.({ + node: this, + pointerId: e.event.pointerId, + position: e.position, + originalEvent: e.event, + }) !== false); + if (!dragAllowed) return; + this.#dragPointerId = e.event.pointerId; + + // Claim the pointer from the very first pointer event: waiting for the + // drag-start threshold would let the camera pan by one move event before + // onDragStart runs. The claim auto-releases when the gesture ends, so no + // paired release is needed anywhere. + this.engine.input.claimPointer(e.event.pointerId); + this._hasMoved = false; - if (this.global.data.select?.includes(this) == false) { - for (const node of [...this.global.data.select]) { + const selection = [...getSelectList(this.global)]; + this.#selectedAtPointerDown = selection.includes(this); + this.#pointerSelectionMode = + this.#callbacks.resolveSelectionMode?.({ + node: this, + selected: this.#selectedAtPointerDown, + selection, + originalEvent: e.event, + }) ?? "replace"; + + if (this.#pointerSelectionMode === "replace" && !this.#selectedAtPointerDown) { + for (const node of [...getSelectList(this.global)]) { node.setSelected(false); } this.setSelected(true); + } else if ( + (this.#pointerSelectionMode === "add" || + this.#pointerSelectionMode === "toggle") && + !this.#selectedAtPointerDown + ) { + this.setSelected(true); } } onDragStart(prop: dragStartProp): void { - // Disable camera control while dragging - this.global.data.allowCameraControl = false; - - for (const node of this.global.data.select ?? []) { - node.setStartPositions(); - node._mouseDownX = prop.start.x; - node._mouseDownY = prop.start.y; + if (this.#dragPointerId !== prop.pointerId) return; + if (this.#resizeArmed) { + this._resizing = true; + this.#resizeStartW = this.#hitBox.width; + this.#resizeStartH = this.#hitBox.height; + this.#resizeStartX = this.worldTransform.x; + this.#resizeStartY = this.worldTransform.y; + this._mouseDownX = prop.start.x; + this._mouseDownY = prop.start.y; + this._hasMoved = true; + // Guard so releasing a resize over another node doesn't click-select it. + snapData(this.global).resizingNode = this; + return; + } + if (!this.#config.lockPosition && this.#config.edgePan) { + this.#edgePanPointerId = prop.pointerId; + this.engine.edgePanController?.startEdgePan( + prop.pointerId, + prop.start, + (position) => this.#moveSelectionToPointer(position), + ); + } + const selected = [...getSelectList(this.global)]; + this.#dragRoots = selected.filter( + (node) => + !selected.some( + (candidate) => + candidate !== node && candidate.containsSelectionDragNode(node), + ), + ); + this.#lastDragPosition = prop.start; + for (const node of this.#dragRoots) { + node.beginSelectionDrag(prop.start); } + this.#dragCommitNodes = [ + ...new Set(this.#dragRoots.flatMap((node) => node.selectionDragNodes())), + ]; this._hasMoved = true; + this.#callbacks.onDragStart?.({ + node: this, + pointerId: prop.pointerId, + position: prop.start, + }); } onDrag(prop: dragProp): void { + if (this.#dragPointerId !== prop.pointerId) return; if (this.global == null) { console.error("Global stats is null"); return; } - if (this._config.lockPosition) return; - for (const node of this.global.data.select ?? []) { - node.setDragPosition(prop); + if (this._resizing) { + this.#applyResizeDrag( + prop.position.x - this._mouseDownX, + prop.position.y - this._mouseDownY, + ); + return; + } + if (this.#config.lockPosition) return; + if (this.#edgePanPointerId != null) { + this.engine.edgePanController?.updateEdgePan( + this.#edgePanPointerId, + prop.position, + ); + } + this.#moveSelectionToPointer(prop.position); + this.#callbacks.onDrag?.({ + node: this, + pointerId: prop.pointerId, + position: prop.position, + }); + } + + #moveSelectionToPointer(position: eventPosition): void { + this.#lastDragPosition = position; + for (const node of this.#dragRoots) { + node.setDragPosition({ position } as dragProp); } } + /** @internal Hook used to build one deduplicated multi-selection drag session. */ + beginSelectionDrag(position: eventPosition): void { + this.setStartPositions(); + this._mouseDownX = position.x; + this._mouseDownY = position.y; + } + + /** @internal Whether this node's drag behavior already carries `node`. */ + containsSelectionDragNode(_node: NodeComponent): boolean { + return false; + } + + /** @internal Nodes whose final positions belong to this drag root's commit. */ + selectionDragNodes(): NodeComponent[] { + return [this]; + } + + /** @internal Finalize any temporary carry state owned by this drag root. */ + finishSelectionDrag(): void {} + setDragPosition(prop: dragProp) { const dx = prop.position.x - this._mouseDownX; const dy = prop.position.y - this._mouseDownY; + const x = this._dragStartX + dx; + const y = this._dragStartY + dy; + const resolved = this.#callbacks.resolveDragPosition?.({ + node: this, + x, + y, + startX: this._dragStartX, + startY: this._dragStartY, + position: prop.position, + }) ?? { x, y }; - this.worldTransform = { - x: this._dragStartX + dx, - y: this._dragStartY + dy, - }; + this.worldTransform = { x: resolved.x, y: resolved.y }; this.schedule(() => this.writeTransformAndLines(), { stage: "WRITE_2", queueId: `${this.id}-transform`, @@ -216,12 +852,85 @@ class NodeComponent extends ElementObject { } onDragEnd(prop: dragEndProp) { - // Re-enable camera control after drag ends - this.global.data.allowCameraControl = true; + // A pointerdown rejected by a drag handle/predicate still becomes a generic + // input drag gesture once it crosses the engine threshold. It must not + // commit stale selection coordinates on release. + if (this.#dragPointerId !== prop.pointerId) return; + if (this.#edgePanPointerId != null) { + this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); + this.#edgePanPointerId = null; + } + if (this._resizing) { + this.#applyResizeDrag( + prop.end.x - this._mouseDownX, + prop.end.y - this._mouseDownY, + ); + this.#callbacks.onResizeCommit?.({ + node: this, + handle: this.#activeResizeHandle, + x: this.worldTransform.x, + y: this.worldTransform.y, + width: this.#hitBox.width, + height: this.#hitBox.height, + }); + this._resizing = false; + this.#resizeArmed = false; + this.#activeResizeHandle = null; + snapData(this.global).resizingNode = null; + this.#dragPointerId = null; + this.#refreshResizeHover(prop.end); + // A resized node's center may have moved into/out of a group. + for (const group of getGroups(this.global)) { + if ((group as unknown) !== this) group.refreshMembership(true); + } + return; + } + + // The live position is authoritative. Recomputing from the raw pointer-up + // coordinate would discard edge-pan compensation and can make the release + // frame jump away from what the user was dragging. + if (this.#lastDragPosition) { + this.#moveSelectionToPointer(this.#lastDragPosition); + } + for (const node of this.#dragRoots) { + node.finishSelectionDrag(); + node.schedule(() => node.writeTransformAndLines(), { + stage: "WRITE_2", + queueId: `${node.id}-transform`, + }); + } - for (const node of this.global.data.select ?? []) { - node.setUpPosition(prop); + // A settled node may have entered or left a group; groups re-evaluate + // membership on settle (never at group-drag-start), so the maintained set is + // current before the next group drag. The structural GroupLike type keeps + // node.ts free of any group import. + for (const group of getGroups(this.global)) { + group.refreshMembership(true); } + this.emitDragCommit(prop); + this.#dragRoots = []; + this.#dragCommitNodes = []; + this.#lastDragPosition = null; + this.#dragPointerId = null; + } + + protected emitDragCommit(prop: dragEndProp): void { + this.#callbacks.onDragCommit?.({ + node: this, + pointerId: prop.pointerId, + position: prop.end, + nodes: this.getDragCommitNodes().map((node) => ({ + node, + x: node.worldTransform.x, + y: node.worldTransform.y, + })), + }); + } + + protected getDragCommitNodes(): NodeComponent[] { + return this.#dragCommitNodes.length + ? [...this.#dragCommitNodes] + : [...getSelectList(this.global)]; } setUpPosition(prop: dragEndProp) { @@ -239,12 +948,32 @@ class NodeComponent extends ElementObject { }); } - onUp(_prop: pointerUpProp) { + onUp(prop: pointerUpProp) { + if (this.#dragPointerId !== prop.event.pointerId) return; + + // pointerUp is dispatched to whatever is under the release point, which for a + // resize may be a DIFFERENT node than the one being resized. Skip click-select + // while any resize is settling so releasing a resize doesn't select this node. + if (snapData(this.global).resizingNode) return; + if (this.#resizeArmed) { + this.#resizeArmed = false; + this.#activeResizeHandle = null; + this.#refreshResizeHover(prop.position, prop.event.target); + return; + } + if (this._hasMoved == false) { - for (const node of [...(this.global.data.select ?? [])]) { - node.setSelected(false); + if (this.#pointerSelectionMode === "replace") { + for (const node of [...getSelectList(this.global)]) { + if (node !== this) node.setSelected(false); + } + this.setSelected(true); + } else if (this.#pointerSelectionMode === "add") { + this.setSelected(true); + } else { + this.setSelected(!this.#selectedAtPointerDown); } - this.setSelected(true); + this.#dragPointerId = null; } this._hasMoved = false; } @@ -282,25 +1011,42 @@ class NodeComponent extends ElementObject { } setProp(name: string, value: any) { - if (name in this._propSetCallback) { - this._propSetCallback[name](value); - } - this._prop[name] = value; + const pending: Array<{ node: NodeComponent; name: string }> = [ + { node: this, name }, + ]; + const visited = new Map>(); - if (!(name in this._connectors)) { - return; - } - const peers = this._connectors[name].outgoingLines - .filter((line) => line.target && !line.isDeleteRequested) - .map((line) => line.target); - if (!peers) { - return; - } - for (const peer of peers) { - if (!peer) continue; - if (!peer.parent) continue; - let parent = peer.parent as NodeComponent; - parent.setProp(peer.name, value); + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + + let visitedNames = visited.get(current.node); + if (!visitedNames) { + visitedNames = new Set(); + visited.set(current.node, visitedNames); + } + if (visitedNames.has(current.name)) continue; + visitedNames.add(current.name); + + if (current.name in current.node._propSetCallback) { + current.node._propSetCallback[current.name](value); + } + current.node._prop[current.name] = value; + + const connector = current.node._connectors[current.name]; + if (!connector) continue; + + const peers = connector.outgoingLines + .filter((line) => line.target && !line.isDeleteRequested) + .map((line) => line.target); + for (let index = peers.length - 1; index >= 0; index -= 1) { + const peer = peers[index]; + if (!peer?.parent) continue; + pending.push({ + node: peer.parent as NodeComponent, + name: peer.name, + }); + } } } @@ -310,11 +1056,33 @@ class NodeComponent extends ElementObject { } } + #refreshResizeHover(position: eventPosition, target?: EventTarget | null): void { + this.#resizeHoverController?.clear(); + const handle = findResizeHandle(this.engine, position); + if (handle) this.#resizeHoverController?.activate(handle, target); + } + destroy() { + if (this.#edgePanPointerId != null) { + this.engine.edgePanController?.stopEdgePan(this.#edgePanPointerId); + this.#edgePanPointerId = null; + } for (const connector of Object.values(this._connectors)) { - connector.deleteAllLines(); + // A node unmount is a teardown, not a deliberate programmatic + // disconnect — keep the reason contract honest for intent consumers. + connector.deleteAllLines("teardown"); } + getNodeManager(this.engine).unregisterNode(this); this.setSelected(false); + if (this.#resizeHitBoxes.size > 0) { + const ownedHandles = new Set(this.#resizeHitBoxes.values()); + snapData(this.global).resizeHandles = getResizeHandles(this.global).filter( + (handle) => !ownedHandles.has(handle as ResizeHandleCollider), + ); + this.#resizeHoverController?.release(this); + this.#resizeHoverController = null; + this.#resizeHitBoxes.clear(); + } this._connectors = {}; super.destroy(); } diff --git a/assets/snapline/core/src/placement.ts b/assets/snapline/core/src/placement.ts new file mode 100644 index 0000000..3a5cbac --- /dev/null +++ b/assets/snapline/core/src/placement.ts @@ -0,0 +1,189 @@ +export interface PlacementPoint { + x: number; + y: number; +} + +export interface PlacementSize { + width: number; + height: number; +} + +export interface PlacementAnchor { + x: number; + y: number; +} + +export interface PlacementSnapshot { + active: boolean; + payload: T | null; + screen: PlacementPoint | null; + world: PlacementPoint | null; + position: PlacementPoint | null; + size: PlacementSize | null; + anchor: PlacementAnchor; + allowed: boolean; +} + +export interface PlacementEvent extends PlacementSnapshot { + payload: T; + screen: PlacementPoint; + world: PlacementPoint; + position: PlacementPoint; + size: PlacementSize; + originalEvent?: PointerEvent | KeyboardEvent; +} + +export interface PlacementCancelEvent { + controller: PlacementController; + payload: T; + reason: "cancel" | "escape" | "secondary-button" | "outside"; + originalEvent?: PointerEvent | KeyboardEvent; +} + +export interface PlacementCallbacks { + canPlace?: (event: PlacementEvent) => boolean; + onPreview?: (event: PlacementEvent) => void; + onCommit?: (event: PlacementEvent) => void; + onCancel?: (event: PlacementCancelEvent) => void; + onChange?: (snapshot: PlacementSnapshot) => void; +} + +export interface PlacementConfig { + screenToWorld: (screen: PlacementPoint) => PlacementPoint | null; + callbacks?: PlacementCallbacks; + anchor?: PlacementAnchor; +} + +/** + * Framework-agnostic placement state machine. It neither creates nodes nor + * listens to the DOM: adapters feed it pointer positions and consumers decide + * whether a surface is valid and what a committed payload means. + */ +export class PlacementController { + #config: PlacementConfig; + #snapshot: PlacementSnapshot; + + constructor(config: PlacementConfig) { + this.#config = config; + this.#snapshot = { + active: false, + payload: null, + screen: null, + world: null, + position: null, + size: null, + anchor: config.anchor ?? { x: 0.5, y: 0.5 }, + allowed: false, + }; + } + + get snapshot(): PlacementSnapshot { + return this.#snapshot; + } + + get callbacks(): PlacementCallbacks { + return this.#config.callbacks ?? {}; + } + + begin( + payload: T, + size: PlacementSize, + options: { anchor?: PlacementAnchor; screen?: PlacementPoint } = {}, + ): void { + this.#snapshot = { + active: true, + payload, + screen: null, + world: null, + position: null, + size, + anchor: options.anchor ?? this.#config.anchor ?? { x: 0.5, y: 0.5 }, + allowed: false, + }; + this.#emitChange(); + if (options.screen) this.update(options.screen); + } + + update(screen: PlacementPoint, originalEvent?: PointerEvent): boolean { + const current = this.#snapshot; + if (!current.active || current.payload == null || current.size == null) { + return false; + } + const world = this.#config.screenToWorld(screen); + if (!world) return false; + const position = { + x: world.x - current.size.width * current.anchor.x, + y: world.y - current.size.height * current.anchor.y, + }; + const base = { + ...current, + screen, + world, + position, + allowed: true, + }; + const event = this.#event(base, originalEvent); + const allowed = this.callbacks.canPlace?.(event) !== false; + this.#snapshot = { ...base, allowed }; + const finalEvent = this.#event(this.#snapshot, originalEvent); + this.callbacks.onPreview?.(finalEvent); + this.#emitChange(); + return allowed; + } + + commit(originalEvent?: PointerEvent): boolean { + const current = this.#snapshot; + if ( + !current.active || + !current.allowed || + current.payload == null || + current.screen == null || + current.world == null || + current.position == null || + current.size == null + ) { + return false; + } + const event = this.#event(current, originalEvent); + this.#snapshot = { ...current, active: false }; + this.callbacks.onCommit?.(event); + this.#emitChange(); + return true; + } + + cancel( + reason: PlacementCancelEvent["reason"] = "cancel", + originalEvent?: PointerEvent | KeyboardEvent, + ): boolean { + if (!this.#snapshot.active || this.#snapshot.payload == null) return false; + const payload = this.#snapshot.payload; + this.#snapshot = { ...this.#snapshot, active: false, allowed: false }; + this.callbacks.onCancel?.({ + controller: this, + payload, + reason, + ...(originalEvent ? { originalEvent } : {}), + }); + this.#emitChange(); + return true; + } + + #event( + snapshot: PlacementSnapshot, + originalEvent?: PointerEvent, + ): PlacementEvent { + return { + ...snapshot, + payload: snapshot.payload!, + screen: snapshot.screen!, + world: snapshot.world!, + position: snapshot.position!, + size: snapshot.size!, + ...(originalEvent ? { originalEvent } : {}), + }; + } + + #emitChange(): void { + this.callbacks.onChange?.(this.#snapshot); + } +} diff --git a/assets/snapline/core/src/query.ts b/assets/snapline/core/src/query.ts new file mode 100644 index 0000000..5101c55 --- /dev/null +++ b/assets/snapline/core/src/query.ts @@ -0,0 +1,38 @@ +import type { ConnectorComponent } from "./connector"; +import { GroupNodeComponent } from "./group"; +import type { NodeComponent } from "./node"; +import { getNodeManager, getSelectList } from "./snapline-globals"; + +type EngineLike = { + global: { + data: any; + }; +}; + +// Enumeration delegates to the per-engine NodeManager registry (components +// register in their constructors), replacing the old engine-object-table +// scans. Public signatures unchanged. + +export function getNodes(engine: EngineLike): readonly NodeComponent[] { + return getNodeManager(engine).nodes; +} + +export function getConnectors( + engine: EngineLike, +): readonly ConnectorComponent[] { + return getNodeManager(engine).connectors; +} + +export function getGroupNodes( + engine: EngineLike, +): readonly GroupNodeComponent[] { + return getNodeManager(engine).nodes.filter( + (node): node is GroupNodeComponent => node instanceof GroupNodeComponent, + ); +} + +export function getSelectedNodes( + engine: EngineLike, +): readonly NodeComponent[] { + return [...getSelectList(engine.global)]; +} diff --git a/assets/snapline/core/src/select.ts b/assets/snapline/core/src/select.ts index da51c86..b2d53a0 100644 --- a/assets/snapline/core/src/select.ts +++ b/assets/snapline/core/src/select.ts @@ -5,14 +5,62 @@ import type { pointerUpProp, } from "@snap-engine/core"; import { RectCollider, Collider } from "@snap-engine/core/collision"; -import { NodeComponent } from "./node"; +import { NodeComponent, type SelectionMode } from "./node"; +import { getSelectList, snapData } from "./snapline-globals"; + +/** World-space rectangle the framework renders as the selection box. */ +export interface SelectRect { + x: number; + y: number; + width: number; + height: number; + visible: boolean; +} + +export interface SelectStartEvent { + select: RectSelectComponent; + position: { x: number; y: number }; + originalEvent: PointerEvent; +} + +export interface SelectChangeEvent { + select: RectSelectComponent; + selection: readonly NodeComponent[]; +} + +export interface SelectCallbacks { + canStart?: (event: SelectStartEvent) => boolean; + /** Consumer-defined selection policy; SnapLine owns no modifier keys. */ + resolveSelectionMode?: (event: SelectStartEvent) => SelectionMode; + /** + * The rubber-band rectangle changed — the FRAMEWORK renders it (position, + * size, visibility, and any custom styling). Core keeps only the pointer + * math and the selection collider; it never writes the box's DOM. This is + * deliberately a plain callback with no flush handshake: the box visual is + * not paint-atomic, so the framework may flush on its own schedule. + */ + onRectChange?: (rect: SelectRect) => void; + onSelectionChange?: (event: SelectChangeEvent) => void; +} + +export interface SelectConfig { + callbacks?: SelectCallbacks; +} class RectSelectComponent extends ElementObject { _state: "none" | "dragging"; _mouseDownX: number; _mouseDownY: number; _selectHitBox: Collider; - constructor(engine: any, parent: BaseObject | null) { + #callbacks: SelectCallbacks; + #selectionMode: SelectionMode = "replace"; + #baselineSelection = new Set(); + + constructor( + engine: any, + parent: BaseObject | null, + config: SelectConfig = {}, + ) { super(engine, parent); this._state = "none"; @@ -29,57 +77,58 @@ class RectSelectComponent extends ElementObject { this.addCollider(this._selectHitBox); - this.global.data.select = []; + snapData(this.global).select = []; - this.style = { - width: "0px", - height: "0px", - transformOrigin: "top left", - position: "absolute", - left: "0px", - top: "0px", - pointerEvents: "none", - }; - this.schedule(() => this.writeDom(), { - stage: "WRITE_1", - queueId: `${this.id}-dom`, - }); + this.#callbacks = config.callbacks ?? {}; } - scheduleWrite() { - this.schedule(() => this.writeDom(), { - stage: "WRITE_1", - queueId: `${this.id}-dom`, - }); - this.schedule(() => this.writeTransform(), { - stage: "WRITE_2", - queueId: `${this.id}-transform`, + get callbacks(): SelectCallbacks { + return this.#callbacks; + } + + #fireRect(width: number, height: number, visible: boolean): void { + this.#callbacks.onRectChange?.({ + x: this.worldTransform.x, + y: this.worldTransform.y, + width, + height, + visible, }); } onGlobalCursorDown(prop: pointerDownProp): void { - if ( - prop.event.button !== 0 || - (prop.event.target && - (prop.event.target as HTMLElement).id !== "sl-background") - ) { + if (prop.event.button !== 0) { return; } - for (let node of [...this.global.data.select]) { - node.setSelected(false); + const startEvent = { + select: this, + position: prop.position, + originalEvent: prop.event, + }; + if (this.#callbacks.canStart?.(startEvent) === false) return; + this.#selectionMode = + this.#callbacks.resolveSelectionMode?.(startEvent) ?? "replace"; + this.#baselineSelection = new Set(getSelectList(this.global)); + if (this.#selectionMode === "replace") { + for (let node of [...getSelectList(this.global)]) { + node.setSelected(false); + } + snapData(this.global).select = []; } - this.global.data.select = []; + // worldTransform positions the selection collider (its transform parent); + // the visual box is framework-rendered from the callback rect. this.worldTransform = { x: prop.position.x, y: prop.position.y }; this._state = "dragging"; - this.style = { - display: "block", - width: "0px", - height: "0px", - }; this._mouseDownX = prop.position.x; this._mouseDownY = prop.position.y; - this.scheduleWrite(); + this._selectHitBox.width = 0; + this._selectHitBox.height = 0; + this.#fireRect(0, 0, true); + this.#callbacks.onSelectionChange?.({ + select: this, + selection: [...getSelectList(this.global)], + }); this._selectHitBox.event.collider.onBeginContact = ( _: Collider, @@ -87,7 +136,15 @@ class RectSelectComponent extends ElementObject { ) => { if (otherObject.parent instanceof NodeComponent) { let node = otherObject.parent as NodeComponent; - node.setSelected(true); + node.setSelected( + this.#selectionMode === "toggle" + ? !this.#baselineSelection.has(node) + : true, + ); + this.#callbacks.onSelectionChange?.({ + select: this, + selection: [...getSelectList(this.global)], + }); } }; this._selectHitBox.event.collider.onEndContact = ( @@ -96,7 +153,11 @@ class RectSelectComponent extends ElementObject { ) => { if (otherObject.parent instanceof NodeComponent) { let node = otherObject.parent as NodeComponent; - node.setSelected(false); + node.setSelected(this.#baselineSelection.has(node)); + this.#callbacks.onSelectionChange?.({ + select: this, + selection: [...getSelectList(this.global)], + }); } }; } @@ -111,27 +172,21 @@ class RectSelectComponent extends ElementObject { Math.abs(prop.position.x - this._mouseDownX), Math.abs(prop.position.y - this._mouseDownY), ]; - this.style = { - width: `${boxWidth}px`, - height: `${boxHeight}px`, - }; this.worldTransform = { x: boxOriginX, y: boxOriginY }; this._selectHitBox.localTransform = { x: 0, y: 0 }; this._selectHitBox.width = boxWidth; this._selectHitBox.height = boxHeight; - this.scheduleWrite(); + this.#fireRect(boxWidth, boxHeight, true); } } onGlobalCursorUp(_prop: pointerUpProp): void { - this.style = { - display: "none", - }; + const wasDragging = this._state === "dragging"; this._state = "none"; this._selectHitBox.event.collider.onBeginContact = null; this._selectHitBox.event.collider.onEndContact = null; - this.scheduleWrite(); + if (wasDragging) this.#fireRect(0, 0, false); } onCollideNode(_hitBox: Collider, _node: Collider): void {} diff --git a/assets/snapline/core/src/snapline-globals.ts b/assets/snapline/core/src/snapline-globals.ts new file mode 100644 index 0000000..9eec984 --- /dev/null +++ b/assets/snapline/core/src/snapline-globals.ts @@ -0,0 +1,117 @@ +import type { RectCollider } from "@snap-engine/core/collision"; +import type { eventPosition } from "@snap-engine/core"; +import type { NodeComponent } from "./node"; +import { NodeManager } from "./node-manager"; + +/** + * Structural stand-in for GroupNodeComponent so node.ts can notify groups on + * settle without importing the group module (no group→node import cycle). + */ +export interface GroupLike { + refreshMembership(fireDelta: boolean): void; +} + +/** + * Structural source-surface contract shared with engine input. Keeping this + * shape here avoids an engine-core -> SnapLine dependency while still allowing + * a headless connector to own pointer input outside its parent's DOM bounds. + */ +export interface SourceSurfaceOwner { + id: string; + engine: unknown; + isDeleteRequested: boolean; + resolveSourceHit(position: eventPosition): { + candidate: { + hit: { + distance: number; + priority?: number; + }; + }; + strategyIndex: number; + } | null; +} + +/** + * The shape of everything SnapLine stores on the engine's shared `global.data` + * bag. This is the single declaration site for these cross-module contracts — + * every reader/writer goes through the typed accessors below instead of + * re-deriving the shape inline. + * + * NOTE for engine core: `input.ts#resolveResizeOwner` reads `resizeHandles` + * and `input.ts#resolveSourceSurfaceOwner` reads `sourceSurfaces` duck-typed + * (engine core cannot import snapline); keep their structural types in sync + * with this declaration. + */ +export interface SnapLineSharedData { + /** Currently-selected nodes (multi-select drag moves all of them). */ + select?: NodeComponent[]; + /** All live groups; notified on any node's drop so membership stays settled. */ + groups?: GroupLike[]; + /** Registered resize hitboxes; input.ts routes pointerdowns over them. */ + resizeHandles?: RectCollider[]; + /** Registered headless source surfaces; input.ts routes pointerdowns to them. */ + sourceSurfaces?: SourceSurfaceOwner[]; + /** The node mid-resize, so an unrelated pointerUp doesn't click-select. */ + resizingNode?: NodeComponent | null; + /** + * @deprecated Legacy camera-control boolean (last-writer-wins), read by the + * camera for third-party writers only. In-repo gesture owners block the + * camera at the input-dispatch layer instead: `engine.input.claimPointer()` + * (claims auto-release when the gesture ends). + */ + allowCameraControl?: boolean; + /** + * Per-engine SnapLine registries. GlobalManager is application-wide, so the + * map is keyed by engine; `getNodeManager` lazy-creates entries the first + * time a SnapLine component registers on that engine. + */ + nodeManagers?: Map; +} + +/** Typed view over the untyped global data bag (cast at the boundary). */ +export function snapData(global: { data: any }): SnapLineSharedData { + return global.data as SnapLineSharedData; +} + +export function getSelectList(global: { data: any }): NodeComponent[] { + const data = snapData(global); + if (!data.select) data.select = []; + return data.select; +} + +export function getGroups(global: { data: any }): GroupLike[] { + const data = snapData(global); + if (!data.groups) data.groups = []; + return data.groups; +} + +export function getResizeHandles(global: { data: any }): RectCollider[] { + const data = snapData(global); + if (!data.resizeHandles) data.resizeHandles = []; + return data.resizeHandles; +} + +export function getSourceSurfaces(global: { + data: any; +}): SourceSurfaceOwner[] { + const data = snapData(global); + if (!data.sourceSurfaces) data.sourceSurfaces = []; + return data.sourceSurfaces; +} + + +export function getNodeManager(engine: { + global: { data: any } | null; +}): NodeManager { + if (!engine.global) { + throw new Error("SnapLine: getNodeManager requires an initialized engine."); + } + const data = snapData(engine.global); + if (!data.nodeManagers) data.nodeManagers = new Map(); + let manager = data.nodeManagers.get(engine); + if (!manager) { + manager = new NodeManager(engine); + data.nodeManagers.set(engine, manager); + } + return manager; +} diff --git a/assets/snapline/react/README.md b/assets/snapline/react/README.md new file mode 100644 index 0000000..fab06f7 --- /dev/null +++ b/assets/snapline/react/README.md @@ -0,0 +1,48 @@ +# @snap-engine/snapline-react + +React 18/19 adapters for SnapLine node graph primitives. + +## Install + +```bash +npm install react react-dom @snap-engine/core \ + @snap-engine/snapline @snap-engine/snapline-react +``` + +## Components + +The package exports `Engine`, `Node`, `Group`, `Connector`, `Line`, `Select`, +and `Placement`. Each component is also available from a named subpath. + +```tsx +import { Engine, Group, Node, Select } from "@snap-engine/snapline-react"; + +export function Graph() { + return ( + + + + Process + +``` + +Geometry props resynchronize after mount while active gestures update locally. +Consumer callbacks compose with the adapter's rendering callbacks. +Pass framework-native ARIA attributes or DOM event handlers to the outer node +element through `Node`'s `elementProps`. + +`` creates a logical connector without a visible port. +Combine it with `surfaceStrategies` and independent `capabilities` to make a +node border or another application-defined shape act as the connection surface. +Application graph state remains authoritative; an opaque line payload can link +a custom renderer back to the corresponding domain edge. + +Connector policy, metadata, callbacks, strategies, and `virtual` are reactive. +Switching `virtual` detaches or remounts only the visible port; the logical +connector and its existing lines remain intact. + +Full documentation: https://snapengine.dev/docs/snapline/introduction diff --git a/assets/snapline/svelte/package.json b/assets/snapline/svelte/package.json index 6c292c5..85251c9 100644 --- a/assets/snapline/svelte/package.json +++ b/assets/snapline/svelte/package.json @@ -1,7 +1,6 @@ { "name": "@snap-engine/snapline-svelte", - "private": true, - "version": "0.2.0", + "version": "0.3.0", "repository": { "type": "git", "url": "git+https://github.com/tfukaza/SnapEngineJS.git", @@ -10,14 +9,18 @@ "description": "Svelte components for node graph UI", "type": "module", "svelte": "./src/index.ts", + "types": "./src/index.ts", "exports": { ".": { + "types": "./src/index.ts", "svelte": "./src/index.ts" }, "./Node.svelte": "./src/Node.svelte", + "./Group.svelte": "./src/Group.svelte", "./Connector.svelte": "./src/Connector.svelte", "./Line.svelte": "./src/Line.svelte", - "./Select.svelte": "./src/Select.svelte" + "./Select.svelte": "./src/Select.svelte", + "./Placement.svelte": "./src/Placement.svelte" }, "keywords": [ "node-graph", @@ -29,7 +32,7 @@ "license": "MIT", "dependencies": { "@snap-engine/core": "^0.4.1", - "@snap-engine/snapline": "^0.2.0" + "@snap-engine/snapline": "^0.3.0" }, "files": [ "src", diff --git a/assets/snapline/svelte/src/Connector.svelte b/assets/snapline/svelte/src/Connector.svelte index b5fcfd4..078dcaa 100644 --- a/assets/snapline/svelte/src/Connector.svelte +++ b/assets/snapline/svelte/src/Connector.svelte @@ -2,50 +2,105 @@ import { NodeComponent, ConnectorComponent, + LineComponent, + type ConnectorCapabilities, + type ConnectorCallbacks, + type ConnectorSurfaceStrategy, + type SnapLineMetadata, } from "@snap-engine/snapline"; import type { Engine } from "@snap-engine/core"; - import { getContext, onDestroy, onMount } from "svelte"; + import { getContext, onDestroy } from "svelte"; let { name, maxConnectors = 1, allowDragOut = true, + metadata = {}, + callbacks = {}, + edgePan = true, + capabilities = undefined, + surfaceStrategies = [], + virtual = false, + colliderRadius = undefined, + lineClass = undefined, + connectorObject = null, + data = {}, }: { name: string; maxConnectors?: number; allowDragOut?: boolean; + metadata?: SnapLineMetadata; + callbacks?: ConnectorCallbacks; + edgePan?: boolean; + capabilities?: Partial; + surfaceStrategies?: readonly ConnectorSurfaceStrategy[]; + /** Keep the logical connector without rendering a visible port element. */ + virtual?: boolean; + colliderRadius?: number; + lineClass?: typeof LineComponent; + connectorObject?: ConnectorComponent | null; + data?: Record; } = $props(); let engine: Engine = getContext("engine"); let nodeObject: NodeComponent = getContext("nodeObject"); - let connector = new ConnectorComponent(engine, nodeObject, { + const ownsConnector = connectorObject == null; + let connector = connectorObject ?? new ConnectorComponent(engine, nodeObject, { name: name, maxConnectors: maxConnectors, allowDragOut: allowDragOut, + metadata, + callbacks, + edgePan, + capabilities, + surfaceStrategies, + colliderRadius, + lineClass, }); nodeObject.addConnectorObject(connector); - let connectorDOM: HTMLDivElement | null = null; export function object(): ConnectorComponent { return connector; } - onMount(() => { - connector.element = connectorDOM as HTMLElement; + function bindConnectorElement(element: HTMLDivElement) { + connector.bindElement(element); + return { + destroy() { + connector.bindElement(null); + }, + }; + } + + $effect(() => { + connector.updateConfig({ + maxConnectors, + allowDragOut, + metadata, + callbacks, + edgePan, + capabilities, + surfaceStrategies, + colliderRadius, + lineClass, + }); }); onDestroy(() => { - connector.destroy(); + if (ownsConnector) connector.destroy(); }); -
+{#if !virtual} +
[`data-${key}`, value]))} + class={`connector ${(capabilities?.source ?? allowDragOut) ? "right" : "left"}`} + >
+{/if} diff --git a/assets/snapline/svelte/src/Line.svelte b/assets/snapline/svelte/src/Line.svelte index e41f116..0a80944 100644 --- a/assets/snapline/svelte/src/Line.svelte +++ b/assets/snapline/svelte/src/Line.svelte @@ -1,7 +1,21 @@ {#each lineList as line (line.id)} {/each} -
+
{@render children()} + {#each nodeObject.resizeHandles as handle} +
+ {/each}
diff --git a/assets/snapline/svelte/src/Placement.svelte b/assets/snapline/svelte/src/Placement.svelte new file mode 100644 index 0000000..9ad9ab8 --- /dev/null +++ b/assets/snapline/svelte/src/Placement.svelte @@ -0,0 +1,83 @@ + + + + +{#if snapshot.active && preview} + {@render preview(snapshot)} +{/if} diff --git a/assets/snapline/svelte/src/Select.svelte b/assets/snapline/svelte/src/Select.svelte index f7cb3ec..0b0abce 100644 --- a/assets/snapline/svelte/src/Select.svelte +++ b/assets/snapline/svelte/src/Select.svelte @@ -1,24 +1,39 @@ - -
+
diff --git a/assets/snapline/svelte/src/index.ts b/assets/snapline/svelte/src/index.ts index 9de7741..d7cbe9a 100644 --- a/assets/snapline/svelte/src/index.ts +++ b/assets/snapline/svelte/src/index.ts @@ -1,4 +1,7 @@ export { default as Node } from "./Node.svelte"; +export { default as Group } from "./Group.svelte"; export { default as Connector } from "./Connector.svelte"; export { default as Line } from "./Line.svelte"; export { default as Select } from "./Select.svelte"; +export { default as Placement } from "./Placement.svelte"; +export { default as EdgeSync } from "./EdgeSync.svelte"; diff --git a/assets/snapsort/AGENTS.md b/assets/snapsort/AGENTS.md index 759b818..e7d39e2 100644 --- a/assets/snapsort/AGENTS.md +++ b/assets/snapsort/AGENTS.md @@ -11,7 +11,7 @@ A single `Container`/`Item` class pair (per framework) whose drag/drop behavior - `Container` - The only container class. `new Container(engine, parent, { mode, ... })`. - `Item` - The only item class (including ghosts/markers). Never needs a mode. - `DragSession` - Owns all per-drag state (pointer, ghost, drop target); lives at `container.dragSession` on the root while a drag is active. -- Event types: `ItemInsertEvent`, `ItemRemoveEvent`, `ItemMoveEvent`, `ItemSwapEvent`, `GhostCreateEvent`, `GhostInsertEvent`, `GhostRemoveEvent`, `DragStartEvent`, `DragEndEvent`, `DropTargetChangeEvent`, `CanDropEvent`, `DragLocation`. +- Event types: `ItemInsertEvent`, `ItemRemoveEvent`, `ItemMoveEvent`, `ItemSwapEvent`, `GhostCreateEvent`, `GhostInsertEvent`, `GhostRemoveEvent`, `DragStartEvent`, `DragEndEvent`, `DropTargetChangeEvent`, `CanDropEvent`, `VisualGeometryInvalidationEvent`, `DragLocation`. - `ContainerCallbacks`, `ContainerConfig`, `SortMode`, `SortStrategy`, `DropTargetStrategy`, `DragLifecycleStrategy`. ### @snap-engine/snapsort-svelte @@ -93,6 +93,10 @@ Every `ContainerCallbacks` invocation goes through one of the `fire*` functions - Semantic: `onItemMove` (preferred — carries `from`/`to` `DragLocation`s). - Lifecycle: `onDragStart` (return `false` to veto before any state changes), `onDragEnd`, `onDropTargetChange` (fires only when the prospective container/index actually changes). - Validation: `canDrop` — consulted once per container per drop-target resolution (not per candidate slot); must be cheap. +- Integration: `onVisualGeometryInvalidated` — one root-coalesced notification + when drag, ghost, or FLIP transforms may have changed rendered item geometry. + Consumers use it to invalidate dependent visuals without SnapSort knowing + what those visuals are. ### Framework adapter ownership diff --git a/assets/snapsort/core/README.md b/assets/snapsort/core/README.md index 603566d..a7bb83a 100644 --- a/assets/snapsort/core/README.md +++ b/assets/snapsort/core/README.md @@ -17,7 +17,7 @@ npm install @snap-engine/snapsort @snap-engine/core - `Container` - `Item` - `DragSession` -- Event types: `ItemInsertEvent`, `ItemRemoveEvent`, `ItemMoveEvent`, `ItemSwapEvent`, `GhostCreateEvent`, `GhostInsertEvent`, `GhostRemoveEvent`, `DragStartEvent`, `DragEndEvent`, `DropTargetChangeEvent`, `CanDropEvent`, `DragLocation` +- Event types: `ItemInsertEvent`, `ItemRemoveEvent`, `ItemMoveEvent`, `ItemSwapEvent`, `GhostCreateEvent`, `GhostInsertEvent`, `GhostRemoveEvent`, `DragStartEvent`, `DragEndEvent`, `DropTargetChangeEvent`, `CanDropEvent`, `VisualGeometryInvalidationEvent`, `DragLocation` - `ContainerCallbacks`, `ContainerConfig`, `SortMode`, `SortStrategy` ## Usage @@ -56,6 +56,11 @@ never fight over the same DOM nodes. The contract: `awaitMutation` remains deprecated for compatibility. Promise-returning mutation waits are not paint-atomic and are not supported by the FLIP path. +`onVisualGeometryInvalidated` is the low-level seam for visuals owned by other +systems. SnapSort coalesces drag, ghost, and FLIP changes at the root container +and reports the affected items during the next engine read phase. The callback +does not refresh any dependent UI itself; consumers decide what to invalidate. + A concrete consequence: **the dragged element stays in its original DOM parent for the entire drag.** It never gets reparented into whichever container is currently hovered — it's moved only by `transform`, computed diff --git a/assets/snapsort/core/src/container.ts b/assets/snapsort/core/src/container.ts index 3739e0e..774615e 100644 --- a/assets/snapsort/core/src/container.ts +++ b/assets/snapsort/core/src/container.ts @@ -1,5 +1,8 @@ import { Item } from "./item"; -import type { ContainerCallbacks } from "./events"; +import type { + ContainerCallbacks, + VisualGeometryInvalidationReason, +} from "./events"; import { defaultCallbacks } from "./mutation"; import type { LayoutMainAxisAlign } from "./layout"; import type { LayoutWrap } from "./snapshot"; @@ -61,6 +64,8 @@ export class Container extends Item { #config: ContainerConfig; #depth: number = 0; #itemList: Item[] = []; + #visualInvalidationItems = new Set(); + #visualInvalidationReasons = new Set(); /** The in-progress drag session for this tree, or null when nothing is being dragged. Only meaningful on the root container. */ dragSession: DragSession | null = null; @@ -168,6 +173,55 @@ export class Container extends Item { return this.#config.callbacks; } + /** + * Queue one coalesced notification that rendered item geometry may have + * changed. This is a low-level integration seam, not a DOM mutation hook. + * @internal + */ + invalidateVisualGeometry( + items: Iterable, + reason: VisualGeometryInvalidationReason, + ): void { + const root = this.rootContainer; + if (root !== this) { + root.invalidateVisualGeometry(items, reason); + return; + } + + for (const item of items) { + if (!item.isGhost) this.#visualInvalidationItems.add(item); + } + this.#visualInvalidationReasons.add(reason); + // Always publish in the next read phase. Most invalidations originate in + // WRITE_3; enqueueing another WRITE_3 task from inside that stage can + // replace a task whose slot was already visited and lose the final visual + // position. READ_1 also gives integrations a safe point to enqueue their + // own geometry reads for the same frame. + this.schedule( + () => { + if ( + this.#visualInvalidationItems.size === 0 || + this.#visualInvalidationReasons.size === 0 + ) { + return; + } + const event = { + root: this, + session: this.dragSession, + items: [...this.#visualInvalidationItems], + reasons: [...this.#visualInvalidationReasons], + }; + this.#visualInvalidationItems.clear(); + this.#visualInvalidationReasons.clear(); + this.callbacks?.onVisualGeometryInvalidated?.(event); + }, + { + stage: "READ_1", + queueId: `${this.id}-visual-geometry-invalidated`, + }, + ); + } + get itemList() { return this.#itemList; } diff --git a/assets/snapsort/core/src/drag/session.ts b/assets/snapsort/core/src/drag/session.ts index aabe8c9..2e40b2a 100644 --- a/assets/snapsort/core/src/drag/session.ts +++ b/assets/snapsort/core/src/drag/session.ts @@ -490,6 +490,12 @@ export class DragSession { if (previousGhostLocation || this.dropTarget) { this.dropTarget = null; await lifecycle.removeGhost(this, "target"); + this.#invalidateVisualGeometry( + previousGhostLocation + ? [previousGhostLocation.container] + : [], + "ghost", + ); this.fireDropTargetChange(previousGhostLocation, null); } this.updateHoveredItem(null); @@ -517,6 +523,10 @@ export class DragSession { targetIndex, target.ghostRect, ); + this.#invalidateVisualGeometry( + [ghostSource.container, targetContainer], + "ghost", + ); this.fireDropTargetChange(ghostSource, { container: targetContainer, index: targetIndex, @@ -530,6 +540,7 @@ export class DragSession { targetIndex, target.ghostRect, ); + this.#invalidateVisualGeometry([targetContainer], "ghost"); this.fireDropTargetChange(null, { container: targetContainer, index: targetIndex, @@ -538,6 +549,19 @@ export class DragSession { } } + #invalidateVisualGeometry( + containers: readonly Container[], + reason: "ghost", + ): void { + const items = new Set(this.items); + for (const container of containers) { + for (const item of container.itemOrderedList) { + if (!item.isGhost) items.add(item); + } + } + this.root.invalidateVisualGeometry(items, reason); + } + private fireDropTargetChange( previous: { container: Container; index: number } | null, current: { container: Container; index: number } | null, diff --git a/assets/snapsort/core/src/events.ts b/assets/snapsort/core/src/events.ts index b3bb5d5..b95073a 100644 --- a/assets/snapsort/core/src/events.ts +++ b/assets/snapsort/core/src/events.ts @@ -45,6 +45,24 @@ export type GhostRole = "target" | "source" | "pointer"; */ export type MutationPhase = "preview" | "commit"; +export type VisualGeometryInvalidationReason = + | "drag" + | "ghost" + | "animation" + | "settle"; + +/** + * Coalesced notification that SnapSort changed transient visual geometry. + * Consumers can use this to invalidate geometry owned by another system + * without SnapSort knowing what that system is. + */ +export interface VisualGeometryInvalidationEvent { + root: Container; + session: DragSession | null; + items: readonly Item[]; + reasons: readonly VisualGeometryInvalidationReason[]; +} + export interface ItemRemoveEvent { session: DragSession | null; item: Item; @@ -366,6 +384,15 @@ export interface ContainerCallbacks { /** Fired on the container owning `overItem` when the pointer's hitbox stops matching it. */ onDragItemLeave?: (event: DragItemHoverEvent) => void; + /** + * Fired on the root container at most once per engine frame when transient + * item geometry may have changed. Notification only; consumers decide what + * external geometry, if any, to invalidate. + */ + onVisualGeometryInvalidated?: ( + event: VisualGeometryInvalidationEvent, + ) => void; + /** Consulted while resolving candidates for `container`; return false to reject it for this drag. */ canDrop?: (event: CanDropEvent) => boolean; diff --git a/assets/snapsort/core/src/index.ts b/assets/snapsort/core/src/index.ts index 7ffed01..6a4e207 100644 --- a/assets/snapsort/core/src/index.ts +++ b/assets/snapsort/core/src/index.ts @@ -29,6 +29,8 @@ export type { DropTargetChangeEvent, DragItemHoverEvent, CanDropEvent, + VisualGeometryInvalidationEvent, + VisualGeometryInvalidationReason, } from "./events"; export type { LayoutMainAxisAlign } from "./layout"; export { Item } from "./item"; diff --git a/assets/snapsort/core/src/item.ts b/assets/snapsort/core/src/item.ts index 72c2b38..c0e3122 100644 --- a/assets/snapsort/core/src/item.ts +++ b/assets/snapsort/core/src/item.ts @@ -18,6 +18,7 @@ import type { GhostKind, GhostRect, GhostRole, + VisualGeometryInvalidationReason, } from "./events"; import { assertCanFireGhostInsert, @@ -357,6 +358,24 @@ export class Item extends ElementObject { return this.#rootContainer ?? (this as unknown as Container); } + /** + * Base implementation for items outside any container tree (for example a + * ghost anchor mid-move, whose `rootContainer` getter falls back to the + * item itself). Forwards to the owning container when one exists; there is + * no consumer to notify otherwise. `Container` overrides this with the + * accumulating implementation. + * @internal + */ + invalidateVisualGeometry( + items: Iterable, + reason: VisualGeometryInvalidationReason, + ): void { + const root = this.#rootContainer; + if (root && root !== (this as unknown as Container)) { + root.invalidateVisualGeometry(items, reason); + } + } + set rootContainer(value: Container | null) { this.#rootContainer = value; } @@ -1105,6 +1124,7 @@ export class Item extends ElementObject { finish: () => { item.#clearVisualAnimationOffset(); targetElement.style.transform = ""; + item.rootContainer.invalidateVisualGeometry([item], "settle"); }, }, ); @@ -1159,6 +1179,7 @@ export class Item extends ElementObject { ) { this.#setVisualAnimationOffset(x, y); targetElement.style.transform = this.#translateTransform(x, y); + this.rootContainer.invalidateVisualGeometry([this], "animation"); } /** @@ -1340,6 +1361,7 @@ export class Item extends ElementObject { if (!layoutPosition) { if (parentItem) return; this.writeTransform(); + this.rootContainer.invalidateVisualGeometry([this], "drag"); return; } @@ -1369,6 +1391,7 @@ export class Item extends ElementObject { groupOffset.y; this.writeTransform(); + this.rootContainer.invalidateVisualGeometry([this], "drag"); } /** diff --git a/css/snapdesign.scss b/css/snapdesign.scss index ca32200..855fa33 100644 --- a/css/snapdesign.scss +++ b/css/snapdesign.scss @@ -13,17 +13,41 @@ body { padding: 0; background-color: #ffffff; color: var(--color-text); - font-family: "Geist", sans-serif; + font-family: var(--font-body); } :root { --color-primary: #ff5d0f; + --color-action: #c94000; + --color-action-hover: #a93600; --color-secondary-1: #f34336; --color-accent: #ff0e56; --color-background: #ffffff; --color-background-tint: #f6f7f7; --color-background-dark: #7f8286; --color-text: #333637; + --color-text-muted: #5d6266; + --color-text-subtle: #697074; + + --font-body: "Geist", sans-serif; + --font-display: "Geist Pixel Circle", sans-serif; + --font-label: "Bitcount Grid Single", monospace; + --font-code: "Geist Mono", monospace; + + --type-page-title: clamp(2.75rem, 6vw, 5rem); + --type-section-title: clamp(2rem, 3.5vw, 3.25rem); + --type-card-title: clamp(1.5rem, 2.5vw, 2.25rem); + --type-lead: clamp(1.125rem, 1.5vw, 1.25rem); + --type-body: 1rem; + --type-caption: 0.875rem; + --type-label: 0.875rem; + + --leading-display: 1; + --leading-heading: 1.1; + --leading-card: 1.15; + --leading-body: 1.6; + --leading-caption: 1.4; + --leading-label: 1.4; --size-256: 256px; --size-128: 128px; @@ -33,6 +57,7 @@ body { --size-48: 48px; --size-32: 32px; --size-24: 24px; + --size-20: 20px; --size-16: 16px; --size-12: 12px; --size-8: 8px; @@ -61,60 +86,62 @@ h6 { margin: 0; font-weight: 500; color: #373738; + text-wrap: balance; } h1 { - font-family: "Geist Pixel Circle", sans-serif; - font-size: 72px; + font-family: var(--font-display); + font-size: var(--type-page-title); + line-height: var(--leading-display); + letter-spacing: -0.025em; +} + +h2 { + margin-bottom: var(--size-16); + font-family: var(--font-display); + font-size: var(--type-section-title); + line-height: var(--leading-heading); } -h2, h3, h4, h5, h6 { - font-family: "Geist", sans-serif; -} - -h2 { - font-size: 36px; - margin-bottom: var(--size-16); + font-family: var(--font-body); + line-height: var(--leading-card); } h3 { - font-size: 24px; + font-size: var(--type-card-title); margin-bottom: var(--size-12); } h4 { - font-size: 20px; + font-size: 1.25rem; margin-bottom: var(--size-8); } h5 { - font-size: 18px; + font-size: 1.125rem; margin-bottom: var(--size-8); } h6 { - font-size: 16px; + font-size: 1rem; margin-bottom: var(--size-8); } p, -a, blockquote, -span, -label, ul, ol { - font-family: "Geist", sans-serif; - font-size: 1rem; - font-weight: 300; + font-family: var(--font-body); + font-size: var(--type-body); + font-weight: 400; color: var(--color-text); margin: 0; margin-bottom: 0.1em; - line-height: 1.5; + line-height: var(--leading-body); &.light { color: var(--color-background-tint); @@ -129,11 +156,11 @@ p.large { pre, code { - font-family: "Geist Mono", monospace; + font-family: var(--font-code); font-size: 0.8rem; > span, > span > span { - font-family: "Geist Mono", monospace; + font-family: var(--font-code); font-size: 0.8rem; } } @@ -151,7 +178,7 @@ article { } a { - color: var(--color-primary); + color: var(--color-action); text-decoration: underline; text-underline-offset: 0.18em; text-decoration-thickness: 1px; @@ -502,6 +529,11 @@ label:has(input[type="radio"]) { content: ""; } } + + input:focus-visible + span { + outline: 3px solid color-mix(in srgb, var(--color-action) 35%, transparent); + outline-offset: 3px; + } } label { @@ -575,7 +607,7 @@ label:has(input[type="checkbox"]) { display: flex; align-items: center; justify-content: center; - font-family: "IBM Plex Mono", monospace; + font-family: var(--font-code); font-size: 11px; font-weight: 600; letter-spacing: -0.05em; @@ -827,7 +859,6 @@ input[type="range"].large { } } -button, .button { --button-color: #fff; --button-highlight-color: hsl( @@ -841,11 +872,20 @@ button, cursor: pointer; font-family: inherit; font-size: 1rem; + font-weight: 500; + line-height: 1.25; position: relative; border-radius: var(--ui-radius); border: none; - padding: var(--size-8) var(--size-12); + min-height: 44px; + padding: 10px var(--size-16); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--size-8); + box-sizing: border-box; + text-decoration: none; background: var(--button-color); box-shadow: @@ -856,22 +896,15 @@ button, 1.5px 1.5px 3px -2px rgba(13, 34, 68, 0.538), 3px 3px 6px -3px rgba(6, 29, 57, 0.435); - transition: all 0.1s ease-in-out; + transition: + color 100ms ease-in-out, + box-shadow 100ms ease-in-out, + transform 100ms ease-in-out; &:active:not(:disabled), &.active:not(:disabled), &.is-active:not(:disabled) { - color: var(--color-primary) !important; - text-shadow: - 0 0 1px var(--color-primary), - 0 0 4px rgba(255, 117, 58, 0.38); - - * { - color: var(--color-primary) !important; - text-shadow: - 0 0 1px var(--color-primary), - 0 0 4px rgba(255, 117, 58, 0.38); - } + transform: translateY(1px); } // &::before { @@ -930,8 +963,7 @@ button, } &:hover:not(:disabled):not(.active) { - color: var(--color-primary); - text-shadow: 0 0 3px rgba(255, 117, 58, 0.26); + color: var(--color-action); box-shadow: 0px 0px 1px 0.5px rgba(1, 11, 38, 0.157) inset, 1px 1px 1px 0.5px hsl(from var(--button-highlight-color) h s l / 0.7) @@ -987,7 +1019,6 @@ button, } } -button, .button { &.primary { --button-color: var(--color-primary); @@ -995,7 +1026,6 @@ button, &:hover:not(:disabled):not(.active) { color: #fff; - text-shadow: 0 0 6px rgba(255, 255, 255, 1); } &:disabled { @@ -1004,15 +1034,45 @@ button, } &.small { - padding: var(--size-2) var(--size-8); - font-size: 1rem; - line-height: 0.8; + min-height: 36px; + padding: 6px var(--size-12); + font-size: 0.875rem; + line-height: 1.25; + } + + &.secondary { + --button-color: var(--color-background); + } + + &.quiet { + --button-color: transparent; + box-shadow: none; } + + &:disabled { + --button-color: rgb(31 30 41 / 8%); + + background: var(--button-color); + box-shadow: none; + color: rgb(31 30 41 / 38%); + cursor: not-allowed; + pointer-events: none; + } + + &:focus-visible { + outline: 2px solid var(--color-action); + outline-offset: 3px; + } +} + +:where(a, button, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--color-action); + outline-offset: 3px; } input[type="number"], input[type="text"] { - font-family: "IBM Plex Mono", monospace; + font-family: var(--font-code); font-size: 1rem; user-select: none; border: none; @@ -1041,7 +1101,7 @@ input[type="text"] { input[type="date"], input[type="datetime-local"], input[type="time"] { - font-family: "IBM Plex Mono", monospace; + font-family: var(--font-code); font-size: 1rem; user-select: none; border: none; @@ -1274,7 +1334,7 @@ table { border-radius: var(--size-4); font-size: 11px; font-weight: 600; - font-family: "IBM Plex Mono", monospace; + font-family: var(--font-code); background-color: var(--color-background-tint); color: var(--color-text); diff --git a/demo/react/src/App.jsx b/demo/react/src/App.jsx index af7de23..41a5f35 100644 --- a/demo/react/src/App.jsx +++ b/demo/react/src/App.jsx @@ -1,8 +1,11 @@ import { Connector, + EdgeSync, + Group, Node, Select, } from "@snap-engine/snapline-react"; +import { useState } from "react"; import { Engine as SnapEngine } from "@snap-engine/asset-base-react"; import { DropSnapNestedDemo, @@ -14,6 +17,88 @@ import { import "../demo.css"; import AssetBaseReactDemo from "./AssetBaseReactDemo"; +function ResizableNode({ title, x, y }) { + return ( + +
+

{title}

+
+
+
+
+ +
+ Input +
+
+ Output +
+ +
+
+
+
+ ); +} + +function SnapLineResizeDemo() { + return ( +
+ +
+
+ + +
+ +
+ ); +} + +function SnapLineGroupDemo() { + const updateMembers = ({ added, removed }) => { + for (const node of added) { + node.element?.setAttribute("data-member", "true"); + } + for (const node of removed) { + node.element?.removeAttribute("data-member"); + } + }; + return ( +
+ +
+
+ + + + +
+ +
+ ); +} + function SimpleNode({ title, x, y }) { return ( @@ -82,9 +167,132 @@ export default function App() { return ; } + if (path === "/snapline-resize" || demo === "snapline_resize") { + return ; + } + + if (path === "/snapline-group" || demo === "snapline_group") { + return ; + } + + if (path === "/snapline-edges" || demo === "snapline_edges") { + return ; + } + return ; } +function EdgeSyncNode({ nodeId, title, x, y, maxIncoming = 1 }) { + return ( + +
+

{title}

+
+
+
+
+ +
+ Input +
+
+ Output +
+ +
+
+
+
+ ); +} + +function SnapLineEdgesDemo() { + const [edges, setEdges] = useState([]); + const [connectIntents, setConnectIntents] = useState(0); + const [intentLog, setIntentLog] = useState([]); + + const sameEdge = (a, b) => + a.from.node === b.from.node && + a.from.port === b.from.port && + a.to.node === b.to.node && + a.to.port === b.to.port; + + const addEdge = (edge) => + setEdges((current) => + current.some((existing) => sameEdge(existing, edge)) + ? current + : [ + ...current.filter( + (existing) => + !(existing.to.node === edge.to.node && existing.to.port === edge.to.port), + ), + edge, + ], + ); + + const identity = (connector) => { + const metadata = connector.metadata; + return typeof metadata.node === "string" && typeof metadata.port === "string" + ? { node: metadata.node, port: metadata.port } + : null; + }; + + return ( +
+
+ + {connectIntents} + {edges.length} + {intentLog.join("|")} +
+ +
+
+ - +
diff --git a/demo/svelte/src/demo/node_ui_demo/SimpleNode.svelte b/demo/svelte/src/demo/node_ui_demo/SimpleNode.svelte index 2009fe5..250c9dd 100644 --- a/demo/svelte/src/demo/node_ui_demo/SimpleNode.svelte +++ b/demo/svelte/src/demo/node_ui_demo/SimpleNode.svelte @@ -1,7 +1,7 @@ @@ -12,7 +12,7 @@
- +
Input
diff --git a/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte b/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte new file mode 100644 index 0000000..3a99267 --- /dev/null +++ b/demo/svelte/src/demo/node_ui_edges/EdgeNode.svelte @@ -0,0 +1,70 @@ + + + +
+

{title}

+
+
+
+
+ +
+ Input +
+
+ Output +
+ +
+
+
+
+ + diff --git a/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte b/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte new file mode 100644 index 0000000..4c35bdf --- /dev/null +++ b/demo/svelte/src/demo/node_ui_edges/NodeUIEdgesDemo.svelte @@ -0,0 +1,136 @@ + + +
+ + + + + + {connectIntents} + {disconnectIntents} + {edges.length} + {intentLog.join("|")} +
+ + +
+
+ + + Source + + + + Result + + + +``` + +```tsx framework=react +import { Connector, Engine, Node, Select } from "@snap-engine/snapline-react"; + +export function Graph() { + return ( + + + {#each options as option} + + {/each} + +
+ + diff --git a/website/src/lib/components/SeoHead.svelte b/website/src/lib/components/SeoHead.svelte index cd32fae..4bdaccc 100644 --- a/website/src/lib/components/SeoHead.svelte +++ b/website/src/lib/components/SeoHead.svelte @@ -16,7 +16,7 @@ description = defaultDescription, path = "/", image = defaultImage, - imageAlt = "SnapEngineJS website preview", + imageAlt = "SnapEngine website preview", type = "website", }: { title?: string; diff --git a/website/src/lib/components/docs/SnapLineDemo.svelte b/website/src/lib/components/docs/SnapLineDemo.svelte new file mode 100644 index 0000000..bc97585 --- /dev/null +++ b/website/src/lib/components/docs/SnapLineDemo.svelte @@ -0,0 +1,279 @@ + + +
+ +
+ {#if mode === "placement"} + + {/if} + +
+ {#if mode === "connections"} + + Source + Drag the port +
+
+ + Result + Drop it here +
+ +
+
+ {:else if mode === "selection"} +