Lock button
This commit is contained in:
@@ -36,5 +36,5 @@ export default ts.config(
|
||||
parser: ts.parser,
|
||||
},
|
||||
},
|
||||
{ ignores: ['dist/'] },
|
||||
{ ignores: ['dist/', 'buboard-dist/'] },
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
z-index: {item.zIndex};
|
||||
cursor: {cursorStyle()};
|
||||
--handle-scale: {handleScale()};
|
||||
{appState.locked ? 'pointer-events: none;' : ''}
|
||||
"
|
||||
onmousedown={handleMouseDown}
|
||||
ondblclick={handleDoubleClick}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user