synops/frontend/src/lib/components/blockshell/BlockShell.svelte
vegard 543b0ca29f Mørkt tema på alle sider: workspace, canvas, blockshell, traits, collection
Erstattet alle hardkodede lyse farger (white, #f0f2f5, #f3f4f6)
med mørke (#0a0a0b, #1c1c20, #242428) i alle Svelte-komponenter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 02:27:15 +00:00

691 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { type Snippet } from 'svelte';
import {
type SizeConstraints,
type ResizeDirection,
type DropZoneState,
type BlockReceiver,
type BlockShellEvents,
DEFAULT_CONSTRAINTS,
MOBILE_BREAKPOINT,
} from './types.js';
import { getDragPayload, type DragPayload } from '$lib/transfer.js';
/**
* BlockShell — wrapper component for tool panels on the spatial workspace.
*
* Provides:
* - Header with title, icon, and control buttons (fullscreen, close)
* - Drag handle for repositioning (header acts as drag handle)
* - Resize handles on edges and corners
* - Drop-zone rendering with compatibility feedback
* - Responsive: min/max size constraints, mobile-friendly
*
* Ref: docs/retninger/arbeidsflaten.md
* Ref: docs/features/universell_overfoering.md § 8.1
*/
interface Props extends BlockShellEvents {
/** Panel title shown in header */
title: string;
/** Optional icon (emoji or text) */
icon?: string;
/** Current width (controlled by parent / canvas) */
width?: number;
/** Current height (controlled by parent / canvas) */
height?: number;
/** Size constraints */
constraints?: SizeConstraints;
/** Receiver for drag-and-drop compatibility checks */
receiver?: BlockReceiver;
/** Whether to show close button */
closable?: boolean;
/** Whether panel is minimized (collapsed to header only) */
minimized?: boolean;
/** Extra CSS class */
class?: string;
/** Panel content */
children?: Snippet;
}
let {
title,
icon,
width = 400,
height = 300,
constraints = DEFAULT_CONSTRAINTS,
receiver,
closable = true,
minimized = false,
class: extraClass = '',
children,
onClose,
onDragMove,
onResize,
onFullscreenChange,
onMinimizeChange,
onDrop,
}: Props = $props();
// --- State ---
let isFullscreen = $state(false);
let dropZoneState = $state<DropZoneState>('idle');
let dropFeedback = $state<string>('');
let isResizing = $state(false);
let isDragging = $state(false);
let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0);
// Track resize interaction
let resizeDir: ResizeDirection | null = null;
let resizeStartX = 0;
let resizeStartY = 0;
let resizeStartW = 0;
let resizeStartH = 0;
// Track drag interaction
let dragStartX = 0;
let dragStartY = 0;
// --- Derived ---
let isMobile = $derived(containerWidth < MOBILE_BREAKPOINT);
let clampedWidth = $derived(
Math.min(constraints.maxWidth, Math.max(constraints.minWidth, width))
);
let clampedHeight = $derived(
Math.min(constraints.maxHeight, Math.max(constraints.minHeight, height))
);
// --- Viewport tracking ---
$effect(() => {
if (!containerEl) return;
// Use parent's width for mobile detection
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerWidth = entry.contentRect.width;
}
});
// Observe parent element or self
const target = containerEl.parentElement ?? containerEl;
ro.observe(target);
return () => ro.disconnect();
});
// --- Keyboard: Escape exits fullscreen ---
$effect(() => {
if (!isFullscreen) return;
function onKeyDown(e: KeyboardEvent) {
if (e.code === 'Escape') {
isFullscreen = false;
onFullscreenChange?.(false);
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
});
// --- Fullscreen toggle ---
function toggleFullscreen() {
isFullscreen = !isFullscreen;
onFullscreenChange?.(isFullscreen);
}
// --- Minimize toggle (double-click header) ---
let hasDragged = false;
function handleHeaderDblClick() {
if (hasDragged) return;
if (isFullscreen) return;
onMinimizeChange?.(!minimized);
}
// --- Drag (repositioning via header) ---
function handleDragStart(e: PointerEvent) {
// Only left button, not on buttons
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button')) return;
isDragging = true;
hasDragged = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
e.preventDefault();
}
function handleDragMove(e: PointerEvent) {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
hasDragged = true;
}
dragStartX = e.clientX;
dragStartY = e.clientY;
onDragMove?.(dx, dy);
}
function handleDragEnd(_e: PointerEvent) {
isDragging = false;
}
// --- Resize handles ---
function handleResizeStart(dir: ResizeDirection, e: PointerEvent) {
if (e.button !== 0 || isFullscreen) return;
isResizing = true;
resizeDir = dir;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
resizeStartW = clampedWidth;
resizeStartH = clampedHeight;
// Capture on the document level for smooth resizing
window.addEventListener('pointermove', handleResizeMove);
window.addEventListener('pointerup', handleResizeEnd);
e.preventDefault();
e.stopPropagation();
}
function handleResizeMove(e: PointerEvent) {
if (!isResizing || !resizeDir) return;
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
let newW = resizeStartW;
let newH = resizeStartH;
// Horizontal
if (resizeDir.includes('e')) newW = resizeStartW + dx;
if (resizeDir.includes('w')) newW = resizeStartW - dx;
// Vertical
if (resizeDir.includes('s')) newH = resizeStartH + dy;
if (resizeDir.includes('n')) newH = resizeStartH - dy;
// Clamp
newW = Math.min(constraints.maxWidth, Math.max(constraints.minWidth, newW));
newH = Math.min(constraints.maxHeight, Math.max(constraints.minHeight, newH));
onResize?.(newW, newH);
}
function handleResizeEnd() {
isResizing = false;
resizeDir = null;
window.removeEventListener('pointermove', handleResizeMove);
window.removeEventListener('pointerup', handleResizeEnd);
}
// --- Drop zone (HTML5 drag-and-drop between panels) ---
let dragOverCount = 0; // Track nested enter/leave
function handleDragEnter(e: DragEvent) {
e.preventDefault();
dragOverCount++;
if (dragOverCount === 1) {
updateDropState(e);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
// Set dropEffect based on compatibility
if (e.dataTransfer) {
e.dataTransfer.dropEffect = dropZoneState === 'compatible' ? 'copy' : 'none';
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
dragOverCount--;
if (dragOverCount <= 0) {
dragOverCount = 0;
dropZoneState = 'idle';
dropFeedback = '';
}
}
function handleDropEvent(e: DragEvent) {
e.preventDefault();
dragOverCount = 0;
if (dropZoneState === 'compatible' && e.dataTransfer) {
const payload = getDragPayload(e.dataTransfer);
if (payload) {
// Validate on actual drop (we couldn't read payload during drag)
if (receiver) {
const result = receiver.canReceive(payload);
if (!result.compatible) {
// Show incompatible feedback briefly
dropZoneState = 'incompatible';
dropFeedback = result.reason ?? 'Kan ikke motta dette innholdet';
setTimeout(() => {
dropZoneState = 'idle';
dropFeedback = '';
}, 1500);
return;
}
}
// Pass shiftKey so transfer service can override mode
onDrop?.(payload, e.shiftKey);
}
}
dropZoneState = 'idle';
dropFeedback = '';
}
function updateDropState(e: DragEvent) {
if (!receiver || !e.dataTransfer) {
dropZoneState = 'idle';
return;
}
// During dragenter we can't read payload data (security restriction),
// but we can check types to see if it's a synops transfer
const types = e.dataTransfer.types;
const hasSynopsPayload = types.includes('application/x-synops-transfer');
if (!hasSynopsPayload) {
dropZoneState = 'idle';
return;
}
// We can't fully validate during drag (security), so show as compatible
// and validate on actual drop. For now, show compatible state.
dropZoneState = 'compatible';
dropFeedback = '';
}
// Cursor style for resize directions
function resizeCursor(dir: ResizeDirection): string {
const cursors: Record<ResizeDirection, string> = {
n: 'ns-resize', s: 'ns-resize',
e: 'ew-resize', w: 'ew-resize',
ne: 'nesw-resize', sw: 'nesw-resize',
nw: 'nwse-resize', se: 'nwse-resize',
};
return cursors[dir];
}
const resizeDirections: ResizeDirection[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="blockshell {extraClass}"
class:blockshell-fullscreen={isFullscreen}
class:blockshell-minimized={minimized && !isFullscreen}
class:blockshell-dragging={isDragging}
class:blockshell-resizing={isResizing}
class:blockshell-drop-compatible={dropZoneState === 'compatible'}
class:blockshell-drop-incompatible={dropZoneState === 'incompatible'}
style:width={isFullscreen ? undefined : `${clampedWidth}px`}
style:height={isFullscreen ? undefined : minimized ? undefined : `${clampedHeight}px`}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDropEvent}
role="region"
aria-label={title}
>
<!-- Header / drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="blockshell-header"
onpointerdown={handleDragStart}
onpointermove={handleDragMove}
onpointerup={handleDragEnd}
ondblclick={handleHeaderDblClick}
>
<div class="blockshell-header-title">
{#if icon}
<span class="blockshell-icon" aria-hidden="true">{icon}</span>
{/if}
<span class="blockshell-title">{title}</span>
</div>
<div class="blockshell-controls">
{#if !isFullscreen}
<button
class="blockshell-btn"
onclick={() => onMinimizeChange?.(!minimized)}
title={minimized ? 'Gjenopprett' : 'Minimer'}
aria-label={minimized ? 'Gjenopprett panel' : 'Minimer panel'}
>
{minimized ? '▢' : '▬'}
</button>
{/if}
<button
class="blockshell-btn"
onclick={toggleFullscreen}
title={isFullscreen ? 'Avslutt fullskjerm' : 'Fullskjerm'}
aria-label={isFullscreen ? 'Avslutt fullskjerm' : 'Fullskjerm'}
>
{isFullscreen ? '⊖' : '⊕'}
</button>
{#if closable}
<button
class="blockshell-btn blockshell-btn-close"
onclick={() => onClose?.()}
title="Lukk"
aria-label="Lukk panel"
>
×
</button>
{/if}
</div>
</div>
<!-- Content area (hidden when minimized) -->
{#if !minimized || isFullscreen}
<div class="blockshell-content">
{#if children}
{@render children()}
{:else}
<p class="blockshell-empty">Ingen innhold ennå.</p>
{/if}
</div>
{/if}
<!-- Drop zone overlay -->
{#if dropZoneState !== 'idle'}
<div class="blockshell-drop-overlay">
{#if dropZoneState === 'compatible'}
<span class="blockshell-drop-label">Slipp her</span>
{:else if dropZoneState === 'incompatible'}
<span class="blockshell-drop-label blockshell-drop-label-bad">
{dropFeedback || 'Kan ikke motta dette innholdet'}
</span>
{/if}
</div>
{/if}
<!-- Resize handles (hidden in fullscreen, mobile, and minimized) -->
{#if !isFullscreen && !isMobile && !minimized}
{#each resizeDirections as dir}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="blockshell-resize blockshell-resize-{dir}"
style:cursor={resizeCursor(dir)}
onpointerdown={(e) => handleResizeStart(dir, e)}
></div>
{/each}
{/if}
</div>
<style>
.blockshell {
position: relative;
display: flex;
flex-direction: column;
background: #1c1c20;
border: 1px solid #2a2a2e;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
contain: layout;
}
.blockshell-fullscreen {
position: fixed;
inset: 0;
z-index: 50;
width: 100% !important;
height: 100% !important;
border-radius: 0;
border: none;
}
.blockshell-minimized {
height: auto !important;
min-height: 0;
}
.blockshell-minimized .blockshell-header {
border-bottom: none;
}
.blockshell-dragging {
opacity: 0.95;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.blockshell-resizing {
user-select: none;
}
/* --- Drop zone visual feedback --- */
.blockshell-drop-compatible {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3), 0 0 12px rgba(59, 130, 246, 0.15);
}
.blockshell-drop-incompatible {
border-color: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
}
/* --- Header --- */
.blockshell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid #f3f4f6;
cursor: grab;
user-select: none;
flex-shrink: 0;
min-height: 36px;
background: #141416;
}
.blockshell-dragging .blockshell-header {
cursor: grabbing;
}
.blockshell-header-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.blockshell-icon {
font-size: 14px;
flex-shrink: 0;
}
.blockshell-title {
font-size: 13px;
font-weight: 600;
color: #8a8a96;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.blockshell-controls {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.blockshell-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
color: #8a8a96;
transition: background 0.12s, color 0.12s;
}
.blockshell-btn:hover {
background: #242428;
color: #8a8a96;
}
.blockshell-btn-close:hover {
background: #fee2e2;
color: #dc2626;
}
/* --- Content --- */
.blockshell-content {
flex: 1;
overflow: auto;
min-height: 0;
}
.blockshell-empty {
padding: 16px;
text-align: center;
font-size: 13px;
color: #5a5a66;
}
/* --- Drop overlay --- */
.blockshell-drop-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(59, 130, 246, 0.06);
pointer-events: none;
z-index: 10;
border-radius: 7px;
}
.blockshell-drop-incompatible .blockshell-drop-overlay {
background: rgba(239, 68, 68, 0.06);
}
.blockshell-drop-label {
padding: 6px 14px;
background: rgba(59, 130, 246, 0.9);
color: white;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
}
.blockshell-drop-label-bad {
background: rgba(239, 68, 68, 0.9);
}
/* --- Resize handles --- */
.blockshell-resize {
position: absolute;
z-index: 5;
}
/* Edge handles */
.blockshell-resize-n {
top: -3px;
left: 8px;
right: 8px;
height: 6px;
}
.blockshell-resize-s {
bottom: -3px;
left: 8px;
right: 8px;
height: 6px;
}
.blockshell-resize-e {
right: -3px;
top: 8px;
bottom: 8px;
width: 6px;
}
.blockshell-resize-w {
left: -3px;
top: 8px;
bottom: 8px;
width: 6px;
}
/* Corner handles */
.blockshell-resize-ne {
top: -3px;
right: -3px;
width: 12px;
height: 12px;
}
.blockshell-resize-nw {
top: -3px;
left: -3px;
width: 12px;
height: 12px;
}
.blockshell-resize-se {
bottom: -3px;
right: -3px;
width: 12px;
height: 12px;
}
.blockshell-resize-sw {
bottom: -3px;
left: -3px;
width: 12px;
height: 12px;
}
/* --- Responsive --- */
@media (max-width: 768px) {
.blockshell {
border-radius: 0;
border-left: none;
border-right: none;
width: 100% !important;
}
.blockshell-header {
padding: 8px 12px;
min-height: 44px;
}
.blockshell-btn {
width: 36px;
height: 36px;
font-size: 20px;
}
.blockshell-title {
font-size: 15px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.blockshell-header {
min-height: 40px;
}
.blockshell-btn {
width: 30px;
height: 30px;
}
}
</style>