From ca5b6524e4129bbd177421b8271838b6aa68aa33 Mon Sep 17 00:00:00 2001 From: MoonLyss Date: Fri, 23 Aug 2024 14:25:55 +0200 Subject: [PATCH] Add resizable --- package.json | 2 +- src/lib/actions/index.ts | 1 + src/lib/actions/resizable.ts | 197 ++++++++++++++++++++++++++++++ src/lib/utils/html.svelte.ts | 40 ++++++ src/routes/+layout.svelte | 2 +- src/routes/resizable/+page.svelte | 31 +++++ 6 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/lib/actions/resizable.ts create mode 100644 src/routes/resizable/+page.svelte diff --git a/package.json b/package.json index a2b2c7d..46d4fb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@selenite/commons", - "version": "0.21.2", + "version": "0.22.0", "repository": "github:ShaitanLyss/selenite-commons", "license": "MIT", "keywords": [ diff --git a/src/lib/actions/index.ts b/src/lib/actions/index.ts index 6b47d35..75fa328 100644 --- a/src/lib/actions/index.ts +++ b/src/lib/actions/index.ts @@ -15,6 +15,7 @@ export * as Click from './click'; export * from './click'; export * as Keyboard from './keyboard'; export * from './keyboard'; +export * from './resizable'; let handleFocusLeaveRefCount = 0; let handleFocusLeaveCallbacks: ((isKeyboard: boolean) => void)[] = []; function handleKeydown(e: KeyboardEvent) { diff --git a/src/lib/actions/resizable.ts b/src/lib/actions/resizable.ts new file mode 100644 index 0000000..f813d25 --- /dev/null +++ b/src/lib/actions/resizable.ts @@ -0,0 +1,197 @@ +import { Vector2D } from '$lib/datastructure'; +import { PointerDownWatcher, posFromClient } from '$lib/utils'; +import type { Action, ActionReturn } from 'svelte/action'; + +const resizeHandleMap = new Map< + HTMLElement, + { + onpointerenter: (e: PointerEvent) => void; + onpointerleave: (e: PointerEvent) => void; + threshold: number; + } +>(); +const enteredNodes = new Set(); +function globalOnpointermove(e: PointerEvent) { + const pos = posFromClient(e); + for (const [node, { onpointerenter, onpointerleave, threshold: m }] of resizeHandleMap) { + const rect = node.getBoundingClientRect(); + if ( + pos.x >= rect.left - m && + pos.x <= rect.right + m && + pos.y >= rect.top - m && + pos.y <= rect.bottom + m + ) { + if (enteredNodes.has(node)) return; + enteredNodes.add(node); + onpointerenter(e); + } else { + if (!enteredNodes.has(node)) return; + enteredNodes.delete(node); + onpointerleave(e); + } + } +} +const defaultThreshold = 5; +export type ResizeHandleParams = { + threshold?: number; + onresize?: (params: { + side: ResizeSide; + node: Element; + event: PointerEvent; + height: number; + width: number; + }) => void; + sides?: + | { top?: boolean; left?: boolean; bottom?: boolean; right?: boolean; all?: boolean } + | undefined; +}; + +export type ResizeSide = 'n' | 'w' | 's' | 'e' | 'ne' | 'se' | 'nw' | 'sw'; + +export function resizeHandle( + node: N, + params: ResizeHandleParams = {} +): ActionReturn> { + function onpointerenter(e: PointerEvent) { + document.addEventListener('pointermove', onpointermove); + removePointermoveCleanup?.(); + } + let removePointermoveCleanup: (() => void) | undefined; + function onpointerleave(e: PointerEvent) { + // Wait for pointer up to stop resizing + removePointermoveCleanup = PointerDownWatcher.instance.subscribe((down) => { + if (!down) { + document.removeEventListener('pointermove', onpointermove); + removePointermoveCleanup?.(); + document.body.style.cursor = ''; + currentSide = null; + } + }); + } + + let resetLastPosCleanup: (() => void) | undefined; + + let currentSide: ResizeSide | null = null; + let lastPos: Vector2D | undefined; + let baseWidth: number | undefined; + let baseHeight: number | undefined; + function onpointermove(e: PointerEvent) { + const pointerdownWatcher = PointerDownWatcher.instance; + const pointerdown = pointerdownWatcher.isPointerDown; + if (pointerdown && currentSide !== null) { + if (!resetLastPosCleanup) { + resetLastPosCleanup = pointerdownWatcher.subscribe((down) => { + if (!down) { + lastPos = undefined; + baseHeight = undefined; + baseWidth = undefined; + resetLastPosCleanup?.(); + resetLastPosCleanup = undefined; + } + }); + } + const rect = node.getBoundingClientRect(); + if (!baseWidth) { + baseWidth = rect.width; + } + if (!baseHeight) { + baseHeight = rect.height; + } + const downPos = pointerdownWatcher.pos; + if (!downPos) return; + // Dragging + if (!lastPos) { + lastPos = downPos; + } + const pos = posFromClient(e); + const offset = Vector2D.subtract(pos, lastPos); + // const totalOffset = Vector2D.subtract(pos, downPos); + lastPos = pos; + document.body.style.userSelect = 'none'; + + let width = rect.width; + let height = rect.height; + if (currentSide.includes('e')) { + width = pos.x - rect.left; + } else if (currentSide.includes('w')) { + width = rect.right - pos.x; + } + if (currentSide.includes('s')) { + height = pos.y - rect.top; + } else if (currentSide.includes('n')) { + height = rect.bottom - pos.y; + } + node.style.width = `${width}px`; + node.style.height = `${height}px`; + params.onresize?.({ side: currentSide, node, event: e, height, width }); + + return; + } + document.body.style.userSelect = ''; + + const all = + params.sides?.all ?? + (params.sides?.top === undefined && + params.sides?.left === undefined && + params.sides?.bottom === undefined && + params.sides?.right === undefined); + + const top = params.sides?.top ?? all; + const left = params.sides?.left ?? all; + const bottom = params.sides?.bottom ?? all; + const right = params.sides?.right ?? all; + const threshold = params.threshold ?? defaultThreshold; + + const pos = posFromClient(e); + const rect = node.getBoundingClientRect(); + + const dx = pos.x - rect.left; + const dy = pos.y - rect.top; + const w = rect.width; + const h = rect.height; + const m = threshold; + + const previousSide = currentSide; + + let verticalLetter: 's' | 'n' | '' = ''; + let horizontalLetter: 'e' | 'w' | '' = ''; + if (left && dx < m) { + horizontalLetter = 'w'; + } else if (right && dx > w - m) { + horizontalLetter = 'e'; + } + if (top && dy < m) { + verticalLetter = 'n'; + } else if (bottom && dy > h - m) { + verticalLetter = 's'; + } + const candidate = verticalLetter + horizontalLetter; + currentSide = candidate.length > 0 ? candidate as ResizeSide : null; + if (currentSide !== previousSide) { + document.body.style.cursor = currentSide !== null ? `${currentSide}-resize` : ''; + } + } + + if (resizeHandleMap.size === 0) { + document.addEventListener('pointermove', globalOnpointermove); + } + resizeHandleMap.set(node, { + onpointerenter, + onpointerleave, + get threshold() { + return params.threshold ?? defaultThreshold; + } + }); + + return { + destroy() { + resizeHandleMap.delete(node); + if (resizeHandleMap.size === 0) { + document.removeEventListener('pointermove', globalOnpointermove); + } + }, + update(newParams) { + params = newParams; + } + }; +} diff --git a/src/lib/utils/html.svelte.ts b/src/lib/utils/html.svelte.ts index ee282c9..d2bed28 100644 --- a/src/lib/utils/html.svelte.ts +++ b/src/lib/utils/html.svelte.ts @@ -1,3 +1,4 @@ +import { readable, type Readable, type Subscriber } from 'svelte/store'; import type { Position } from './math'; /** * Reactive window state for use in svelte 5. @@ -263,3 +264,42 @@ export function getClosestElementIndex(target: Element, elements: Element[]) { } return closestIndex; } + + +export class PointerDownWatcher { + isPointerDown = $state(false); + pos = $state(); + static #instance: PointerDownWatcher | undefined = undefined; + + static get instance() { + if (!this.#instance) this.#instance = new PointerDownWatcher(); + return this.#instance; + } + private constructor() { + if (typeof document === 'undefined') return; + console.debug('Adding pointer down watcher.') + document.addEventListener('pointerdown', this.onpointerdown.bind(this)); + document.addEventListener('pointerup', this.onpointerup.bind(this)); + } + + #subscribers = new Set<(value: boolean) => void>(); + subscribe(run: (value: boolean) => void): () => void { + this.#subscribers.add(run); + run(this.isPointerDown); + return () => { + this.#subscribers.delete(run); + }; + } + + protected onpointerdown(e: PointerEvent) { + this.isPointerDown = true; + this.pos = posFromClient(e) + this.#subscribers.forEach((s) => s(true)); + } + + protected onpointerup(e: PointerEvent) { + this.isPointerDown = false; + this.pos = undefined; + this.#subscribers.forEach((s) => s(false)); + } +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e475a92..1d33e32 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,6 @@ import '../app.css'; -
+
diff --git a/src/routes/resizable/+page.svelte b/src/routes/resizable/+page.svelte new file mode 100644 index 0000000..dcc8880 --- /dev/null +++ b/src/routes/resizable/+page.svelte @@ -0,0 +1,31 @@ + + +

Resizable

+