Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 49 additions & 20 deletions apps/web/src/routes/[docId]/[pageId]/canvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,50 @@
let spaceHeld = $state(false);
let dragging = $state(false);
let modifierHeld = $state(false);
let lastPointerX = 0;
let lastPointerY = 0;

function findNodeAtPoint(clientX: number, clientY: number): string | null {
for (const el of document.elementsFromPoint(clientX, clientY)) {
if (el instanceof HTMLElement && el.dataset.id) {
return el.dataset.id;
}
}
return null;
}

function updateHover(clientX: number, clientY: number) {
if (spaceHeld) {
return;
}
const id = findNodeAtPoint(clientX, clientY);
if (id) {
if (modifierHeld) {
// If found node is the selected node or inside it,
// hover the selected node's parent instead
let targetId = id;
if (editor.selectedNodeId) {
let checkId: string | null = id;
while (checkId) {
if (checkId === editor.selectedNodeId) {
targetId = editor.selectedNodeId;
break;
}
const p = editor.findParent(checkId);
checkId = p?.id ?? null;
}
}
const parent = editor.findParent(targetId);
if (parent && parent.type !== "root") {
editor.hoverNode(parent.id);
return;
}
}
editor.hoverNode(id);
return;
}
editor.clearHover();
}

let canvasEl = $state<HTMLDivElement>();

