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
51 changes: 48 additions & 3 deletions apps/web/src/routes/[docId]/[pageId]/canvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
} from "@dashedhq/core";

import { getEditorState } from "./editor-state.svelte";
import HoverRing from "./hover-ring.svelte";
import SelectionOverlay from "./selection-overlay.svelte";
import TextEditor from "./text-editor.svelte";

Expand Down Expand Up @@ -61,6 +62,20 @@
}
return editor.registerSelectedNodeEl(el);
});

$effect(() => {
const id = editor.hoveredNodeId;
if (!id || !canvasEl) {
return;
}
const el = canvasEl.querySelector(
`[data-id="${id}"]`,
) as HTMLElement | null;
if (!el) {
return;
}
return editor.registerHoveredNodeEl(el);
});
</script>

{#snippet renderNode(node: Node)}
Expand All @@ -71,7 +86,11 @@
data-id={node.id}
onclick={(e) => {
e.stopPropagation();
editor.selectNode(node.id);
if ((e.metaKey || e.ctrlKey) && editor.hoveredNodeId) {
editor.selectNode(editor.hoveredNodeId);
} else {
editor.selectNode(node.id);
}
}}
ondblclick={(e) => {
e.stopPropagation();
Expand Down Expand Up @@ -105,6 +124,9 @@

<svelte:window
onkeydown={(e) => {
if (e.key === "Meta" || e.key === "Control") {
editor.setModifierHeld(true);
}
if (!canvasEl) {
return;
}
Expand Down Expand Up @@ -149,11 +171,15 @@
}
}}
onkeyup={(e) => {
if (e.key === "Meta" || e.key === "Control") {
editor.setModifierHeld(false);
}
if (e.code === "Space") {
spaceHeld = false;
dragging = false;
}
}}
onblur={() => editor.setModifierHeld(false)}
/>

<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
Expand All @@ -169,6 +195,7 @@
}
editor.clearNodeSelection();
}}
onpointerleave={() => editor.clearHover()}
onpointerdown={(e) => {
if (!spaceHeld) {
return;
Expand All @@ -179,10 +206,21 @@
e.currentTarget.setPointerCapture(e.pointerId);
}}
onpointermove={(e) => {
if (!dragging) {
if (dragging) {
editor.pan(e.movementX, e.movementY);
return;
}
editor.pan(e.movementX, e.movementY);
if (spaceHeld) return;
let el = e.target as HTMLElement | null;
while (el && el !== e.currentTarget) {
const id = el.dataset?.id;
if (id && editor.isHoverableNode(id)) {
editor.hoverNode(id);
return;
}
el = el.parentElement;
}
editor.clearHover();
}}
onpointerup={(e) => {
if (!dragging) {
Expand Down Expand Up @@ -220,6 +258,13 @@
{/each}
</div>

{#if editor.hoveredNodeId && editor.hoveredNodeId !== editor.selectedNodeId && editor.hoveredNodeMeasurements && editor.canvasMeasurements && !spaceHeld}
<HoverRing
hoveredNodeMeasurements={editor.hoveredNodeMeasurements}
canvasMeasurements={editor.canvasMeasurements}
/>
{/if}

{#if editor.selectedNode && editor.nodeMeasurements && editor.canvasMeasurements}
<SelectionOverlay
node={editor.selectedNode}
Expand Down
88 changes: 88 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 @@ -100,6 +100,10 @@ export class EditorState {
#nodes: SvelteMap<string, Node>;
#children: string[];
#selection = $state<Selection>(null);
#deepHoveredNodeId = $state<string | null>(null);
#modifierHeld = $state(false);
#hoveredNodeEl: HTMLElement | null = null;
#hoveredNodeMeasurements = $state<NodeMeasurements | null>(null);
#canvasMeasurements = $state<CanvasMeasurements | null>(null);
#nodeEl: HTMLElement | null = null;
#nodeMeasurements = $state<NodeMeasurements | null>(null);
Expand All @@ -124,6 +128,7 @@ export class EditorState {
this.#zoom;
/* eslint-enable @typescript-eslint/no-unused-expressions */
this.#measureNode();
this.#measureHoveredNode();
});
}

Expand Down Expand Up @@ -156,6 +161,89 @@ export class EditorState {
: null,
);

readonly #displayedHoveredNodeId: string | null = $derived.by(() => {
const deepId = this.#deepHoveredNodeId;
if (!deepId) return null;
if (!this.#modifierHeld) return deepId;

// When a frame is selected, promote from the selected frame instead
const referenceId = this.#selection?.nodeId ?? deepId;
const parent = this.#findParent(referenceId);
if (parent && parent.type === "frame" && parent.visible) {
return parent.id;
}
return deepId;
});

get hoveredNodeId(): string | null {
return this.#displayedHoveredNodeId;
}

readonly hoveredNode: Node | null = $derived(
this.#displayedHoveredNodeId
? this.#getNodeOrNull(this.#displayedHoveredNodeId)
: null,
);

get hoveredNodeMeasurements(): NodeMeasurements | null {
return this.#hoveredNodeMeasurements;
}

isHoverableNode(id: string): boolean {
const node = this.#nodes.get(id);
return (
node !== undefined &&
(node.type === "frame" || node.type === "text") &&
node.visible
);
}

hoverNode(id: string) {
if (this.#deepHoveredNodeId === id) {
return;
}
this.#deepHoveredNodeId = id;
}

clearHover() {
if (!this.#deepHoveredNodeId) {
return;
}
this.#deepHoveredNodeId = null;
}

setModifierHeld(held: boolean) {
this.#modifierHeld = held;
}

#measureHoveredNode() {
if (this.#hoveredNodeEl) {
this.#hoveredNodeMeasurements = {
boundingRect: this.#hoveredNodeEl.getBoundingClientRect(),
offsetWidth: this.#hoveredNodeEl.offsetWidth,
offsetHeight: this.#hoveredNodeEl.offsetHeight,
};
}
}

registerHoveredNodeEl(el: HTMLElement): () => void {
this.#hoveredNodeEl = el;
this.#measureHoveredNode();

const observer = new ResizeObserver(() => this.#measureHoveredNode());
observer.observe(el);

return () => {
observer.disconnect();
this.#hoveredNodeEl = null;
this.#hoveredNodeMeasurements = null;
};
}

#getNodeOrNull(id: string): Node | null {
return this.#nodes.get(id) ?? null;
}

#getNodeOrDie(id: string, fn: string) {
const node = this.#nodes.get(id);
if (!node) {
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/routes/[docId]/[pageId]/hover-ring.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import type { CanvasMeasurements, NodeMeasurements } from "./editor-state.svelte";

type Props = {
hoveredNodeMeasurements: NodeMeasurements;
canvasMeasurements: CanvasMeasurements;
};

let { hoveredNodeMeasurements, canvasMeasurements }: Props = $props();

const overlay = $derived.by(() => ({
x:
hoveredNodeMeasurements.boundingRect.x -
canvasMeasurements.boundingRect.x -
canvasMeasurements.clientLeft,
y:
hoveredNodeMeasurements.boundingRect.y -
canvasMeasurements.boundingRect.y -
canvasMeasurements.clientTop,
width: hoveredNodeMeasurements.boundingRect.width,
height: hoveredNodeMeasurements.boundingRect.height,
}));
</script>

<div
class="absolute pointer-events-none z-[5] outline-2 outline-blue-500"
style="left: {overlay.x}px; top: {overlay.y}px; width: {overlay.width}px; height: {overlay.height}px"
></div>