Lock button

This commit is contained in:
2025-12-05 00:33:47 +01:00
parent 38b0bc0437
commit 0a3d2eca77
8 changed files with 211 additions and 43 deletions

View File

@@ -36,5 +36,5 @@ export default ts.config(
parser: ts.parser,
},
},
{ ignores: ['dist/'] },
{ ignores: ['dist/', 'buboard-dist/'] },
);

View File

@@ -43,7 +43,7 @@
<div class="app" class:resizing={isResizing}>
{#if !interfaceHidden}
<Toolbar onHide={() => (interfaceHidden = true)} />
<Toolbar onHide={() => { interfaceHidden = true; appState.setLocked(true); }} />
{/if}
<div class="workspace">
<div class="canvas-container">
@@ -63,7 +63,7 @@
</div>
{#if interfaceHidden}
<div class="presentation-controls">
<button class="show-ui" onclick={() => (interfaceHidden = false)} title="Show interface">
<button class="show-ui" onclick={() => { interfaceHidden = false; appState.setLocked(false); }} title="Show interface">
<Eye size={14} />
</button>
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as key (key)}

View File

@@ -38,7 +38,8 @@
function handleWheel(e: WheelEvent) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const delta = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
const factor = Math.pow(0.995, delta);
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
@@ -47,6 +48,7 @@
function handleDrop(e: DragEvent) {
e.preventDefault();
if (state.locked) return;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
@@ -137,6 +139,14 @@
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if ((e.target as HTMLElement)?.isContentEditable) return;
if (e.key === 'Escape') {
state.clearSelection();
state.focus(null);
return;
}
if (state.locked) return;
const mod = e.metaKey || e.ctrlKey;
const rect = container.getBoundingClientRect();
const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom;
@@ -163,9 +173,6 @@
for (let i = 1; i < newIds.length; i++) {
state.toggleSelection(newIds[i]);
}
} else if (e.key === 'Escape') {
state.clearSelection();
state.focus(null);
} else if (e.key === '[' && hasSelection) {
e.preventDefault();
for (const id of state.selectedIds) {

View File

@@ -191,6 +191,7 @@
z-index: {item.zIndex};
cursor: {cursorStyle()};
--handle-scale: {handleScale()};
{appState.locked ? 'pointer-events: none;' : ''}
"
onmousedown={handleMouseDown}
ondblclick={handleDoubleClick}

View File

@@ -2,6 +2,8 @@
import { Square, Type, Image, Music, Video, Globe } from 'lucide-svelte';
import { state } from './state.svelte';
let locked = $derived(state.locked);
let imageInput: HTMLInputElement;
let soundInput: HTMLInputElement;
let videoInput: HTMLInputElement;
@@ -172,36 +174,38 @@
}
</script>
<div class="palette">
<button onclick={addTile} title="Tile"><Square size={14} /></button>
<button onclick={addText} title="Text"><Type size={14} /></button>
<button onclick={() => imageInput.click()} title="Image"><Image size={14} /></button>
<button onclick={() => soundInput.click()} title="Sound"><Music size={14} /></button>
<button onclick={() => videoInput.click()} title="Video"><Video size={14} /></button>
<button onclick={addEmbed} title="Embed"><Globe size={14} /></button>
{#if !locked}
<div class="palette">
<button onclick={addTile} title="Tile"><Square size={14} /></button>
<button onclick={addText} title="Text"><Type size={14} /></button>
<button onclick={() => imageInput.click()} title="Image"><Image size={14} /></button>
<button onclick={() => soundInput.click()} title="Sound"><Music size={14} /></button>
<button onclick={() => videoInput.click()} title="Video"><Video size={14} /></button>
<button onclick={addEmbed} title="Embed"><Globe size={14} /></button>
<input
bind:this={imageInput}
type="file"
accept="image/*"
onchange={handleImageChange}
style="display:none"
/>
<input
bind:this={soundInput}
type="file"
accept="audio/*"
onchange={handleSoundChange}
style="display:none"
/>
<input
bind:this={videoInput}
type="file"
accept="video/*"
onchange={handleVideoChange}
style="display:none"
/>
</div>
<input
bind:this={imageInput}
type="file"
accept="image/*"
onchange={handleImageChange}
style="display:none"
/>
<input
bind:this={soundInput}
type="file"
accept="audio/*"
onchange={handleSoundChange}
style="display:none"
/>
<input
bind:this={videoInput}
type="file"
accept="video/*"
onchange={handleVideoChange}
style="display:none"
/>
</div>
{/if}
<style>
.palette {

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn } from 'lucide-svelte';
import { exportBoard, importBoard } from './io';
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn, Combine, Lock, Unlock } from 'lucide-svelte';
import { exportBoard, importBoard, mergeBoard } from './io';
import { state } from './state.svelte';
import Palette from './Palette.svelte';
let { onHide }: { onHide?: () => void } = $props();
let fileInput: HTMLInputElement;
let mergeFileInput: HTMLInputElement;
async function handleExport() {
const result = await exportBoard();
@@ -30,6 +31,23 @@
input.value = '';
}
function handleMergeClick() {
mergeFileInput.click();
}
async function handleMergeChange(e: Event) {
const input = e.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) return;
for (const file of files) {
const result = await mergeBoard(file);
if (!result.success) {
alert(result.error || `Merge failed for ${file.name}`);
}
}
input.value = '';
}
function handleClear() {
if (confirm('Clear the canvas? This cannot be undone.')) {
state.reset();
@@ -93,10 +111,22 @@
<span>{zoomPercent}%</span>
</button>
</div>
<button onclick={() => state.editGlobal(true)} title="Style"><Paintbrush size={14} /></button>
<button onclick={handleClear} title="Clear"><Trash2 size={14} /></button>
<button onclick={handleImportClick} title="Import"><Upload size={14} /></button>
<button onclick={() => state.editGlobal(true)} title="Style" disabled={state.locked}><Paintbrush size={14} /></button>
<button onclick={handleClear} title="Clear" disabled={state.locked}><Trash2 size={14} /></button>
<button onclick={handleImportClick} title="Import" disabled={state.locked}><Upload size={14} /></button>
<button onclick={handleMergeClick} title="Merge" disabled={state.locked}><Combine size={14} /></button>
<button onclick={handleExport} title="Export"><Download size={14} /></button>
<button
class:active={state.locked}
onclick={() => state.setLocked(!state.locked)}
title={state.locked ? 'Unlock' : 'Lock'}
>
{#if state.locked}
<Unlock size={14} />
{:else}
<Lock size={14} />
{/if}
</button>
{#if onHide}
<button onclick={onHide} title="Hide interface"><EyeOff size={14} /></button>
{/if}
@@ -107,6 +137,14 @@
onchange={handleFileChange}
style="display: none"
/>
<input
bind:this={mergeFileInput}
type="file"
accept=".bub"
multiple
onchange={handleMergeChange}
style="display: none"
/>
</div>
<style>
@@ -163,6 +201,21 @@
color: var(--text, #fff);
}
button.active {
background: var(--accent, #4a9eff);
color: var(--text, #fff);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button:disabled:hover {
background: var(--surface, #282c34);
color: var(--text-dim, #666);
}
.zoom {
display: flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
import JSZip from 'jszip';
import type { Manifest, AssetStore } from './types';
import type { Manifest, AssetStore, Item } from './types';
import { state } from './state.svelte';
export async function exportBoard(): Promise<{
@@ -110,3 +110,78 @@ export async function importBoard(
};
}
}
export async function mergeBoard(
file: File,
): Promise<{ success: boolean; error?: string; itemCount?: number }> {
try {
const zip = await JSZip.loadAsync(file);
const manifestFile = zip.file('manifest.json');
if (!manifestFile)
throw new Error('Invalid .bub file: missing manifest.json');
const manifestJson = await manifestFile.async('string');
const raw = JSON.parse(manifestJson);
if (raw.version !== 1)
throw new Error(`Unsupported manifest version: ${raw.version}`);
const incomingItems: Item[] = raw.items;
if (incomingItems.length === 0) {
return { success: true, itemCount: 0 };
}
const assetIdMap = new Map<string, string>();
const newAssets: AssetStore = {};
for (const item of incomingItems) {
if (item.assetId && !assetIdMap.has(item.assetId)) {
const newAssetId = crypto.randomUUID();
assetIdMap.set(item.assetId, newAssetId);
const assetFiles = zip
.folder('assets')
?.file(new RegExp(`^${item.assetId}\\.`));
if (assetFiles && assetFiles.length > 0) {
const assetFile = assetFiles[0];
const blob = await assetFile.async('blob');
const url = URL.createObjectURL(blob);
const filename = assetFile.name.split('/').pop() || 'asset';
newAssets[newAssetId] = { blob, url, filename };
}
}
}
const minZIndex = Math.min(...incomingItems.map((i) => i.zIndex));
const zIndexOffset = state.maxZIndex + 1 - minZIndex;
const newItems: Item[] = incomingItems.map((item) => {
const newId = crypto.randomUUID();
const newAssetId = item.assetId ? assetIdMap.get(item.assetId) : undefined;
let html = item.html;
if (newAssetId && newAssets[newAssetId]) {
html = html.replace(/src="[^"]*"/g, `src="${newAssets[newAssetId].url}"`);
}
return {
...item,
id: newId,
assetId: newAssetId,
zIndex: item.zIndex + zIndexOffset,
html,
};
});
state.addAssets(newAssets);
state.addItems(newItems);
return { success: true, itemCount: newItems.length };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : 'Merge failed',
};
}
}

View File

@@ -73,11 +73,13 @@ function createState() {
});
let assets = $state<AssetStore>({});
let viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 });
let selectedIds = new SvelteSet<string>();
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedIds = $state(new SvelteSet<string>());
let editingId = $state<string | null>(null);
let editingGlobal = $state<boolean>(false);
let focusedId = $state<string | null>(null);
let clipboard = $state<Omit<Item, 'id'>[]>([]);
let locked = $state<boolean>(false);
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let animationId: number | null = null;
@@ -166,6 +168,16 @@ function createState() {
save();
}
function addItems(items: Item[]) {
manifest.items.push(...items);
save();
}
function addAssets(newAssets: AssetStore) {
Object.assign(assets, newAssets);
save();
}
function getItem(id: string): Item | undefined {
return manifest.items.find((i) => i.id === id);
}
@@ -198,6 +210,16 @@ function createState() {
if (editing) editingId = null;
}
function setLocked(value: boolean) {
locked = value;
if (value) {
clearSelection();
focus(null);
edit(null);
editGlobal(false);
}
}
function focus(id: string | null) {
focusedId = id;
}
@@ -420,19 +442,25 @@ function createState() {
get focusedId() {
return focusedId;
},
get locked() {
return locked;
},
get maxZIndex() {
return maxZIndex;
},
addItem,
addItems,
updateItem,
removeItem,
addAsset,
addAssets,
getItem,
select,
toggleSelection,
clearSelection,
edit,
editGlobal,
setLocked,
focus,
updateSharedCss,
updateAppCss,