Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/ninety-regions-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"$editor": minor
---

feat: node groups
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"eslint": "catalog:",
"globals": "catalog:",
"mode-watcher": "catalog:",
"phosphor-svelte": "catalog:",
"svelte": "catalog:",
"svelte-check": "catalog:",
"typescript-eslint": "catalog:",
Expand Down
33 changes: 32 additions & 1 deletion apps/web/src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
<script>
import { resolve } from "$app/paths";
import { Kbd } from "$theme";
import TreeViewIcon from "phosphor-svelte/lib/TreeViewIcon";
</script>

See <a href={resolve("/editor")}>/editor</a> :)
<main class="p-10">
See <a href={resolve("/editor")}>/editor</a> :)

<h1>How to use:</h1>
<ul>
<li>
<Kbd.Group>
<Kbd.Root>MAJ</Kbd.Root>
<span>+</span>
<Kbd.Root>DRAG</Kbd.Root>
</Kbd.Group>
to select multiple nodes, then <Kbd.Root>G</Kbd.Root> to group them.
</li>
<li><Kbd.Root>UV</Kbd.Root> nodes CANNOT be grouped at this time</li>
<li>
When selecting a node group or when inside it, press <Kbd.Root>N</Kbd.Root> to bring up its
settings.
</li>
<li>
You can create your own, editable copy of any node group by opening its settings sheet
and pressing "Copy & Edit".
</li>
<li>
Nodes with <TreeViewIcon class="bg-muted inline size-5 rounded-sm p-1" /> are a node group.
Click the <TreeViewIcon class="bg-muted inline size-5 rounded-sm p-1" /> button to enter said
node group.
</li>
<li>Select a node group and press <Kbd.Root>Enter</Kbd.Root> to enter it.</li>
</ul>
</main>
205 changes: 194 additions & 11 deletions apps/web/src/routes/editor/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,119 @@
<script lang="ts">
import { Editor, GraphManager, setGraphManager } from "$editor";
import { Menubar } from "$theme/components/ui";
import {
Editor,
GraphManager,
setDevContext,
setGraphManager,
type GraphDocument,
} from "$editor";
import { Menubar, AlertDialog } from "$theme";
import { toggleMode } from "mode-watcher";

const graph = new GraphManager();
setGraphManager(graph);

function handleUpload() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = () => {
if (input.files?.[0]) void graph.upload(input.files[0]);
};
input.click();
setDevContext(true); // TODO: Replace with `dev` from `$app/env` past 1.0 release

function pickFiles(opts: { accept: string; multiple?: boolean }): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = opts.accept;
if (opts.multiple) input.multiple = true;
input.onchange = () => resolve(Array.from(input.files ?? []));
input.click();
});
}

async function handleUpload() {
const [file] = await pickFiles({ accept: ".json" });
if (file) await graph.upload(file);
}

function handleExportGroup() {
const ref = graph.selectedGroupRef;
if (!ref) return;
graph.exportGroup(ref);
}

/** The id of the first selected, enterable group node, if any. */
function getSelectedGroupNodeId(): string | undefined {
for (const id of graph.selectedNodeIds) {
const node = graph.activeDocument.nodes.find((n) => n.id === id);
if (node?.type === "node-group" && node.group) return id;
}
return undefined;
}

function handleGroupSelected() {
const ids = new Set(graph.selectedNodeIds);
if (ids.size === 0) return;
graph.groupSelection(ids);
}

function handleUngroupSelected() {
const ids = new Set(graph.selectedNodeIds);
if (ids.size === 0) return;
graph.ungroupSelection(ids);
}

let pruneDialogOpen = $state(false);

function openPruneDialog() {
pruneDialogOpen = true;
}

function confirmPruneUnused() {
graph.pruneUnusedDefinitions();
pruneDialogOpen = false;
}

function handleEnterGroup() {
const id = getSelectedGroupNodeId();
if (id) graph.enterGroup(id);
}

function handleExitGroup() {
graph.exitGroup();
}

function handleToggleGroupSheet() {
const id = getSelectedGroupNodeId();
if (id) {
graph.openGroupSheet(id);
} else if (graph.path.length > 0) {
const top = graph.path[graph.path.length - 1];
if (top) graph.openGroupSheet(top);
} else {
graph.sheetOpen = !graph.sheetOpen;
}
}

const hasSelection = $derived(graph.selectedNodeIds.length > 0);
const hasSelectedGroup = $derived(getSelectedGroupNodeId() !== undefined);
const canExitGroup = $derived(graph.path.length > 0);
const prunableGroups = $derived(graph.getPrunableGroups());
const hasPrunable = $derived(prunableGroups.length > 0);

