Skip to content

Commit

Permalink
Add resizable
Browse files Browse the repository at this point in the history
  • Loading branch information
ShaitanLyss committed Aug 23, 2024
1 parent e282191 commit ca5b652
Show file tree
Hide file tree
Showing 6 changed files with 271 additions and 2 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@selenite/commons",
"version": "0.21.2",
"version": "0.22.0",
"repository": "github:ShaitanLyss/selenite-commons",
"license": "MIT",
"keywords": [
Expand Down
1 change: 1 addition & 0 deletions src/lib/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
197 changes: 197 additions & 0 deletions src/lib/actions/resizable.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>();
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<Element extends HTMLElement = HTMLElement> = {
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<N extends HTMLElement = HTMLElement>(
node: N,
params: ResizeHandleParams<N> = {}
): ActionReturn<ResizeHandleParams<N>> {
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;
}
};
}
40 changes: 40 additions & 0 deletions src/lib/utils/html.svelte.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -263,3 +264,42 @@ export function getClosestElementIndex(target: Element, elements: Element[]) {
}
return closestIndex;
}


export class PointerDownWatcher {
isPointerDown = $state(false);
pos = $state<Position>();
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));
}
}
2 changes: 1 addition & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
import '../app.css';
</script>

<main class="p-4 min-h-screen grid justify-center gap-2 items-start">
<main class="p-4 min-h-screen grid justify-center gap-2 items-start place-content-start">
<slot />
</main>
31 changes: 31 additions & 0 deletions src/routes/resizable/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts">
import type { ResizeHandleParams } from '$lib';
import { persisted, PointerDownWatcher, resizeHandle as resizable } from '$lib';
let all = $state(true);
// $effect(() => {
// let interval = setInterval(() => {
// all = !all
// }, 1000)
// return () => {
// clearInterval(interval)
// }
// })
let node = $state<HTMLElement>()
let width = 0
$effect(() => {
if (node) {
width = node.getBoundingClientRect().width
}
})
const persistedSize = persisted<{height?: number, width?: number}>('views-resizable-box', {})
const onresize: ResizeHandleParams['onresize'] = ({width, height}) => {
$persistedSize = {width, height}
}
</script>

<h1 class="text-2xl font-bold">Resizable</h1>
<div bind:this={node} class="h-[10rem] w-[10rem] bg-pink-50" style="height: {$persistedSize.height}px; width: {$persistedSize.width}px" use:resizable={{ onresize}}></div>

0 comments on commit ca5b652

Please sign in to comment.