Expand Down Expand Up @@ -100,6 +144,7 @@
onkeydown={(e) => {
if (e.key === "Meta" || e.key === "Control") {
modifierHeld = true;
updateHover(lastPointerX, lastPointerY);
}
if (!canvasEl) {
return;
Expand Down Expand Up @@ -147,6 +192,7 @@
onkeyup={(e) => {
if (e.key === "Meta" || e.key === "Control") {
modifierHeld = false;
updateHover(lastPointerX, lastPointerY);
}
if (e.code === "Space") {
spaceHeld = false;
Expand Down Expand Up @@ -184,26 +230,9 @@
editor.pan(e.movementX, e.movementY);
return;
}
if (spaceHeld) {
return;
}
let el = e.target as HTMLElement | null;
while (el && el !== e.currentTarget) {
const id = el.dataset.id;
if (id) {
if (modifierHeld) {
const parent = editor.findParent(id);
if (parent && parent.type !== "root") {
editor.hoverNode(parent.id);
return;
}
}
editor.hoverNode(id);
return;
}
el = el.parentElement;
}
editor.clearHover();
lastPointerX = e.clientX;
lastPointerY = e.clientY;
updateHover(e.clientX, e.clientY);
}}
onpointerup={(e) => {
if (!dragging) {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export class EditorState {
#currentPatch: Patch | null = null;
#undoStack = $state<Patch[]>([]);
#redoStack = $state<Patch[]>([]);
#nodeVersion = $state(0);

constructor(document: Document, page: Page) {
this.#document = $state(document);
Expand All @@ -197,6 +198,7 @@ export class EditorState {
this.#panY;
this.#zoom;
this.selectedNode;
this.#nodeVersion;
/* eslint-enable @typescript-eslint/no-unused-expressions */
this.#measureSelectedNode();
});
Expand All @@ -220,6 +222,7 @@ export class EditorState {
this.#panY;
this.#zoom;
this.#hoveredNodeId;
this.#nodeVersion;
/* eslint-enable @typescript-eslint/no-unused-expressions */
this.#measureHoveredNode();
});
Expand Down Expand Up @@ -320,6 +323,7 @@ export class EditorState {

patch.updated[id] = { old, next };
this.#nodes.set(id, next);
this.#nodeVersion++;

if (!this.#currentPatch) {
this.#undoStack.push(patch);
Expand Down Expand Up @@ -354,6 +358,7 @@ export class EditorState {
for (const [id, { old }] of Object.entries(patch.updated)) {
this.#nodes.set(id, old);
}
this.#nodeVersion++;
this.#redoStack.push(patch);
}

Expand All @@ -371,6 +376,7 @@ export class EditorState {
for (const [id, { next }] of Object.entries(patch.updated)) {
this.#nodes.set(id, next);
}
this.#nodeVersion++;
this.#undoStack.push(patch);
}

Expand Down
82 changes: 57 additions & 25 deletions apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@
node.type === "text",
);

const isDraggable = $derived(isAbsolute || isAuto);

// -- Auto drag (reorder siblings) --

type SiblingRect = { id: string; midX: number; midY: number };
Expand Down Expand Up @@ -300,6 +302,8 @@
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}

// -- Absolute drag --

function handleDragStart(e: PointerEvent) {
if (!isAbsolute || node.type !== "frame") {
return;
Expand Down Expand Up @@ -341,6 +345,34 @@
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}

// -- Drag handle (unified for auto + absolute) --

const DRAG_HANDLE_SIZE = 16;

function handleDragHandleDown(e: PointerEvent) {
if (isAuto) {
startAutoDrag(e);
} else if (isAbsolute) {
handleDragStart(e);
}
}

function handleDragHandleMove(e: PointerEvent) {
if (autoDragging) {
handleAutoDragMove(e);
} else if (dragging) {
handleDragMove(e);
}
}

function handleDragHandleUp(e: PointerEvent) {
if (autoDragging) {
handleAutoDragEnd(e);
} else if (dragging) {
handleDragEnd(e);
}
}

const HANDLE_SIZE = 8;
const EDGE_THICKNESS = 6;

Expand Down Expand Up @@ -370,19 +402,34 @@
style="left: {overlay.x}px; top: {overlay.y}px; width: {overlay.width}px; height: {overlay.height}px"
></div>

{#if isAuto}
<!-- Auto drag surface (reorder siblings) -->
{#if isDraggable}
<!-- Drag handle (top-center, above the frame) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute z-30 cursor-grab active:cursor-grabbing"
style="left: {overlay.x + HANDLE_SIZE}px; top: {overlay.y +
HANDLE_SIZE}px; width: {overlay.width -
HANDLE_SIZE * 2}px; height: {overlay.height - HANDLE_SIZE * 2}px"
class="absolute z-40 flex items-center justify-center rounded-sm
{isAuto ? 'cursor-grab active:cursor-grabbing' : 'cursor-move'}
bg-blue-500 text-white"
style="left: {overlay.x + overlay.width / 2 - DRAG_HANDLE_SIZE / 2}px;
top: {overlay.y - DRAG_HANDLE_SIZE - 4}px;
width: {DRAG_HANDLE_SIZE}px; height: {DRAG_HANDLE_SIZE}px"
onclick={(e) => e.stopPropagation()}
onpointerdown={startAutoDrag}
onpointermove={handleAutoDragMove}
onpointerup={handleAutoDragEnd}
></div>
onpointerdown={handleDragHandleDown}
onpointermove={handleDragHandleMove}
onpointerup={handleDragHandleUp}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="pointer-events-none"
>
<circle cx="3" cy="3" r="1" />
<circle cx="7" cy="3" r="1" />
<circle cx="3" cy="7" r="1" />
<circle cx="7" cy="7" r="1" />
</svg>
</div>
{/if}

{#if dropIndicator}
Expand All @@ -393,21 +440,6 @@
></div>
{/if}

{#if isAbsolute}
<!-- Absolute drag surface -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute z-30 cursor-move"
style="left: {overlay.x + HANDLE_SIZE}px; top: {overlay.y +
HANDLE_SIZE}px; width: {overlay.width -
HANDLE_SIZE * 2}px; height: {overlay.height - HANDLE_SIZE * 2}px"
onclick={(e) => e.stopPropagation()}
onpointerdown={handleDragStart}
onpointermove={handleDragMove}
onpointerup={handleDragEnd}
></div>
{/if}

{#if node.type === "frame" || node.type === "text"}
<!-- Edge hit areas -->
<button
Expand Down