async function handleImportGroups() {
const files = await pickFiles({ accept: ".json", multiple: true });
if (files.length === 0) return;
const entries = await Promise.all(
files.map(
async (file) =>
JSON.parse(await file.text()) as {
id?: string;
name?: string;
category?: string;
dependencies?: string[];
definition?: GraphDocument;
},
),
);
const result = graph.importGroups(entries);
if (result.missing.length > 0) {
console.warn("Import completed with missing dependencies:", result.missing);
}
}

function handleKeydown(event: KeyboardEvent) {
Expand All @@ -26,7 +126,7 @@

<svelte:window onkeydown={handleKeydown} />

<div class="flex h-dvh w-dvw flex-col">
<div class="flex h-dvh w-full flex-col overflow-hidden">
<Menubar.Root class="h-auto gap-1 rounded-none border-t-0 border-r-0 border-l-0 p-2">
<Menubar.Menu>
<Menubar.Trigger>File</Menubar.Trigger>
Expand All @@ -39,6 +139,52 @@
Save
</Menubar.Item>
<Menubar.Item onSelect={handleUpload}>Upload</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={handleExportGroup} disabled={!graph.selectedGroupRef}>
Export selected group
</Menubar.Item>
<Menubar.Item onSelect={handleImportGroups}>Import groups</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>

<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menubar.Content>
<Menubar.Item
onSelect={handleGroupSelected}
disabled={!(hasSelection && !graph.isLocked)}
>
Group selected
<Menubar.Shortcut>G</Menubar.Shortcut>
</Menubar.Item>
<Menubar.Item
onSelect={handleUngroupSelected}
disabled={!(hasSelectedGroup && !graph.isLocked)}
>
Ungroup selected
<Menubar.Shortcut>⇧G</Menubar.Shortcut>
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={openPruneDialog} disabled={!hasPrunable}>
Remove all unused group references
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item onSelect={handleEnterGroup} disabled={!hasSelectedGroup}>
Enter node group
<Menubar.Shortcut>↵</Menubar.Shortcut>
</Menubar.Item>
<Menubar.Item onSelect={handleExitGroup} disabled={!canExitGroup}>
Exit node group
<Menubar.Shortcut>Esc</Menubar.Shortcut>
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onSelect={handleToggleGroupSheet}
disabled={!(hasSelectedGroup || canExitGroup || graph.sheetOpen)}
>
Toggle node group sheet
<Menubar.Shortcut>N</Menubar.Shortcut>
</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>

Expand All @@ -56,5 +202,42 @@
</Menubar.Menu>
</Menubar.Root>

<AlertDialog.Root bind:open={pruneDialogOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove unused group references?</AlertDialog.Title>
<AlertDialog.Description>
This will permanently delete the following group definition{prunableGroups.length ===
1
? ""
: "s"} that {prunableGroups.length === 1 ? "is" : "are"} not referenced by
any node in the project.
</AlertDialog.Description>
</AlertDialog.Header>
<ul class="flex max-h-64 flex-col gap-1 overflow-y-auto text-sm">
{#each prunableGroups as group (group.ref)}
<li
class="flex items-center justify-between gap-4 rounded-md border px-3 py-2"
>
<span class="font-medium">{group.label}</span>
<span class="text-muted-foreground tabular-nums">
{group.nodeCount}
{group.nodeCount === 1 ? "node" : "nodes"}
</span>
</li>
{/each}
</ul>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmPruneUnused}>
Remove {prunableGroups.length}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>

<Editor class="min-h-0 flex-1" />
</div>
1 change: 1 addition & 0 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler"
}
}
6 changes: 5 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"eslint-plugin-svelte": "^3.20.0",
"globals": "^17.7.0",
"mode-watcher": "^1.1.0",
"phosphor-svelte": "^3.1.0",
"svelte": "^5.56.4",
"svelte-check": "^4.7.2",
"tailwindcss": "^4.3.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"dependencies": {
"$theme": "workspace:*",
"@motion-core/motion-gpu": "^0.12.0",
"@xyflow/svelte": "^1.6.2"
"@xyflow/svelte": "^1.6.2",
"phosphor-svelte": "catalog:"
},
"devDependencies": {
"@sveltejs/package": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { Resizable } from "$theme/components/ui";
import { Resizable } from "$theme";
import { FragCanvas, defineMaterial } from "@motion-core/motion-gpu/svelte";

import NodeGraph from "./NodeGraph.svelte";
Expand Down
Loading