Canvas handlePointerDown ignorerer klikk inne i .blockshell-content. Inputs, knapper, selects og lister i paneler fungerer normalt. Bare headeren starter Canvas-drag. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
758 lines
20 KiB
Svelte
758 lines
20 KiB
Svelte
<script lang="ts">
|
||
import { type Snippet } from 'svelte';
|
||
import {
|
||
type Camera,
|
||
type CanvasObject,
|
||
type GridConfig,
|
||
type ViewportSize,
|
||
visibleObjects,
|
||
screenToWorld,
|
||
snap,
|
||
clampZoom,
|
||
ZOOM_MIN,
|
||
ZOOM_MAX,
|
||
ZOOM_FIT_MIN,
|
||
} from './types.js';
|
||
|
||
/**
|
||
* Canvas Primitive — felles fritt-canvas underlag.
|
||
*
|
||
* Håndterer kamera (pan/zoom), viewport culling, objekt-plassering
|
||
* og interaksjon. Consumer bestemmer hva som rendres via renderObject snippet.
|
||
*/
|
||
|
||
interface Props {
|
||
/** Objects to render on the canvas */
|
||
objects: CanvasObject[];
|
||
/** Render callback for each visible object */
|
||
renderObject: Snippet<[CanvasObject]>;
|
||
/** Grid configuration */
|
||
grid?: GridConfig;
|
||
/** Initial camera position */
|
||
initialCamera?: Camera;
|
||
/** Callback when an object is moved (drag-drop) */
|
||
onObjectMove?: (id: string, x: number, y: number) => void;
|
||
/** Callback when camera changes */
|
||
onCameraChange?: (camera: Camera) => void;
|
||
/** Callback when selection changes */
|
||
onSelectionChange?: (ids: string[]) => void;
|
||
/** Callback when grid is toggled */
|
||
onGridChange?: (enabled: boolean) => void;
|
||
}
|
||
|
||
let {
|
||
objects,
|
||
renderObject,
|
||
grid = { enabled: false, size: 20 },
|
||
initialCamera = { x: 0, y: 0, zoom: 1.0 },
|
||
onObjectMove,
|
||
onCameraChange,
|
||
onGridChange,
|
||
onSelectionChange,
|
||
}: Props = $props();
|
||
|
||
// --- State ---
|
||
let camera = $state<Camera>({ ...initialCamera });
|
||
let selectedIds = $state<Set<string>>(new Set());
|
||
let isFullscreen = $state(false);
|
||
let viewport = $state<ViewportSize>({ width: 0, height: 0 });
|
||
|
||
// Interaction state (not reactive — internal bookkeeping)
|
||
let isPanning = $state(false);
|
||
let isDragging = $state(false);
|
||
let isLassoing = $state(false);
|
||
let spaceHeld = $state(false);
|
||
let dragTargetId: string | null = null;
|
||
let topZCounter = $state(1);
|
||
let zOrderMap = $state(new Map<string, number>());
|
||
let dragStartWorld = { x: 0, y: 0 };
|
||
let dragObjectStart = { x: 0, y: 0 };
|
||
let dragGroupStarts = new Map<string, { x: number; y: number }>();
|
||
let panStart = { x: 0, y: 0 };
|
||
let cameraStart = { x: 0, y: 0 };
|
||
let lassoStart = { x: 0, y: 0 };
|
||
let lassoEnd = $state({ x: 0, y: 0 });
|
||
|
||
// Touch tracking for pinch-zoom
|
||
let activeTouches = new Map<number, { x: number; y: number }>();
|
||
let lastPinchDist = 0;
|
||
let lastPinchCenter = { x: 0, y: 0 };
|
||
|
||
// DOM refs
|
||
let containerEl: HTMLDivElement | undefined = $state();
|
||
|
||
// --- Derived ---
|
||
let visibleObjs = $derived(visibleObjects(objects, camera, viewport));
|
||
|
||
let transformStyle = $derived(
|
||
`translate(${camera.x}px, ${camera.y}px) scale(${camera.zoom})`
|
||
);
|
||
|
||
let lassoRect = $derived.by(() => {
|
||
if (!isLassoing) return null;
|
||
const x = Math.min(lassoStart.x, lassoEnd.x);
|
||
const y = Math.min(lassoStart.y, lassoEnd.y);
|
||
const w = Math.abs(lassoEnd.x - lassoStart.x);
|
||
const h = Math.abs(lassoEnd.y - lassoStart.y);
|
||
return { x, y, width: w, height: h };
|
||
});
|
||
|
||
// Notify consumers of camera changes
|
||
$effect(() => {
|
||
// Access camera fields to create dependency
|
||
const _ = camera.x + camera.y + camera.zoom;
|
||
onCameraChange?.(camera);
|
||
});
|
||
|
||
// ResizeObserver for viewport size
|
||
$effect(() => {
|
||
if (!containerEl) return;
|
||
const ro = new ResizeObserver((entries) => {
|
||
for (const entry of entries) {
|
||
viewport.width = entry.contentRect.width;
|
||
viewport.height = entry.contentRect.height;
|
||
}
|
||
});
|
||
ro.observe(containerEl);
|
||
return () => ro.disconnect();
|
||
});
|
||
|
||
// Keyboard listeners
|
||
$effect(() => {
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
if (e.code === 'Space' && !e.repeat) {
|
||
spaceHeld = true;
|
||
e.preventDefault();
|
||
}
|
||
if (e.code === 'Escape' && isFullscreen) {
|
||
isFullscreen = false;
|
||
}
|
||
// 'g' toggles grid snap
|
||
if (e.code === 'KeyG' && !e.ctrlKey && !e.metaKey) {
|
||
grid = { ...grid, enabled: !grid.enabled };
|
||
onGridChange?.(grid.enabled);
|
||
}
|
||
// Piltaster = pan
|
||
const PAN_STEP = 50;
|
||
if (e.code === 'ArrowUp') { camera = { ...camera, y: camera.y + PAN_STEP }; e.preventDefault(); }
|
||
if (e.code === 'ArrowDown') { camera = { ...camera, y: camera.y - PAN_STEP }; e.preventDefault(); }
|
||
if (e.code === 'ArrowLeft') { camera = { ...camera, x: camera.x + PAN_STEP }; e.preventDefault(); }
|
||
if (e.code === 'ArrowRight') { camera = { ...camera, x: camera.x - PAN_STEP }; e.preventDefault(); }
|
||
}
|
||
function onKeyUp(e: KeyboardEvent) {
|
||
if (e.code === 'Space') {
|
||
spaceHeld = false;
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKeyDown);
|
||
window.addEventListener('keyup', onKeyUp);
|
||
return () => {
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
window.removeEventListener('keyup', onKeyUp);
|
||
};
|
||
});
|
||
|
||
// --- Scroll: Ctrl = zoom, uten Ctrl = pan ---
|
||
function handleWheel(e: WheelEvent) {
|
||
// Ikke fang scroll inne i paneler (la innhold scrolle)
|
||
if ((e.target as HTMLElement).closest('[data-canvas-object-id]')) return;
|
||
|
||
e.preventDefault();
|
||
if (e.ctrlKey || e.metaKey) {
|
||
// Ctrl+scroll = zoom
|
||
const zoomFactor = e.deltaY > 0 ? 0.92 : 1.08;
|
||
zoomAt(e.clientX, e.clientY, zoomFactor);
|
||
} else {
|
||
// Scroll = pan
|
||
camera = {
|
||
...camera,
|
||
x: camera.x - e.deltaX - (e.shiftKey ? e.deltaY : 0),
|
||
y: camera.y - (e.shiftKey ? 0 : e.deltaY),
|
||
};
|
||
}
|
||
}
|
||
|
||
function zoomAt(screenX: number, screenY: number, factor: number) {
|
||
const rect = containerEl?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
const cx = screenX - rect.left;
|
||
const cy = screenY - rect.top;
|
||
|
||
const newZoom = clampZoom(camera.zoom * factor);
|
||
const scale = newZoom / camera.zoom;
|
||
|
||
camera = {
|
||
x: cx - scale * (cx - camera.x),
|
||
y: cy - scale * (cy - camera.y),
|
||
zoom: newZoom,
|
||
};
|
||
}
|
||
|
||
// --- Pointer events ---
|
||
function handlePointerDown(e: PointerEvent) {
|
||
if (!containerEl) return;
|
||
// Ignore clicks on toolbar and other UI overlays
|
||
if ((e.target as HTMLElement).closest('.canvas-toolbar')) return;
|
||
|
||
const rect = containerEl.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
|
||
// Middle button or space+left = pan
|
||
if (e.button === 1 || (e.button === 0 && spaceHeld)) {
|
||
isPanning = true;
|
||
panStart = { x: e.clientX, y: e.clientY };
|
||
cameraStart = { x: camera.x, y: camera.y };
|
||
containerEl.setPointerCapture(e.pointerId);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
// Left button on object = drag object (but not panel content)
|
||
if (e.button === 0) {
|
||
// La klikk inne i panel-innhold passere (inputs, knapper, lister osv.)
|
||
// Bare headeren (.blockshell-header) skal starte Canvas-drag
|
||
const inContent = (e.target as HTMLElement).closest('.blockshell-content');
|
||
if (inContent) return;
|
||
|
||
const target = (e.target as HTMLElement).closest('[data-canvas-object-id]');
|
||
if (target) {
|
||
const id = target.getAttribute('data-canvas-object-id')!;
|
||
isDragging = true;
|
||
dragTargetId = id;
|
||
const world = screenToWorld(sx, sy, camera);
|
||
dragStartWorld = { x: world.x, y: world.y };
|
||
const obj = objects.find(o => o.id === id);
|
||
if (obj) {
|
||
dragObjectStart = { x: obj.x, y: obj.y };
|
||
}
|
||
|
||
// Selection logic
|
||
if (e.shiftKey) {
|
||
const newSet = new Set(selectedIds);
|
||
if (newSet.has(id)) newSet.delete(id);
|
||
else newSet.add(id);
|
||
selectedIds = newSet;
|
||
} else if (!selectedIds.has(id)) {
|
||
selectedIds = new Set([id]);
|
||
}
|
||
onSelectionChange?.([...selectedIds]);
|
||
|
||
// Bring to front
|
||
topZCounter++;
|
||
zOrderMap = new Map(zOrderMap);
|
||
for (const sid of selectedIds) {
|
||
zOrderMap.set(sid, topZCounter);
|
||
}
|
||
|
||
// Record start positions for all selected objects (group drag)
|
||
dragGroupStarts = new Map();
|
||
for (const sid of selectedIds) {
|
||
const sobj = objects.find(o => o.id === sid);
|
||
if (sobj) dragGroupStarts.set(sid, { x: sobj.x, y: sobj.y });
|
||
}
|
||
|
||
containerEl.setPointerCapture(e.pointerId);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
// Left button on empty space = pan (or lasso with shift)
|
||
if (e.shiftKey) {
|
||
// Shift+drag = lasso selection
|
||
isLassoing = true;
|
||
lassoStart = { x: sx, y: sy };
|
||
lassoEnd = { x: sx, y: sy };
|
||
} else {
|
||
// Normal drag on empty space = pan
|
||
isPanning = true;
|
||
panStart = { x: e.clientX, y: e.clientY };
|
||
cameraStart = { x: camera.x, y: camera.y };
|
||
// Deselect all
|
||
selectedIds = new Set();
|
||
onSelectionChange?.([]);
|
||
}
|
||
containerEl.setPointerCapture(e.pointerId);
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function handlePointerMove(e: PointerEvent) {
|
||
if (!containerEl) return;
|
||
|
||
if (isPanning) {
|
||
const dx = e.clientX - panStart.x;
|
||
const dy = e.clientY - panStart.y;
|
||
camera = {
|
||
...camera,
|
||
x: cameraStart.x + dx,
|
||
y: cameraStart.y + dy,
|
||
};
|
||
return;
|
||
}
|
||
|
||
if (isDragging && dragTargetId) {
|
||
const rect = containerEl.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
const world = screenToWorld(sx, sy, camera);
|
||
const dx = world.x - dragStartWorld.x;
|
||
const dy = world.y - dragStartWorld.y;
|
||
|
||
// Move all selected objects together
|
||
for (const [sid, start] of dragGroupStarts) {
|
||
let newX = start.x + dx;
|
||
let newY = start.y + dy;
|
||
if (grid.enabled) {
|
||
newX = snap(newX, grid.size);
|
||
newY = snap(newY, grid.size);
|
||
}
|
||
onObjectMove?.(sid, newX, newY);
|
||
}
|
||
|
||
// Edge-pan: scroll canvas when dragging near edges
|
||
const edgeMargin = 40;
|
||
const edgeSpeed = 8;
|
||
let panDx = 0, panDy = 0;
|
||
if (sx < edgeMargin) panDx = edgeSpeed;
|
||
if (sx > rect.width - edgeMargin) panDx = -edgeSpeed;
|
||
if (sy < edgeMargin) panDy = edgeSpeed;
|
||
if (sy > rect.height - edgeMargin) panDy = -edgeSpeed;
|
||
if (panDx || panDy) {
|
||
camera = { ...camera, x: camera.x + panDx, y: camera.y + panDy };
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isLassoing) {
|
||
const rect = containerEl.getBoundingClientRect();
|
||
lassoEnd = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
return;
|
||
}
|
||
}
|
||
|
||
function handlePointerUp(e: PointerEvent) {
|
||
if (isPanning) {
|
||
isPanning = false;
|
||
return;
|
||
}
|
||
|
||
if (isDragging) {
|
||
isDragging = false;
|
||
dragTargetId = null;
|
||
return;
|
||
}
|
||
|
||
if (isLassoing && lassoRect) {
|
||
// Select objects within lasso (in screen space)
|
||
const newSelection = new Set(selectedIds);
|
||
for (const obj of visibleObjs) {
|
||
const screenPos = {
|
||
x: obj.x * camera.zoom + camera.x,
|
||
y: obj.y * camera.zoom + camera.y,
|
||
width: obj.width * camera.zoom,
|
||
height: obj.height * camera.zoom,
|
||
};
|
||
// Check if object intersects lasso rect
|
||
if (
|
||
screenPos.x < lassoRect.x + lassoRect.width &&
|
||
screenPos.x + screenPos.width > lassoRect.x &&
|
||
screenPos.y < lassoRect.y + lassoRect.height &&
|
||
screenPos.y + screenPos.height > lassoRect.y
|
||
) {
|
||
newSelection.add(obj.id);
|
||
}
|
||
}
|
||
selectedIds = newSelection;
|
||
onSelectionChange?.([...selectedIds]);
|
||
isLassoing = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// --- Touch: pinch-zoom and two-finger pan ---
|
||
function handleTouchStart(e: TouchEvent) {
|
||
for (const touch of e.changedTouches) {
|
||
activeTouches.set(touch.identifier, { x: touch.clientX, y: touch.clientY });
|
||
}
|
||
if (activeTouches.size === 2) {
|
||
const [a, b] = [...activeTouches.values()];
|
||
lastPinchDist = Math.hypot(b.x - a.x, b.y - a.y);
|
||
lastPinchCenter = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function handleTouchMove(e: TouchEvent) {
|
||
for (const touch of e.changedTouches) {
|
||
activeTouches.set(touch.identifier, { x: touch.clientX, y: touch.clientY });
|
||
}
|
||
if (activeTouches.size === 2) {
|
||
const [a, b] = [...activeTouches.values()];
|
||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||
const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
|
||
// Pinch zoom
|
||
if (lastPinchDist > 0) {
|
||
const factor = dist / lastPinchDist;
|
||
zoomAt(center.x, center.y, factor);
|
||
}
|
||
|
||
// Two-finger pan
|
||
const dx = center.x - lastPinchCenter.x;
|
||
const dy = center.y - lastPinchCenter.y;
|
||
camera = { ...camera, x: camera.x + dx, y: camera.y + dy };
|
||
|
||
lastPinchDist = dist;
|
||
lastPinchCenter = center;
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function handleTouchEnd(e: TouchEvent) {
|
||
for (const touch of e.changedTouches) {
|
||
activeTouches.delete(touch.identifier);
|
||
}
|
||
if (activeTouches.size < 2) {
|
||
lastPinchDist = 0;
|
||
}
|
||
}
|
||
|
||
// --- Toolbar zoom (zoom toward viewport center) ---
|
||
function zoomStep(delta: number) {
|
||
const cx = viewport.width / 2;
|
||
const cy = viewport.height / 2;
|
||
const newZoom = clampZoom(camera.zoom * (1 + delta));
|
||
const scale = newZoom / camera.zoom;
|
||
camera = {
|
||
x: cx - scale * (cx - camera.x),
|
||
y: cy - scale * (cy - camera.y),
|
||
zoom: newZoom,
|
||
};
|
||
}
|
||
|
||
// --- Reset zoom (ankret til viewport-sentrum) ---
|
||
function resetZoom() {
|
||
const cx = viewport.width / 2;
|
||
const cy = viewport.height / 2;
|
||
const scale = 1.0 / camera.zoom;
|
||
camera = {
|
||
x: cx - scale * (cx - camera.x),
|
||
y: cy - scale * (cy - camera.y),
|
||
zoom: 1.0,
|
||
};
|
||
}
|
||
|
||
// --- Fullscreen ---
|
||
function toggleFullscreen() {
|
||
isFullscreen = !isFullscreen;
|
||
}
|
||
|
||
// --- Public API via bindable ---
|
||
export function getCamera(): Camera {
|
||
return { ...camera };
|
||
}
|
||
|
||
export function setCamera(cam: Camera) {
|
||
camera = { ...cam, zoom: clampZoom(cam.zoom) };
|
||
}
|
||
|
||
export function zoomToFit(padding: number = 50) {
|
||
if (objects.length === 0) return;
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const obj of objects) {
|
||
minX = Math.min(minX, obj.x);
|
||
minY = Math.min(minY, obj.y);
|
||
maxX = Math.max(maxX, obj.x + obj.width);
|
||
maxY = Math.max(maxY, obj.y + obj.height);
|
||
}
|
||
const contentW = maxX - minX;
|
||
const contentH = maxY - minY;
|
||
if (contentW <= 0 || contentH <= 0) return;
|
||
|
||
const zoomX = (viewport.width - padding * 2) / contentW;
|
||
const zoomY = (viewport.height - padding * 2) / contentH;
|
||
const zoom = clampZoom(Math.min(zoomX, zoomY));
|
||
|
||
camera = {
|
||
x: (viewport.width - contentW * zoom) / 2 - minX * zoom,
|
||
y: (viewport.height - contentH * zoom) / 2 - minY * zoom,
|
||
zoom,
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div
|
||
bind:this={containerEl}
|
||
class="canvas-container"
|
||
class:canvas-fullscreen={isFullscreen}
|
||
class:canvas-panning={isPanning || spaceHeld}
|
||
onwheel={handleWheel}
|
||
onpointerdown={handlePointerDown}
|
||
onpointermove={handlePointerMove}
|
||
onpointerup={handlePointerUp}
|
||
ondblclick={(e) => {
|
||
if (!(e.target as HTMLElement).closest('[data-canvas-object-id]') &&
|
||
!(e.target as HTMLElement).closest('.canvas-toolbar')) {
|
||
resetZoom();
|
||
}
|
||
}}
|
||
ontouchstart={handleTouchStart}
|
||
ontouchmove={handleTouchMove}
|
||
ontouchend={handleTouchEnd}
|
||
role="application"
|
||
aria-label="Canvas"
|
||
>
|
||
<!-- Toolbar -->
|
||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||
<div class="canvas-toolbar" onclick={(e) => e.stopPropagation()} onpointerdown={(e) => e.stopPropagation()}>
|
||
<button
|
||
class="canvas-toolbar-btn"
|
||
onclick={() => zoomStep(-0.15)}
|
||
title="Zoom ut (−15%)"
|
||
aria-label="Zoom ut"
|
||
>−</button>
|
||
<button class="canvas-toolbar-zoom" onclick={() => { resetZoom(); }} title="Tilbakestill til 100%">{Math.round(camera.zoom * 100)}%</button>
|
||
<button
|
||
class="canvas-toolbar-btn"
|
||
onclick={() => zoomStep(0.15)}
|
||
title="Zoom inn (+15%)"
|
||
aria-label="Zoom inn"
|
||
>+</button>
|
||
<button
|
||
class="canvas-toolbar-btn"
|
||
onclick={() => zoomToFit()}
|
||
title="Tilpass visning"
|
||
aria-label="Tilpass visning"
|
||
>⊡</button>
|
||
<div class="canvas-toolbar-sep"></div>
|
||
<button
|
||
class="canvas-toolbar-btn"
|
||
class:canvas-toolbar-active={grid.enabled}
|
||
onclick={() => { grid = { ...grid, enabled: !grid.enabled }; onGridChange?.(grid.enabled); }}
|
||
title="Snap-to-grid (G)"
|
||
aria-label="Snap-to-grid"
|
||
>#</button>
|
||
<button
|
||
class="canvas-toolbar-btn"
|
||
onclick={toggleFullscreen}
|
||
title="Fullskjerm"
|
||
aria-label="Fullskjerm"
|
||
>{isFullscreen ? '⊖' : '⊕'}</button>
|
||
</div>
|
||
|
||
<!-- Grid background (when enabled) -->
|
||
{#if grid.enabled}
|
||
<div
|
||
class="canvas-grid"
|
||
style:background-size="{grid.size * camera.zoom}px {grid.size * camera.zoom}px"
|
||
style:background-position="{camera.x}px {camera.y}px"
|
||
></div>
|
||
{/if}
|
||
|
||
<!-- World layer — CSS transformed -->
|
||
<div
|
||
class="canvas-world"
|
||
style:transform={transformStyle}
|
||
>
|
||
{#each visibleObjs as obj (obj.id)}
|
||
<div
|
||
class="canvas-object"
|
||
class:canvas-object-selected={selectedIds.has(obj.id)}
|
||
data-canvas-object-id={obj.id}
|
||
style:left="{obj.x}px"
|
||
style:top="{obj.y}px"
|
||
style:width="{obj.width}px"
|
||
style:height="{obj.height}px"
|
||
style:z-index={zOrderMap.get(obj.id) ?? 1}
|
||
>
|
||
{@render renderObject(obj)}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Lasso selection overlay (screen space) -->
|
||
{#if isLassoing && lassoRect}
|
||
<div
|
||
class="canvas-lasso"
|
||
style:left="{lassoRect.x}px"
|
||
style:top="{lassoRect.y}px"
|
||
style:width="{lassoRect.width}px"
|
||
style:height="{lassoRect.height}px"
|
||
></div>
|
||
{/if}
|
||
|
||
<!-- Mobile: touch hint -->
|
||
{#if viewport.width < 768}
|
||
<div class="canvas-touch-hint">
|
||
Bruk to fingre for å panorere og zoome
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.canvas-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: var(--color-bg, #0a0a0b);
|
||
touch-action: none; /* We handle all touch ourselves */
|
||
user-select: none;
|
||
cursor: default;
|
||
}
|
||
|
||
.canvas-container.canvas-panning {
|
||
cursor: grab;
|
||
}
|
||
|
||
.canvas-container.canvas-fullscreen {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 9999;
|
||
background: var(--color-bg, #0a0a0b);
|
||
}
|
||
|
||
.canvas-world {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
transform-origin: 0 0;
|
||
will-change: transform;
|
||
}
|
||
|
||
.canvas-object {
|
||
position: absolute;
|
||
cursor: default;
|
||
overflow: visible;
|
||
}
|
||
|
||
.canvas-object-selected {
|
||
outline: 1px solid rgba(99, 102, 241, 0.4);
|
||
outline-offset: 1px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Grid pattern */
|
||
.canvas-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(to right, rgba(99, 102, 241, 0.12) 1px, transparent 1px),
|
||
linear-gradient(to bottom, rgba(99, 102, 241, 0.12) 1px, transparent 1px);
|
||
}
|
||
|
||
/* Lasso */
|
||
.canvas-lasso {
|
||
position: absolute;
|
||
border: 1px dashed #3b82f6;
|
||
background: rgba(59, 130, 246, 0.08);
|
||
pointer-events: none;
|
||
z-index: 10;
|
||
}
|
||
|
||
/* Toolbar */
|
||
.canvas-toolbar {
|
||
position: absolute;
|
||
bottom: 12px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 20;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
background: var(--color-surface, #1c1c20);
|
||
border: 1px solid var(--color-border, #2a2a2e);
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.canvas-toolbar-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
color: var(--color-text-muted, #8a8a96);
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.canvas-toolbar-btn:hover {
|
||
background: var(--color-surface-hover, #242428);
|
||
}
|
||
|
||
.canvas-toolbar-active {
|
||
background: var(--color-accent-glow, rgba(99, 102, 241, 0.15));
|
||
color: var(--color-accent, #3b82f6);
|
||
}
|
||
|
||
.canvas-toolbar-zoom {
|
||
font-size: 12px;
|
||
color: var(--color-text-muted, #8a8a96);
|
||
min-width: 40px;
|
||
text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
padding: 2px 4px;
|
||
}
|
||
.canvas-toolbar-zoom:hover {
|
||
color: var(--color-text, #e8e8ec);
|
||
background: var(--color-surface-hover, #242428);
|
||
}
|
||
|
||
.canvas-toolbar-sep {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--color-border, #2a2a2e);
|
||
margin: 0 4px;
|
||
}
|
||
|
||
/* Touch hint for mobile */
|
||
.canvas-touch-hint {
|
||
position: absolute;
|
||
top: 12px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 20;
|
||
padding: 4px 12px;
|
||
background: rgba(0,0,0,0.6);
|
||
color: white;
|
||
font-size: 12px;
|
||
border-radius: 16px;
|
||
pointer-events: none;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* Responsive touch targets */
|
||
@media (max-width: 768px) {
|
||
.canvas-toolbar-btn {
|
||
width: 44px;
|
||
height: 44px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.canvas-toolbar {
|
||
bottom: 16px;
|
||
gap: 2px;
|
||
padding: 4px;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 768px) and (max-width: 1024px) {
|
||
.canvas-toolbar-btn {
|
||
width: 38px;
|
||
height: 38px;
|
||
}
|
||
}
|
||
</style>
|