import { Signal, useSignal, useSignalEffect } from "@preact/signals"; import { useLiveSignal } from "@preact/signals/utils"; import { ComponentChildren, Ref, RefObject } from "preact"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { Task } from "."; import { flushSync } from "preact/compat"; type UseDragFunction = (onStartDrag: (e: MouseEvent) => void, onUpdatePosition: (e: MouseEvent) => void) => [Signal, (e: MouseEvent) => void] export function Window(props: Window.Props) { const useDrag = useCallback((onStartDrag, onUpdatePosition) => { const dragTimeout: Ref> = useRef(null); const dragging = useSignal(false); const stopDrag = useCallback((e: MouseEvent) => { if (e.button === 0 && dragging.value && !dragTimeout.current) { dragTimeout.current = setTimeout(() => { dragging.value = false dragTimeout.current = null; props.desktop.current?.removeEventListener("mousemove", onUpdatePosition) props.desktop.current?.removeEventListener("mouseup", stopDrag) }, 50) // intrdouce a small delay where the user can still drag the window without actually pressing down lmb to prevent accidental drops } }, [onUpdatePosition]); const startDrag = useCallback((e: MouseEvent) => { if (e.button === 0 && !maximized.value.value) { if (dragTimeout.current !== null) { clearTimeout(dragTimeout.current); dragTimeout.current = null; } else { onStartDrag(e); dragging.value = true props.desktop.current?.addEventListener("mousemove", onUpdatePosition) props.desktop.current?.addEventListener("mouseleave", stopDrag, { once: true }) props.desktop.current?.addEventListener("mouseup", stopDrag, { once: true }) } } }, [onUpdatePosition, onStartDrag, stopDrag]); return [dragging, startDrag] }, [props]) const windowRef: Ref = useRef(null); const source = useLiveSignal(props.task.source); const title = useLiveSignal(props.task.title); const contentReady = useLiveSignal(props.task.contentReady); const sizingFinished = useLiveSignal(props.task.sizingFinished); const [titleReady, setTitleReady] = useState(false); const [titleRendered, setTitleRendered] = useState(false); const maximized = useLiveSignal(props.task.window.maximized); const minimized = useLiveSignal(props.task.window.minimized); const position = props.task.window.position; const z = useLiveSignal(props.task.window.z); const x = useSignal(position?.x ?? "50%"); const y = useSignal(position?.y ?? "50%"); const initialOffsetX = useRef(0); const initialOffsetY = useRef(0); const onWindowUpdatePosition = useCallback((e: MouseEvent) => { x.value = e.clientX - initialOffsetX.current y.value = e.clientY - initialOffsetY.current }, []) const onStartWindowMove = useCallback((e: MouseEvent) => { const BORDER_WIDTH = 1; // Naturally, I have to add the border width here, because offset* uses padding-edge for the calculations initialOffsetX.current = e.offsetX + BORDER_WIDTH + (e.target as HTMLDivElement).offsetLeft initialOffsetY.current = e.offsetY + BORDER_WIDTH + (e.target as HTMLDivElement).offsetTop document.getSelection()?.empty() }, []) const [dragging, startWindowMove] = useDrag(onStartWindowMove, onWindowUpdatePosition); const height = useSignal(props.task.window.height); const width = useSignal(props.task.window.width); const onStartResize = useCallback((e: MouseEvent) => { initialOffsetX.current = e.offsetX initialOffsetY.current = e.offsetY }, []) const onNorthResize = useCallback((e: MouseEvent) => { const oldY = y.value as number; y.value = e.clientY - initialOffsetY.current; height.value = (height.value as number) - (y.value - oldY) }, []) const [, startNorthResize] = useDrag(onStartResize, onNorthResize) const onEastResize = useCallback((e: MouseEvent) => { width.value = (e.clientX - (x.value as number) - initialOffsetX.current) }, []) const [, startEastResize] = useDrag(onStartResize, onEastResize) const onSouthResize = useCallback((e: MouseEvent) => { height.value = (e.clientY - (y.value as number) - initialOffsetY.current) }, []) const [, startSouthResize] = useDrag(onStartResize, onSouthResize) const onWestResize = useCallback((e: MouseEvent) => { const oldX = x.value as number; x.value = e.clientX - initialOffsetX.current; width.value = (width.value as number) - (x.value - oldX) }, []) const [, startWestResize] = useDrag(onStartResize, onWestResize) const startNEResize = useCallback((e: MouseEvent) => { startNorthResize(e) startEastResize(e) }, []) const startNWResize = useCallback((e: MouseEvent) => { startNorthResize(e) startWestResize(e) }, []) const startSEResize = useCallback((e: MouseEvent) => { startSouthResize(e) startEastResize(e) }, []) const startSWResize = useCallback((e: MouseEvent) => { startSouthResize(e) startWestResize(e) }, []) // tldr: wait until the title signal is ready, // rerender so that the new title is rendered, calculate the position, // and then rerender *again*, this time to reposition and show the window useSignalEffect(() => { if (title.value.value !== undefined && !titleReady) { setTitleReady(true) } }) useLayoutEffect(() => { if (titleReady && contentReady.value.value) { setTitleRendered(true); // normalize position and dimensions from css values to pixels const { height: bounding_height, width: bounding_width } = windowRef.current!.getBoundingClientRect(); height.value = bounding_height width.value = bounding_width if (position === undefined) { // center x.value = windowRef.current!.offsetLeft - (bounding_width / 2); y.value = windowRef.current!.offsetTop - (bounding_height / 2); } else { x.value = windowRef.current!.offsetLeft; y.value = windowRef.current!.offsetTop; } sizingFinished.value.value = true } }, [titleReady, contentReady.value.value]) const handleMinimize = useCallback(() => { minimized.value.value = !minimized.value.value }, []) const handleMaximize = useCallback(() => { maximized.value.value = !maximized.value.value }, []) const onClose = useCallback(() => { props.onClose(props.task) }, [props]) const onFocus = useCallback(() => { props.onFocus(props.task); }, [props]) const onPointerDownButton = useCallback((e: MouseEvent) => { onFocus(); e.stopPropagation(); }, []) return
{title.value.value ?? "[404 Title Not Found]"}
{props.children}
} export namespace Window { export type Props = { desktop: RefObject; task: Task; children: ComponentChildren; onClose: (task: Task) => void; onFocus: (task: Task) => void; } }