feat: Floating toolbars

This commit is contained in:
Sidharth Vinod
2025-03-10 21:28:08 -07:00
parent f608761fa2
commit d8cdf2f42c
7 changed files with 167 additions and 52 deletions
+1
View File
@@ -77,6 +77,7 @@ module.exports = {
'error',
{
allowList: {
args: true,
ctx: true,
db: true,
doc: true,
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<div class="flex items-center justify-between gap-2 rounded-2xl bg-border p-2">
{@render children()}
</div>
@@ -0,0 +1,29 @@
<script lang="ts">
import { inputStateStore, stateStore } from '$/util/state';
import { cmdKey, syncDiagram } from '$/util/util';
import { slide } from 'svelte/transition';
import FloatingToolbar from './FloatingToolbar.svelte';
import { Switch } from './ui/switch';
</script>
<FloatingToolbar>
<label class="label flex cursor-pointer items-center gap-2" for="rough">
<Switch bind:checked={$inputStateStore.rough} name="rough" />
<span>Hand drawn</span>
</label>
<label class="label flex cursor-pointer items-center gap-2" for="autoSync">
<Switch bind:checked={$inputStateStore.autoSync} name="autoSync" />
<span> Auto sync</span>
</label>
{#if !$stateStore.autoSync}
<button
transition:slide={{ axis: 'x' }}
class="btn btn-secondary btn-xs ml-2"
title="Sync Diagram ({cmdKey} + Enter)"
aria-label="Sync Diagram"
data-cy="sync"
onclick={syncDiagram}><i class="fas fa-sync"></i></button>
{/if}
</FloatingToolbar>
+3 -2
View File
@@ -102,7 +102,6 @@
);
if (svg.length > 0) {
handlePanZoom(state);
container.innerHTML = svg;
const graphDiv = document.querySelector<SVGSVGElement>('#graph-div');
if (!graphDiv) {
@@ -119,6 +118,7 @@
}
const height = sketch.getAttribute('height');
const width = sketch.getAttribute('width');
sketch.setAttribute('id', 'graph-div');
sketch.setAttribute('height', '100%');
sketch.setAttribute('width', '100%');
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
@@ -130,6 +130,7 @@
bindFunctions(graphDiv);
}
}
handlePanZoom(state);
}
if (view?.parentElement && scroll) {
view.parentElement.scrollTop = scroll;
@@ -165,7 +166,7 @@
{#if outOfSync}
<div
class="font-monotext-yellow-600 absolute z-10 w-full bg-base-100 bg-opacity-80 p-2 text-left"
class="font-monotext-yellow-600 bg-base-100 absolute z-10 w-full bg-opacity-80 p-2 text-left"
id="errorContainer">
Diagram out of sync. <br />
{#if $stateStore.autoSync}
+19 -21
View File
@@ -1,27 +1,25 @@
<script lang="ts">
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
import { cn } from '$lib/utils.js';
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
bind:ref
bind:checked
class={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-secondary data-[state=unchecked]:bg-input',
className
)}
{...restProps}>
<SwitchPrimitive.Thumb
class={cn(
'pointer-events-none block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)} />
</SwitchPrimitive.Root>
+94
View File
@@ -0,0 +1,94 @@
import type { State } from '$/types';
import type { Point } from 'mermaid/dist/types.js';
import panzoom from 'svg-pan-zoom';
type PanZoom = typeof panzoom;
export class PanZoomState {
private pan?: Point;
private zoom?: number;
private pzoom: PanZoom | undefined;
private isDirty = false;
private resizeObserver: ResizeObserver;
public isPanEnabled: boolean;
constructor() {
this.isPanEnabled = true;
this.resizeObserver = new ResizeObserver(() => {
this.resize();
if (!this.isDirty) {
this.reset();
}
});
}
public updateElement(diagramView: SVGElement, { pan, zoom }: Pick<State, 'pan' | 'zoom'>) {
this.pzoom = panzoom(diagramView, {
onPan: (pan) => {
this.pan = pan;
this.zoom = this.pzoom?.getZoom();
this.isDirty = true;
},
onZoom: (zoom) => {
this.zoom = zoom;
this.pan = this.pzoom?.getPan();
this.isDirty = true;
},
controlIconsEnabled: false,
panEnabled: true,
zoomEnabled: true,
fit: true,
center: true,
maxZoom: 12,
minZoom: 0.2
});
this.pzoom.disableDblClickZoom();
this.resizeObserver.disconnect();
this.resizeObserver.observe(diagramView);
// TODO: Investigate why this is necessary
if (pan !== undefined && zoom !== undefined && Number.isFinite(zoom)) {
this.restorePanZoom(pan, zoom);
} else if (this.pan && this.zoom) {
this.restorePanZoom(this.pan, this.zoom);
}
// we start out with both pan and zoom enabled so that the tool can auto position view refreshed
// then set enable/disable pan based on state
if (this.isPanEnabled) {
this.pzoom.enablePan();
this.pzoom.enableZoom();
} else {
this.pzoom.disableZoom();
this.pzoom.disablePan();
}
}
public restorePanZoom(pan: Point, zoom: number) {
if (!this.pzoom) {
console.error('PanZoomState.restorePanZoom: pzoom is not initialized');
return;
}
this.pzoom.zoom(zoom);
this.pzoom.pan(pan);
}
public resize() {
this.pzoom?.resize();
}
public zoomIn() {
this.pzoom?.zoomIn();
}
public zoomOut() {
this.pzoom?.zoomOut();
}
public reset() {
this.pzoom?.reset();
this.isDirty = false;
}
}
+8 -29
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import SyncRoughToolbar from '$/components/SyncRoughToolbar.svelte';
import { Button } from '$/components/ui/button';
import * as Resizable from '$/components/ui/resizable';
import Actions from '$lib/components/Actions.svelte';
@@ -120,35 +121,12 @@
<div class="flex h-full flex-col overflow-hidden">
<Navbar />
<div class="flex flex-1 overflow-hidden">
<Resizable.PaneGroup direction="horizontal" autoSaveId="liveEditor" class="p-2">
<Resizable.PaneGroup direction="horizontal" autoSaveId="liveEditor" class="p-6">
<Resizable.Pane defaultSize={40} minSize={15}>
<div class="hidden h-full flex-col gap-2 md:flex" id="editorPane">
<div class="hidden h-full flex-col gap-6 md:flex" id="editorPane">
<Card onselect={tabSelectHandler} isOpen {tabs} {activeTabID} isClosable={false}>
{#snippet actions()}
<div class="flex flex-row items-center">
<!-- <div class="form-control flex-row items-center">
<label class="label cursor-pointer" for="autoSync">
<span> Auto sync</span>
<input
type="checkbox"
class="toggle {$stateStore.autoSync
? 'btn-secondary'
: 'toggle-primary'} ml-1"
id="autoSync"
bind:checked={$inputStateStore.autoSync} />
</label>
</div>
{#if !$stateStore.autoSync}
<button
class="btn btn-secondary btn-xs mr-1"
title="Sync Diagram ({cmdKey} + Enter)"
aria-label="Sync Diagram"
data-cy="sync"
onclick={syncDiagram}><i class="fas fa-sync"></i></button>
{/if}
-->
<Button
variant="ghost"
href={docURL}
@@ -162,16 +140,17 @@
<Editor />
</Card>
<div class="group flex flex-wrap justify-between gap-2">
<div class="group flex flex-wrap justify-between gap-6">
<Preset />
<!-- <History /> -->
<Actions />
</div>
</div>
</Resizable.Pane>
<Resizable.Handle class="opacity-0" />
<Resizable.Pane class="p-2">
<div class="flex h-full flex-1 flex-col overflow-hidden">
<Resizable.Handle class="mr-2 opacity-0" />
<Resizable.Pane>
<div class="relative flex h-full flex-1 flex-col overflow-hidden">
<div class="absolute left-0 top-0"><SyncRoughToolbar /></div>
<!-- <Card title="Diagram" isClosable={false}>
{#snippet actions()}
<div class="flex flex-row items-center gap-2">