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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [
]

[workspace.package]
version = "0.8.48"
version = "0.8.49"
edition = "2024"
publish = false

Expand Down
82 changes: 82 additions & 0 deletions apps/gui/frontend/src/features/design/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { JSX } from "@solidjs/web";
import { For, Show } from "solid-js";
import { tx } from "~/stores/i18n";
import { ROOT_ID } from "./document";
import { beginPointerDrag, hitAt } from "./gesture";
import { DesignedNode } from "./render";
import { design } from "./store";

/**
* The artboard.
*
* Renders in the app's own document, which is the H6 decision and the reason
* everything here is simple: hit testing is `closest("[data-design-id]")`,
* the control socket can see every node, and ps-qa addresses them by id.
*
* Pointer events are taken in the capture phase and stopped there. A designed
* Button is a real Button, so without that a click would press it instead of
* selecting it. Capture is the exact tool for "the canvas sees this first and
* the component never does", and it needs no `pointer-events: none` layer,
* which would have broken the hit test it was meant to serve.
*/
export function Canvas(props: {
canvas: () => HTMLElement | undefined;
setCanvas: (element: HTMLElement) => void;
}): JSX.Element {
const doc = () => design.document();
const empty = () => doc().root.children.length === 0;

const onPointerDown = (event: PointerEvent): void => {
event.stopPropagation();
event.preventDefault();
const hit = hitAt(event.clientX, event.clientY);
design.select(hit?.id ?? ROOT_ID);
if (!hit) return;
design.beginDrag({ kind: "node", nodeId: hit.id });
beginPointerDrag({ x: event.clientX, y: event.clientY }, props.canvas, () => {
/* An unmoved press on a node is a selection, already applied above. */
});
};

return (
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
<div class="flex items-baseline justify-between gap-2 px-1">
<span class="text-az-muted text-ui-micro">{tx("Artboard")}</span>
<Show when={design.dropPlan()}>
{(plan) => (
<span
role="status"
aria-label={tx("Drop target {target}", { target: plan().relativeTo })}
class="font-mono text-primary text-ui-micro"
>
{plan().kind} · {plan().relativeTo}
</span>
)}
</Show>
</div>

<section
ref={props.setCanvas}
id="design-canvas"
// A named section, so the artboard is addressable by name rather than
// by position. A bare div carries no role for the name to attach to.
aria-label={tx("Design canvas")}
onPointerDown={onPointerDown}
class="az-scroll flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-auto rounded-panel border border-az-hairline bg-base-100 p-6"
>
<Show
when={!empty()}
fallback={
<div class="flex flex-1 items-center justify-center rounded-xl border border-az-hairline border-dashed py-16 text-az-muted text-ui-detail">
{tx("Drag a component here")}
</div>
}
>
<For each={doc().root.children}>
{(node) => <DesignedNode node={node} selectedId={design.selectedId()} />}
</For>
</Show>
</section>
</div>
);
}
107 changes: 107 additions & 0 deletions apps/gui/frontend/src/features/design/DesignTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { JSX } from "@solidjs/web";
import { createSignal, onCleanup, onSettled, Show } from "solid-js";
import { Button } from "~/components/Button";
import { Icon } from "~/components/Icon";
import { tx } from "~/stores/i18n";
import { Canvas } from "./Canvas";
import { Inspector } from "./Inspector";
import { Palette } from "./Palette";
import { SourcePane } from "./SourcePane";
import { design } from "./store";

/**
* The Design tab: palette, artboard, properties, emitted source.
*
* Four views of one document. The source pane is deliberately never hidden,
* because the source is what the feature produces and everything else is a
* way of arriving at it.
*/
export function DesignTab(): JSX.Element {
const [canvas, setCanvas] = createSignal<HTMLElement | undefined>(undefined);

onSettled(() => {
const onKey = (event: KeyboardEvent): void => {
const target = event.target;
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
if (typing) return;
const accel = event.metaKey || event.ctrlKey;
if (accel && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) design.redo();
else design.undo();
return;
}
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
design.remove(design.selectedId());
}
};
window.addEventListener("keydown", onKey);
onCleanup(() => window.removeEventListener("keydown", onKey));
});

return (
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2.5 rounded-panel border border-az-hairline bg-az-sunken p-4">
<Toolbar />
<div class="flex min-h-0 min-w-0 flex-1 gap-3">
<Palette canvas={canvas} />
<Canvas canvas={canvas} setCanvas={setCanvas} />
<div class="flex min-h-0 w-[364px] shrink-0 flex-col gap-3">
<div class="flex max-h-[46%] min-h-0 flex-none">
<Inspector />
</div>
<SourcePane />
</div>
</div>
</div>
);
}

function Toolbar(): JSX.Element {
const history = () => design.history();

return (
<div class="flex min-w-0 items-center gap-3">
<div class="min-w-0 flex-1">
<h1 class="font-semibold text-az-title text-ui-title tracking-[-.01em]">{tx("Design")}</h1>
<p class="truncate text-az-muted text-ui-micro">
{tx("Compose @pathscale/ui components and read the source they emit")}
</p>
</div>
<Button
id="design-undo"
type="button"
aria-label={tx("Undo")}
title={tx("Undo")}
onClick={() => design.undo()}
disabled={history().past === 0}
class="flex size-8 items-center justify-center rounded-lg text-az-muted transition-colors hover:bg-az-hover hover:text-base-content disabled:opacity-40"
>
<Icon name="history" class="text-ui-body" />
</Button>
<Button
id="design-redo"
type="button"
aria-label={tx("Redo")}
title={tx("Redo")}
onClick={() => design.redo()}
disabled={history().future === 0}
class="flex size-8 items-center justify-center rounded-lg text-az-muted transition-colors hover:bg-az-hover hover:text-base-content disabled:opacity-40"
>
<Icon name="refresh-cw" class="text-ui-body" />
</Button>
<Show when={design.document().root.children.length > 0}>
<Button
id="design-clear"
type="button"
aria-label={tx("Clear artboard")}
title={tx("Clear artboard")}
onClick={() => design.clear()}
class="flex size-8 items-center justify-center rounded-lg text-az-muted transition-colors hover:bg-az-hover hover:text-error"
>
<Icon name="x" class="text-ui-body" />
</Button>
</Show>
</div>
);
}
Loading
Loading