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>
691 lines
16 KiB
Svelte
691 lines
16 KiB
Svelte
<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>
|