Lock button
This commit is contained in:
@@ -36,5 +36,5 @@ export default ts.config(
|
|||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ ignores: ['dist/'] },
|
{ ignores: ['dist/', 'buboard-dist/'] },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<div class="app" class:resizing={isResizing}>
|
<div class="app" class:resizing={isResizing}>
|
||||||
{#if !interfaceHidden}
|
{#if !interfaceHidden}
|
||||||
<Toolbar onHide={() => (interfaceHidden = true)} />
|
<Toolbar onHide={() => { interfaceHidden = true; appState.setLocked(true); }} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<div class="canvas-container">
|
<div class="canvas-container">
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if interfaceHidden}
|
{#if interfaceHidden}
|
||||||
<div class="presentation-controls">
|
<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} />
|
<Eye size={14} />
|
||||||
</button>
|
</button>
|
||||||
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as key (key)}
|
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as key (key)}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
|
|
||||||
function handleWheel(e: WheelEvent) {
|
function handleWheel(e: WheelEvent) {
|
||||||
e.preventDefault();
|
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 rect = container.getBoundingClientRect();
|
||||||
const cx = e.clientX - rect.left;
|
const cx = e.clientX - rect.left;
|
||||||
const cy = e.clientY - rect.top;
|
const cy = e.clientY - rect.top;
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (state.locked) return;
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
@@ -137,6 +139,14 @@
|
|||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
if ((e.target as HTMLElement)?.isContentEditable) 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 mod = e.metaKey || e.ctrlKey;
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom;
|
const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom;
|
||||||
@@ -163,9 +173,6 @@
|
|||||||
for (let i = 1; i < newIds.length; i++) {
|
for (let i = 1; i < newIds.length; i++) {
|
||||||
state.toggleSelection(newIds[i]);
|
state.toggleSelection(newIds[i]);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
state.clearSelection();
|
|
||||||
state.focus(null);
|
|
||||||
} else if (e.key === '[' && hasSelection) {
|
} else if (e.key === '[' && hasSelection) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
for (const id of state.selectedIds) {
|
for (const id of state.selectedIds) {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
z-index: {item.zIndex};
|
z-index: {item.zIndex};
|
||||||
cursor: {cursorStyle()};
|
cursor: {cursorStyle()};
|
||||||
--handle-scale: {handleScale()};
|
--handle-scale: {handleScale()};
|
||||||
|
{appState.locked ? 'pointer-events: none;' : ''}
|
||||||
"
|
"
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
ondblclick={handleDoubleClick}
|
ondblclick={handleDoubleClick}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { Square, Type, Image, Music, Video, Globe } from 'lucide-svelte';
|
import { Square, Type, Image, Music, Video, Globe } from 'lucide-svelte';
|
||||||
import { state } from './state.svelte';
|
import { state } from './state.svelte';
|
||||||
|
|
||||||
|
let locked = $derived(state.locked);
|
||||||
|
|
||||||
let imageInput: HTMLInputElement;
|
let imageInput: HTMLInputElement;
|
||||||
let soundInput: HTMLInputElement;
|
let soundInput: HTMLInputElement;
|
||||||
let videoInput: HTMLInputElement;
|
let videoInput: HTMLInputElement;
|
||||||
@@ -172,36 +174,38 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="palette">
|
{#if !locked}
|
||||||
<button onclick={addTile} title="Tile"><Square size={14} /></button>
|
<div class="palette">
|
||||||
<button onclick={addText} title="Text"><Type size={14} /></button>
|
<button onclick={addTile} title="Tile"><Square size={14} /></button>
|
||||||
<button onclick={() => imageInput.click()} title="Image"><Image size={14} /></button>
|
<button onclick={addText} title="Text"><Type size={14} /></button>
|
||||||
<button onclick={() => soundInput.click()} title="Sound"><Music size={14} /></button>
|
<button onclick={() => imageInput.click()} title="Image"><Image size={14} /></button>
|
||||||
<button onclick={() => videoInput.click()} title="Video"><Video size={14} /></button>
|
<button onclick={() => soundInput.click()} title="Sound"><Music size={14} /></button>
|
||||||
<button onclick={addEmbed} title="Embed"><Globe size={14} /></button>
|
<button onclick={() => videoInput.click()} title="Video"><Video size={14} /></button>
|
||||||
|
<button onclick={addEmbed} title="Embed"><Globe size={14} /></button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={imageInput}
|
bind:this={imageInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onchange={handleImageChange}
|
onchange={handleImageChange}
|
||||||
style="display:none"
|
style="display:none"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
bind:this={soundInput}
|
bind:this={soundInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/*"
|
accept="audio/*"
|
||||||
onchange={handleSoundChange}
|
onchange={handleSoundChange}
|
||||||
style="display:none"
|
style="display:none"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
bind:this={videoInput}
|
bind:this={videoInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
onchange={handleVideoChange}
|
onchange={handleVideoChange}
|
||||||
style="display:none"
|
style="display:none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.palette {
|
.palette {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn } from 'lucide-svelte';
|
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn, Combine, Lock, Unlock } from 'lucide-svelte';
|
||||||
import { exportBoard, importBoard } from './io';
|
import { exportBoard, importBoard, mergeBoard } from './io';
|
||||||
import { state } from './state.svelte';
|
import { state } from './state.svelte';
|
||||||
import Palette from './Palette.svelte';
|
import Palette from './Palette.svelte';
|
||||||
|
|
||||||
let { onHide }: { onHide?: () => void } = $props();
|
let { onHide }: { onHide?: () => void } = $props();
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
let mergeFileInput: HTMLInputElement;
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
const result = await exportBoard();
|
const result = await exportBoard();
|
||||||
@@ -30,6 +31,23 @@
|
|||||||
input.value = '';
|
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() {
|
function handleClear() {
|
||||||
if (confirm('Clear the canvas? This cannot be undone.')) {
|
if (confirm('Clear the canvas? This cannot be undone.')) {
|
||||||
state.reset();
|
state.reset();
|
||||||
@@ -93,10 +111,22 @@
|
|||||||
<span>{zoomPercent}%</span>
|
<span>{zoomPercent}%</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => state.editGlobal(true)} title="Style"><Paintbrush size={14} /></button>
|
<button onclick={() => state.editGlobal(true)} title="Style" disabled={state.locked}><Paintbrush size={14} /></button>
|
||||||
<button onclick={handleClear} title="Clear"><Trash2 size={14} /></button>
|
<button onclick={handleClear} title="Clear" disabled={state.locked}><Trash2 size={14} /></button>
|
||||||
<button onclick={handleImportClick} title="Import"><Upload 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 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}
|
{#if onHide}
|
||||||
<button onclick={onHide} title="Hide interface"><EyeOff size={14} /></button>
|
<button onclick={onHide} title="Hide interface"><EyeOff size={14} /></button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -107,6 +137,14 @@
|
|||||||
onchange={handleFileChange}
|
onchange={handleFileChange}
|
||||||
style="display: none"
|
style="display: none"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
bind:this={mergeFileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".bub"
|
||||||
|
multiple
|
||||||
|
onchange={handleMergeChange}
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -163,6 +201,21 @@
|
|||||||
color: var(--text, #fff);
|
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 {
|
.zoom {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import type { Manifest, AssetStore } from './types';
|
import type { Manifest, AssetStore, Item } from './types';
|
||||||
import { state } from './state.svelte';
|
import { state } from './state.svelte';
|
||||||
|
|
||||||
export async function exportBoard(): Promise<{
|
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 assets = $state<AssetStore>({});
|
||||||
let viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 });
|
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 editingId = $state<string | null>(null);
|
||||||
let editingGlobal = $state<boolean>(false);
|
let editingGlobal = $state<boolean>(false);
|
||||||
let focusedId = $state<string | null>(null);
|
let focusedId = $state<string | null>(null);
|
||||||
let clipboard = $state<Omit<Item, 'id'>[]>([]);
|
let clipboard = $state<Omit<Item, 'id'>[]>([]);
|
||||||
|
let locked = $state<boolean>(false);
|
||||||
|
|
||||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let animationId: number | null = null;
|
let animationId: number | null = null;
|
||||||
@@ -166,6 +168,16 @@ function createState() {
|
|||||||
save();
|
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 {
|
function getItem(id: string): Item | undefined {
|
||||||
return manifest.items.find((i) => i.id === id);
|
return manifest.items.find((i) => i.id === id);
|
||||||
}
|
}
|
||||||
@@ -198,6 +210,16 @@ function createState() {
|
|||||||
if (editing) editingId = null;
|
if (editing) editingId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setLocked(value: boolean) {
|
||||||
|
locked = value;
|
||||||
|
if (value) {
|
||||||
|
clearSelection();
|
||||||
|
focus(null);
|
||||||
|
edit(null);
|
||||||
|
editGlobal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function focus(id: string | null) {
|
function focus(id: string | null) {
|
||||||
focusedId = id;
|
focusedId = id;
|
||||||
}
|
}
|
||||||
@@ -420,19 +442,25 @@ function createState() {
|
|||||||
get focusedId() {
|
get focusedId() {
|
||||||
return focusedId;
|
return focusedId;
|
||||||
},
|
},
|
||||||
|
get locked() {
|
||||||
|
return locked;
|
||||||
|
},
|
||||||
get maxZIndex() {
|
get maxZIndex() {
|
||||||
return maxZIndex;
|
return maxZIndex;
|
||||||
},
|
},
|
||||||
addItem,
|
addItem,
|
||||||
|
addItems,
|
||||||
updateItem,
|
updateItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
addAsset,
|
addAsset,
|
||||||
|
addAssets,
|
||||||
getItem,
|
getItem,
|
||||||
select,
|
select,
|
||||||
toggleSelection,
|
toggleSelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
edit,
|
edit,
|
||||||
editGlobal,
|
editGlobal,
|
||||||
|
setLocked,
|
||||||
focus,
|
focus,
|
||||||
updateSharedCss,
|
updateSharedCss,
|
||||||
updateAppCss,
|
updateAppCss,
|
||||||
|
|||||||
Reference in New Issue
Block a user