This commit is contained in:
2025-12-01 17:42:42 +01:00
commit e9b0e9d856
26 changed files with 3438 additions and 0 deletions

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
*.local
# Tauri
src-tauri/target/
src-tauri/WixTools/
src-tauri/Cargo.lock
# Rust
**/*.rs.bk
*.pdb
# Editor
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Env
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Cache
.cache/
*.tsbuildinfo
.eslintcache
.prettiercache
# Test
coverage/
# Temp
*.tmp
*.temp

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "buboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
},
"dependencies": {
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.8",
"@lezer/highlight": "^1.2.3",
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",
"codemirror": "^6.0.2",
"jszip": "^3.10.1",
"lucide-svelte": "^0.555.0"
}
}

1138
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

163
src/App.svelte Normal file
View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { Eye } from 'lucide-svelte';
import Canvas from './lib/Canvas.svelte';
import Toolbar from './lib/Toolbar.svelte';
import Editor from './lib/Editor.svelte';
import { state as appState } from './lib/state.svelte';
let editingItem = $derived(appState.editingId ? appState.getItem(appState.editingId) : null);
let showEditor = $derived(editingItem || appState.editingGlobal);
let editorWidth = $state(320);
let isResizing = $state(false);
let interfaceHidden = $state(false);
$effect(() => {
if (appState.editingId || appState.editingGlobal) {
interfaceHidden = false;
}
});
function handleResizeStart(e: MouseEvent) {
e.preventDefault();
isResizing = true;
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
editorWidth = Math.max(200, Math.min(800, newWidth));
}
function handleResizeEnd() {
isResizing = false;
}
</script>
<svelte:head>
{@html `<style>${appState.manifest.appCss}</style>`}
</svelte:head>
<svelte:window onmousemove={handleResizeMove} onmouseup={handleResizeEnd} />
<div class="app" class:resizing={isResizing}>
{#if !interfaceHidden}
<Toolbar onHide={() => (interfaceHidden = true)} />
{/if}
<div class="workspace">
<div class="canvas-container">
<Canvas />
</div>
{#if showEditor && !interfaceHidden}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart}></div>
<div class="editor-panel" style="width: {editorWidth}px">
{#if editingItem}
<Editor mode="item" item={editingItem} onClose={() => appState.edit(null)} />
{:else}
<Editor mode="global" onClose={() => appState.editGlobal(false)} />
{/if}
</div>
{/if}
</div>
{#if interfaceHidden}
<div class="presentation-controls">
<button class="show-ui" onclick={() => (interfaceHidden = false)} title="Show interface">
<Eye size={14} />
</button>
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as key}
<button
class="flag"
class:filled={appState.hasFlag(key)}
onclick={() => (appState.hasFlag(key) ? appState.gotoFlag(key) : appState.setFlag(key))}
oncontextmenu={(e) => {
e.preventDefault();
appState.clearFlag(key);
}}
title="Position {key}"
>
{key}
</button>
{/each}
</div>
{/if}
</div>
<style>
.app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app.resizing {
cursor: ew-resize;
user-select: none;
}
.workspace {
flex: 1;
display: flex;
overflow: hidden;
}
.canvas-container {
flex: 1;
overflow: hidden;
}
.resize-handle {
width: 4px;
background: var(--border, #333);
cursor: ew-resize;
flex-shrink: 0;
}
.resize-handle:hover {
background: var(--accent, #4a9eff);
}
.editor-panel {
flex-shrink: 0;
}
.presentation-controls {
position: fixed;
top: 8px;
right: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.presentation-controls button {
padding: 6px;
background: var(--surface, #282c34);
color: var(--text-dim, #666);
border: 1px solid var(--border, #333);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.presentation-controls button:hover {
background: var(--accent, #4a9eff);
color: var(--text, #fff);
}
.presentation-controls .flag {
width: 26px;
height: 24px;
padding: 0;
font-size: 11px;
font-weight: bold;
}
.presentation-controls .flag.filled {
background: var(--accent, #4a9eff);
color: var(--text, #fff);
}
</style>

28
src/app.css Normal file
View File

@@ -0,0 +1,28 @@
@font-face {
font-family: 'Departure Mono';
src: url('/fonts/DepartureMono-Regular.woff2') format('woff2'),
url('/fonts/DepartureMono-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
font-family: 'Departure Mono', monospace;
color: var(--text, #fff);
background: var(--bg, #1a1a1a);
}
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}

174
src/lib/Canvas.svelte Normal file
View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { state } from './state.svelte';
import Item from './Item.svelte';
let container: HTMLDivElement;
let isPanning = false;
let lastX = 0;
let lastY = 0;
function handleMouseDown(e: MouseEvent) {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
isPanning = true;
lastX = e.clientX;
lastY = e.clientY;
e.preventDefault();
} else if (e.button === 0 && e.target === container) {
state.select(null);
state.focus(null);
}
}
function handleMouseMove(e: MouseEvent) {
if (!isPanning) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
state.pan(dx, dy);
}
function handleMouseUp() {
isPanning = false;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
state.zoomAt(factor, cx, cy);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const rect = container.getBoundingClientRect();
const dropX = (e.clientX - rect.left - state.viewport.x) / state.viewport.zoom;
const dropY = (e.clientY - rect.top - state.viewport.y) / state.viewport.zoom;
for (const file of files) {
handleFile(file, dropX, dropY);
}
}
function handleFile(file: File, x: number, y: number) {
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
if (file.type.startsWith('image/')) {
const img = new Image();
img.onload = () => {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
id,
assetId,
html: `<img src="${url}" alt="" />`,
css: `img {
width: 100%;
height: 100%;
object-fit: contain;
}`,
x,
y,
width: img.naturalWidth,
height: img.naturalHeight,
rotation: 0,
zIndex: state.maxZIndex + 1
});
};
img.src = url;
} else if (file.type.startsWith('audio/')) {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
id,
assetId,
html: `<audio src="${url}" controls></audio>`,
css: `audio {
width: 100%;
}`,
x,
y,
width: 300,
height: 54,
rotation: 0,
zIndex: state.maxZIndex + 1
});
} else if (file.type.startsWith('video/')) {
const video = document.createElement('video');
video.onloadedmetadata = () => {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
id,
assetId,
html: `<video src="${url}" controls></video>`,
css: `video {
width: 100%;
height: 100%;
}`,
x,
y,
width: video.videoWidth || 640,
height: video.videoHeight || 360,
rotation: 0,
zIndex: state.maxZIndex + 1
});
};
video.src = url;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
}
</script>
<svelte:window onmouseup={handleMouseUp} onmousemove={handleMouseMove} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={container}
class="canvas"
onmousedown={handleMouseDown}
onwheel={handleWheel}
ondrop={handleDrop}
ondragover={handleDragOver}
>
<div
class="viewport"
style="transform: translate({state.viewport.x}px, {state.viewport.y}px) scale({state.viewport
.zoom})"
>
{#each state.manifest.items as item (item.id)}
<Item {item} />
{/each}
</div>
</div>
<style>
.canvas {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: var(--bg, #1a1a1a);
background-image: radial-gradient(circle, var(--border, #333) 1px, transparent 1px);
background-size: 20px 20px;
cursor: grab;
}
.canvas:active {
cursor: grabbing;
}
.viewport {
transform-origin: 0 0;
position: absolute;
top: 0;
left: 0;
}
</style>

258
src/lib/Editor.svelte Normal file
View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { Code, Palette, X, Globe, Layers } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { defaultKeymap } from '@codemirror/commands';
import { vim } from '@replit/codemirror-vim';
import { emacs } from '@replit/codemirror-emacs';
import { state as appState } from './state.svelte';
import { createTheme } from './theme';
import type { Item } from './types';
type EditMode = 'normal' | 'vim' | 'emacs';
const themeCompartment = new Compartment();
const keymapCompartment = new Compartment();
let { mode = 'item', item, onClose }: { mode?: 'item' | 'global'; item?: Item; onClose: () => void } = $props();
let activeTab = $state<'html' | 'css' | 'shared' | 'app'>(mode === 'item' ? 'html' : 'shared');
let editMode = $state<EditMode>('normal');
function getKeymapExtension(m: EditMode) {
switch (m) {
case 'vim':
return vim();
case 'emacs':
return emacs();
default:
return [];
}
}
function cycleEditMode() {
const modes: EditMode[] = ['normal', 'vim', 'emacs'];
const idx = modes.indexOf(editMode);
editMode = modes[(idx + 1) % modes.length];
}
let htmlContainer: HTMLDivElement;
let cssContainer: HTMLDivElement;
let sharedContainer: HTMLDivElement;
let appContainer: HTMLDivElement;
let htmlEditor: EditorView | null = null;
let cssEditor: EditorView | null = null;
let sharedEditor: EditorView | null = null;
let appEditor: EditorView | null = null;
function createItemEditor(
container: HTMLDivElement,
lang: 'html' | 'css',
initialValue: string
): EditorView {
const extensions = [
keymapCompartment.of(getKeymapExtension(editMode)),
keymap.of(defaultKeymap),
themeCompartment.of(createTheme()),
lang === 'html' ? html() : css(),
EditorView.updateListener.of((update) => {
if (update.docChanged && item) {
const value = update.state.doc.toString();
if (lang === 'html') {
appState.updateItem(item.id, { html: value });
} else {
appState.updateItem(item.id, { css: value });
}
}
})
];
return new EditorView({
state: EditorState.create({ doc: initialValue, extensions }),
parent: container
});
}
function createGlobalEditor(
container: HTMLDivElement,
type: 'shared' | 'app',
initialValue: string
): EditorView {
const extensions = [
keymapCompartment.of(getKeymapExtension(editMode)),
keymap.of(defaultKeymap),
themeCompartment.of(createTheme()),
css(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const value = update.state.doc.toString();
if (type === 'shared') {
appState.updateSharedCss(value);
} else {
appState.updateAppCss(value);
}
}
})
];
return new EditorView({
state: EditorState.create({ doc: initialValue, extensions }),
parent: container
});
}
onMount(() => {
if (mode === 'item' && item) {
if (htmlContainer) {
htmlEditor = createItemEditor(htmlContainer, 'html', item.html);
}
if (cssContainer) {
cssEditor = createItemEditor(cssContainer, 'css', item.css);
}
}
if (sharedContainer) {
sharedEditor = createGlobalEditor(sharedContainer, 'shared', appState.manifest.sharedCss);
}
if (appContainer) {
appEditor = createGlobalEditor(appContainer, 'app', appState.manifest.appCss);
}
});
onDestroy(() => {
htmlEditor?.destroy();
cssEditor?.destroy();
sharedEditor?.destroy();
appEditor?.destroy();
});
let previousAppCss = $state(appState.manifest.appCss);
$effect(() => {
const currentAppCss = appState.manifest.appCss;
if (currentAppCss !== previousAppCss) {
previousAppCss = currentAppCss;
setTimeout(() => {
const newTheme = createTheme();
htmlEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
cssEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
sharedEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
appEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
}, 50);
}
});
$effect(() => {
const ext = getKeymapExtension(editMode);
htmlEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
cssEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
sharedEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
appEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
});
</script>
<div class="panel">
<div class="header">
<div class="tabs">
{#if mode === 'item'}
<button class:active={activeTab === 'html'} onclick={() => (activeTab = 'html')} title="HTML"><Code size={12} /></button>
<button class:active={activeTab === 'css'} onclick={() => (activeTab = 'css')} title="Item CSS"><Palette size={12} /></button>
{/if}
<button class:active={activeTab === 'shared'} onclick={() => (activeTab = 'shared')} title="Shared CSS"><Layers size={12} /></button>
<button class:active={activeTab === 'app'} onclick={() => (activeTab = 'app')} title="App CSS"><Globe size={12} /></button>
</div>
<button class="mode" onclick={cycleEditMode} title="Editor mode: {editMode}">{editMode}</button>
<button class="close" onclick={onClose} title="Close"><X size={12} /></button>
</div>
<div class="editor-container">
{#if mode === 'item'}
<div class="editor" class:hidden={activeTab !== 'html'} bind:this={htmlContainer}></div>
<div class="editor" class:hidden={activeTab !== 'css'} bind:this={cssContainer}></div>
{/if}
<div class="editor" class:hidden={activeTab !== 'shared'} bind:this={sharedContainer}></div>
<div class="editor" class:hidden={activeTab !== 'app'} bind:this={appContainer}></div>
</div>
</div>
<style>
.panel {
width: 100%;
height: 100%;
background: var(--surface, #282c34);
display: flex;
flex-direction: column;
border-left: 1px solid var(--border, #333);
}
.header {
display: flex;
background: var(--surface, #282c34);
border-bottom: 1px solid var(--border, #333);
flex-shrink: 0;
}
.tabs {
display: flex;
flex: 1;
}
.tabs button {
padding: 6px 10px;
background: transparent;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
}
.tabs button:hover {
color: var(--text, #fff);
}
.tabs button.active {
color: var(--accent, #4a9eff);
}
.mode {
padding: 6px 8px;
background: transparent;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
font-size: 10px;
text-transform: uppercase;
}
.mode:hover {
color: var(--text, #fff);
}
.close {
padding: 6px 8px;
background: transparent;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
}
.close:hover {
color: var(--text, #fff);
}
.editor-container {
flex: 1;
position: relative;
overflow: hidden;
}
.editor {
position: absolute;
inset: 0;
}
.editor.hidden {
display: none;
}
</style>

298
src/lib/Item.svelte Normal file
View File

@@ -0,0 +1,298 @@
<script lang="ts">
import type { Item } from './types';
import { state as appState } from './state.svelte';
import { calculateCenterOffset, constrainToAspectRatio } from './geometry';
let { item }: { item: Item } = $props();
let isSelected = $derived(appState.selectedId === item.id);
let isFocused = $derived(appState.focusedId === item.id);
let isDragging = $state(false);
let isResizing = $state(false);
let isRotating = $state(false);
let dragStart = { x: 0, y: 0, itemX: 0, itemY: 0 };
let resizeStart = { x: 0, y: 0, width: 0, height: 0, itemX: 0, itemY: 0, corner: '', aspectRatio: 1 };
let rotateStart = { angle: 0, startAngle: 0 };
let handleScale = $derived(() => {
const rawScale = 1 / appState.viewport.zoom;
return Math.min(16 / 12, Math.max(8 / 12, rawScale));
});
let cursorStyle = $derived(() => {
if (isDragging || isRotating) return 'grabbing';
return 'move';
});
let srcdoc = $derived(() => {
return `<!DOCTYPE html>
<html>
<head>
<style>${appState.manifest.sharedCss}</style>
<style>${item.css}</style>
</head>
<body>${item.html}</body>
</html>`;
});
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
if (isFocused) return;
e.stopPropagation();
appState.select(item.id);
appState.bringToFront(item.id);
isDragging = true;
dragStart = {
x: e.clientX,
y: e.clientY,
itemX: item.x,
itemY: item.y
};
}
function handleResizeStart(e: MouseEvent, corner: string) {
e.stopPropagation();
isResizing = true;
resizeStart = {
x: e.clientX,
y: e.clientY,
width: item.width,
height: item.height,
itemX: item.x,
itemY: item.y,
corner,
aspectRatio: item.width / item.height
};
}
function handleRotateStart(e: MouseEvent) {
e.stopPropagation();
isRotating = true;
const el = document.querySelector(`[data-item-id="${item.id}"]`);
if (!el) return;
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
rotateStart = {
angle: item.rotation,
startAngle: Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI)
};
}
function handleMouseMove(e: MouseEvent) {
if (isDragging) {
const dx = (e.clientX - dragStart.x) / appState.viewport.zoom;
const dy = (e.clientY - dragStart.y) / appState.viewport.zoom;
appState.updateItem(item.id, {
x: dragStart.itemX + dx,
y: dragStart.itemY + dy
});
} else if (isResizing) {
const dx = (e.clientX - resizeStart.x) / appState.viewport.zoom;
const dy = (e.clientY - resizeStart.y) / appState.viewport.zoom;
const cos = Math.cos((item.rotation * Math.PI) / 180);
const sin = Math.sin((item.rotation * Math.PI) / 180);
const rdx = dx * cos + dy * sin;
const rdy = -dx * sin + dy * cos;
let newWidth = resizeStart.width;
let newHeight = resizeStart.height;
if (resizeStart.corner.includes('e')) newWidth = Math.max(20, resizeStart.width + rdx);
if (resizeStart.corner.includes('w')) newWidth = Math.max(20, resizeStart.width - rdx);
if (resizeStart.corner.includes('s')) newHeight = Math.max(20, resizeStart.height + rdy);
if (resizeStart.corner.includes('n')) newHeight = Math.max(20, resizeStart.height - rdy);
if (e.shiftKey) {
const constrained = constrainToAspectRatio(newWidth, newHeight, resizeStart.aspectRatio);
newWidth = constrained.width;
newHeight = constrained.height;
}
const deltaWidth = newWidth - resizeStart.width;
const deltaHeight = newHeight - resizeStart.height;
const offset = calculateCenterOffset(resizeStart.corner, deltaWidth, deltaHeight, item.rotation);
appState.updateItem(item.id, {
width: newWidth,
height: newHeight,
x: resizeStart.itemX + offset.x,
y: resizeStart.itemY + offset.y
});
} else if (isRotating) {
const el = document.querySelector(`[data-item-id="${item.id}"]`);
if (!el) return;
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
let rotation = rotateStart.angle + (angle - rotateStart.startAngle);
rotation = ((rotation % 360) + 360) % 360;
appState.updateItem(item.id, { rotation });
}
}
function handleMouseUp() {
isDragging = false;
isResizing = false;
isRotating = false;
}
function handleKeyDown(e: KeyboardEvent) {
if (!isSelected) return;
if (appState.editingId) return;
if (isFocused) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if ((e.target as HTMLElement)?.isContentEditable) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
appState.removeItem(item.id);
}
}
function handleDoubleClick(e: MouseEvent) {
e.stopPropagation();
appState.focus(item.id);
appState.edit(item.id);
}
</script>
<svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} onkeydown={handleKeyDown} />
<div
class="item"
class:selected={isSelected}
class:focused={isFocused}
data-item-id={item.id}
style="
left: {item.x - item.width / 2}px;
top: {item.y - item.height / 2}px;
width: {item.width}px;
height: {item.height}px;
transform: rotate({item.rotation}deg);
z-index: {item.zIndex};
cursor: {cursorStyle()};
--handle-scale: {handleScale()};
"
onmousedown={handleMouseDown}
ondblclick={handleDoubleClick}
role="button"
tabindex="0"
>
<iframe
class="content"
title="Item content"
srcdoc={srcdoc()}
sandbox="allow-scripts allow-same-origin"
></iframe>
{#if !isFocused}
<div class="interaction-layer"></div>
{/if}
{#if isSelected}
<div class="handles">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="handle nw" onmousedown={(e) => handleResizeStart(e, 'nw')}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="handle ne" onmousedown={(e) => handleResizeStart(e, 'ne')}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="handle sw" onmousedown={(e) => handleResizeStart(e, 'sw')}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="handle se" onmousedown={(e) => handleResizeStart(e, 'se')}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="handle rotate" onmousedown={handleRotateStart}></div>
<div class="rotate-line"></div>
</div>
{/if}
</div>
<style>
.item {
position: absolute;
box-sizing: border-box;
}
.item.selected {
outline: 2px solid var(--accent, #4a9eff);
}
.item.focused {
outline: 2px solid var(--accent, #4a9eff);
outline-style: dashed;
}
.content {
width: 100%;
height: 100%;
border: none;
display: block;
background: transparent;
}
.interaction-layer {
position: absolute;
inset: 0;
}
.handles {
position: absolute;
inset: calc(-6px * var(--handle-scale, 1));
pointer-events: none;
}
.handle {
position: absolute;
width: calc(12px * var(--handle-scale, 1));
height: calc(12px * var(--handle-scale, 1));
background: var(--accent, #4a9eff);
pointer-events: auto;
}
.handle.nw {
top: 0;
left: 0;
cursor: nw-resize;
}
.handle.ne {
top: 0;
right: 0;
cursor: ne-resize;
}
.handle.sw {
bottom: 0;
left: 0;
cursor: sw-resize;
}
.handle.se {
bottom: 0;
right: 0;
cursor: se-resize;
}
.handle.rotate {
top: calc(-30px * var(--handle-scale, 1));
left: 50%;
transform: translateX(-50%);
cursor: grab;
}
.handle.rotate:active {
cursor: grabbing;
}
.rotate-line {
position: absolute;
top: calc(-24px * var(--handle-scale, 1));
left: 50%;
width: 1px;
height: calc(18px * var(--handle-scale, 1));
background: var(--accent, #4a9eff);
transform: translateX(-50%);
pointer-events: none;
}
</style>

224
src/lib/Palette.svelte Normal file
View File

@@ -0,0 +1,224 @@
<script lang="ts">
import { Square, Type, Image, Music, Video, Globe } from 'lucide-svelte';
import { state } from './state.svelte';
let imageInput: HTMLInputElement;
let soundInput: HTMLInputElement;
let videoInput: HTMLInputElement;
function addTile() {
const id = crypto.randomUUID();
state.addItem({
id,
html: '',
css: '',
x: -state.viewport.x / state.viewport.zoom + 400,
y: -state.viewport.y / state.viewport.zoom + 300,
width: 200,
height: 200,
rotation: 0,
zIndex: state.maxZIndex + 1
});
state.select(id);
}
function addText() {
const id = crypto.randomUUID();
state.addItem({
id,
html: '<p>Text</p>',
css: `p {
color: #fff;
font-size: 24px;
font-family: 'Departure Mono', monospace;
}`,
x: -state.viewport.x / state.viewport.zoom + 400,
y: -state.viewport.y / state.viewport.zoom + 300,
width: 200,
height: 50,
rotation: 0,
zIndex: state.maxZIndex + 1
});
state.select(id);
}
function addEmbed() {
const url = prompt('Enter URL to embed:');
if (!url) return;
const id = crypto.randomUUID();
state.addItem({
id,
html: `<iframe src="${url}" frameborder="0" allowfullscreen></iframe>`,
css: `iframe {
width: 100%;
height: 100%;
border: none;
}`,
x: -state.viewport.x / state.viewport.zoom + 400,
y: -state.viewport.y / state.viewport.zoom + 300,
width: 640,
height: 480,
rotation: 0,
zIndex: state.maxZIndex + 1
});
state.select(id);
}
function handleImageChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
addImageItem(file);
(e.target as HTMLInputElement).value = '';
}
function handleSoundChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
addSoundItem(file);
(e.target as HTMLInputElement).value = '';
}
function handleVideoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
addVideoItem(file);
(e.target as HTMLInputElement).value = '';
}
function addImageItem(file: File) {
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
const x = -state.viewport.x / state.viewport.zoom + 400;
const y = -state.viewport.y / state.viewport.zoom + 300;
const img = new Image();
img.onload = () => {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
id,
assetId,
html: `<img src="${url}" alt="" />`,
css: `img {
width: 100%;
height: 100%;
object-fit: contain;
}`,
x,
y,
width: img.naturalWidth,
height: img.naturalHeight,
rotation: 0,
zIndex: state.maxZIndex + 1
});
state.select(id);
};
img.src = url;
}
function addSoundItem(file: File) {
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
const x = -state.viewport.x / state.viewport.zoom + 400;
const y = -state.viewport.y / state.viewport.zoom + 300;
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
id,
assetId,
html: `<audio src="${url}" controls></audio>`,
css: `audio {
width: 100%;
}`,
x,
y,
width: 300,
height: 54,
rotation: 0,
zIndex: state.maxZIndex + 1
});
state.select(id);
}
function addVideoItem(file: File) {
const id = crypto.randomUUID();
const assetId = crypto.randomUUID();
const url = URL.createObjectURL(file);
const x = -state.viewport.x / state.viewport.zoom + 400;
const y = -state.viewport.y / state.viewport.zoom + 300;
const video = document.createElement('video');
video.onloadedmetadata = () => {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
id,
assetId,
html: `<video src="${url}" controls></video>`,
css: `video {
width: 100%;
height: 100%;
}`,
x,
y,
width: video.videoWidth || 640,
height: video.videoHeight || 360,
rotation: 0,
zIndex: state.maxZIndex + 1
});
state.select(id);
};
video.src = url;
}
</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>
<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>
<style>
.palette {
display: flex;
gap: 4px;
}
button {
padding: 4px 8px;
background: var(--surface, #282c34);
color: var(--text-dim, #666);
border: 1px solid var(--border, #333);
cursor: pointer;
}
button:hover {
background: var(--accent, #4a9eff);
color: var(--text, #fff);
}
</style>

207
src/lib/Toolbar.svelte Normal file
View File

@@ -0,0 +1,207 @@
<script lang="ts">
import { Upload, Download, Paintbrush, Trash2, EyeOff } from 'lucide-svelte';
import { exportBoard, importBoard } from './io';
import { state } from './state.svelte';
import Palette from './Palette.svelte';
let { onHide }: { onHide?: () => void } = $props();
let fileInput: HTMLInputElement;
async function handleExport() {
const result = await exportBoard();
if (!result.success) {
alert(result.error || 'Export failed');
}
}
function handleImportClick() {
fileInput.click();
}
async function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const result = await importBoard(file);
if (!result.success) {
alert(result.error || 'Import failed');
}
input.value = '';
}
function handleClear() {
if (confirm('Clear the canvas? This cannot be undone.')) {
state.reset();
}
}
function handleZoom(e: Event) {
const value = parseFloat((e.target as HTMLInputElement).value);
state.setZoom(value);
}
function handleFlagClick(key: string) {
if (state.hasFlag(key)) {
state.gotoFlag(key);
} else {
state.setFlag(key);
}
}
function handleFlagRightClick(e: MouseEvent, key: string) {
e.preventDefault();
state.clearFlag(key);
}
let zoomPercent = $derived(Math.round(state.viewport.zoom * 100));
const flagKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
</script>
<div class="toolbar">
<span class="app-name">Buboard</span>
<Palette />
<div class="flags">
{#each flagKeys as key}
<button
class="flag"
class:filled={state.hasFlag(key)}
onclick={() => handleFlagClick(key)}
oncontextmenu={(e) => handleFlagRightClick(e, key)}
title="Position {key}"
>
{key}
</button>
{/each}
</div>
<div class="spacer"></div>
<div class="zoom">
<input
type="range"
min="0.1"
max="5"
step="0.1"
value={state.viewport.zoom}
oninput={handleZoom}
title="Zoom"
/>
<span class="zoom-label">{zoomPercent}%</span>
</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={handleExport} title="Export"><Download size={14} /></button>
{#if onHide}
<button onclick={onHide} title="Hide interface"><EyeOff size={14} /></button>
{/if}
<input
bind:this={fileInput}
type="file"
accept=".bub"
onchange={handleFileChange}
style="display: none"
/>
</div>
<style>
.toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
background: var(--surface, #282c34);
border-bottom: 1px solid var(--border, #333);
flex-shrink: 0;
}
.app-name {
font-size: 12px;
font-weight: bold;
color: var(--text-dim, #666);
display: flex;
align-items: center;
padding: 0 8px 0 4px;
}
.spacer {
flex: 1;
}
.flags {
display: flex;
gap: 2px;
}
.flag {
width: 24px;
height: 24px;
padding: 0;
font-size: 11px;
font-weight: bold;
}
.flag.filled {
background: var(--accent, #4a9eff);
color: var(--text, #fff);
}
button {
padding: 4px 8px;
background: var(--surface, #282c34);
color: var(--text-dim, #666);
border: 1px solid var(--border, #333);
cursor: pointer;
}
button:hover {
background: var(--accent, #4a9eff);
color: var(--text, #fff);
}
.zoom {
display: flex;
align-items: center;
gap: 4px;
}
.zoom input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
background: var(--border, #333);
border: none;
cursor: pointer;
}
.zoom input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: var(--text-dim, #666);
cursor: pointer;
}
.zoom input[type='range']::-moz-range-thumb {
width: 12px;
height: 12px;
background: var(--text-dim, #666);
border: none;
cursor: pointer;
}
.zoom input[type='range']:hover::-webkit-slider-thumb {
background: var(--accent, #4a9eff);
}
.zoom input[type='range']:hover::-moz-range-thumb {
background: var(--accent, #4a9eff);
}
.zoom-label {
font-size: 11px;
color: var(--text-dim, #666);
min-width: 36px;
text-align: right;
}
</style>

81
src/lib/geometry.ts Normal file
View File

@@ -0,0 +1,81 @@
export interface Point {
x: number;
y: number;
}
export function calculateCenterOffset(
corner: string,
deltaWidth: number,
deltaHeight: number,
rotation: number
): Point {
const rad = (rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
let localDx = 0;
let localDy = 0;
if (corner.includes('w')) localDx = -deltaWidth / 2;
else if (corner.includes('e')) localDx = deltaWidth / 2;
if (corner.includes('n')) localDy = -deltaHeight / 2;
else if (corner.includes('s')) localDy = deltaHeight / 2;
return {
x: localDx * cos - localDy * sin,
y: localDx * sin + localDy * cos
};
}
export function constrainToAspectRatio(
newWidth: number,
newHeight: number,
aspectRatio: number
): { width: number; height: number } {
const newRatio = newWidth / newHeight;
if (newRatio > aspectRatio) {
return { width: newHeight * aspectRatio, height: newHeight };
} else {
return { width: newWidth, height: newWidth / aspectRatio };
}
}
export function detectRotationCorner(
localX: number,
localY: number,
halfWidth: number,
halfHeight: number,
zoneRadius: number
): string | null {
const corners: Record<string, Point> = {
nw: { x: -halfWidth, y: -halfHeight },
ne: { x: halfWidth, y: -halfHeight },
sw: { x: -halfWidth, y: halfHeight },
se: { x: halfWidth, y: halfHeight }
};
const isInsideBounds =
localX >= -halfWidth &&
localX <= halfWidth &&
localY >= -halfHeight &&
localY <= halfHeight;
if (isInsideBounds) return null;
for (const [name, corner] of Object.entries(corners)) {
const dx = localX - corner.x;
const dy = localY - corner.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > zoneRadius || dist < 3) continue;
const isOutwardX = (name.includes('w') && dx < 0) || (name.includes('e') && dx > 0);
const isOutwardY = (name.includes('n') && dy < 0) || (name.includes('s') && dy > 0);
if (isOutwardX || isOutwardY) return name;
}
return null;
}

93
src/lib/io.ts Normal file
View File

@@ -0,0 +1,93 @@
import JSZip from 'jszip';
import type { Manifest, AssetStore } from './types';
import { state } from './state.svelte';
export async function exportBoard(): Promise<{ success: boolean; error?: string }> {
try {
const zip = new JSZip();
const assetsFolder = zip.folder('assets');
if (!assetsFolder) throw new Error('Failed to create assets folder');
const exportManifest: Manifest = {
version: 1,
items: state.manifest.items.map((item) => ({ ...item })),
sharedCss: state.manifest.sharedCss,
appCss: state.manifest.appCss
};
for (const item of exportManifest.items) {
if (item.assetId) {
const asset = state.assets[item.assetId];
if (asset) {
const ext = asset.filename.split('.').pop() || 'bin';
const filename = `${item.assetId}.${ext}`;
assetsFolder.file(filename, asset.blob);
}
}
}
zip.file('manifest.json', JSON.stringify(exportManifest, null, 2));
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'board.bub';
a.click();
URL.revokeObjectURL(url);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : 'Export failed' };
}
}
export async function importBoard(file: File): Promise<{ success: boolean; error?: string }> {
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 manifest: Manifest = {
version: 1,
items: raw.items,
sharedCss: raw.sharedCss ?? '',
appCss: raw.appCss ?? '',
flags: raw.flags ?? {}
};
const assets: AssetStore = {};
const urlReplacements: Map<string, string> = new Map();
for (const item of manifest.items) {
if (item.assetId) {
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';
assets[item.assetId] = { blob, url, filename };
urlReplacements.set(item.assetId, url);
}
}
}
for (const item of manifest.items) {
if (item.assetId && urlReplacements.has(item.assetId)) {
const newUrl = urlReplacements.get(item.assetId)!;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
}
}
state.load(manifest, assets);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : 'Import failed' };
}
}

358
src/lib/state.svelte.ts Normal file
View File

@@ -0,0 +1,358 @@
import type { Item, Manifest, Asset, AssetStore, Viewport, PositionFlag } from './types';
const STORAGE_KEY = 'buboard';
const DEFAULT_SHARED_CSS = `* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
}
body {
font-family: 'Departure Mono', monospace;
color: #fff;
overflow: hidden;
}`;
const DEFAULT_APP_CSS = `:root {
/* Theme */
--bg: #1a1a1a;
--surface: #282c34;
--border: #333;
--accent: #4a9eff;
--text: #fff;
--text-dim: #666;
/* Syntax */
--cm-keyword: #c678dd;
--cm-variable: #e06c75;
--cm-function: #61afef;
--cm-string: #98c379;
--cm-comment: #7d8799;
--cm-number: #d19a66;
--cm-operator: #56b6c2;
}`;
interface StoredAsset {
dataUrl: string;
filename: string;
}
interface StoredState {
manifest: Manifest;
assets: Record<string, StoredAsset>;
}
async function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
const res = await fetch(dataUrl);
return res.blob();
}
function createState() {
let manifest = $state<Manifest>({
version: 1,
items: [],
sharedCss: DEFAULT_SHARED_CSS,
appCss: DEFAULT_APP_CSS,
flags: {}
});
let assets = $state<AssetStore>({});
let viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 });
let selectedId = $state<string | null>(null);
let editingId = $state<string | null>(null);
let editingGlobal = $state<boolean>(false);
let focusedId = $state<string | null>(null);
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let animationId: number | null = null;
let maxZIndex = $derived(
manifest.items.length > 0 ? Math.max(...manifest.items.map((i) => i.zIndex)) : 0
);
async function save() {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
const storedAssets: Record<string, StoredAsset> = {};
for (const [id, asset] of Object.entries(assets)) {
storedAssets[id] = {
dataUrl: await blobToDataUrl(asset.blob),
filename: asset.filename
};
}
const stored: StoredState = { manifest, assets: storedAssets };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
} catch {
// localStorage full or unavailable
}
}, 500);
}
async function restore() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const stored: StoredState = JSON.parse(raw);
if (stored.manifest.version !== 1) return;
const restoredAssets: AssetStore = {};
for (const [id, storedAsset] of Object.entries(stored.assets)) {
const blob = await dataUrlToBlob(storedAsset.dataUrl);
const url = URL.createObjectURL(blob);
restoredAssets[id] = { blob, url, filename: storedAsset.filename };
}
for (const item of stored.manifest.items) {
if (item.assetId && restoredAssets[item.assetId]) {
const newUrl = restoredAssets[item.assetId].url;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
}
}
manifest = { ...stored.manifest, flags: stored.manifest.flags ?? {} };
assets = restoredAssets;
} catch {
// corrupted or missing data
}
}
function addItem(item: Item) {
manifest.items.push(item);
save();
}
function updateItem(id: string, updates: Partial<Item>) {
const item = manifest.items.find((i) => i.id === id);
if (!item) return;
Object.assign(item, updates);
save();
}
function removeItem(id: string) {
const idx = manifest.items.findIndex((i) => i.id === id);
if (idx === -1) return;
const item = manifest.items[idx];
if (item.assetId && assets[item.assetId]) {
URL.revokeObjectURL(assets[item.assetId].url);
delete assets[item.assetId];
}
manifest.items.splice(idx, 1);
if (selectedId === id) selectedId = null;
if (editingId === id) editingId = null;
save();
}
function addAsset(id: string, asset: Asset) {
assets[id] = asset;
save();
}
function getItem(id: string): Item | undefined {
return manifest.items.find((i) => i.id === id);
}
function select(id: string | null) {
selectedId = id;
}
function edit(id: string | null) {
editingId = id;
if (id) editingGlobal = false;
}
function editGlobal(editing: boolean) {
editingGlobal = editing;
if (editing) editingId = null;
}
function focus(id: string | null) {
focusedId = id;
}
function updateSharedCss(css: string) {
manifest.sharedCss = css;
save();
}
function updateAppCss(css: string) {
manifest.appCss = css;
save();
}
function bringToFront(id: string) {
const item = manifest.items.find((i) => i.id === id);
if (!item) return;
item.zIndex = maxZIndex + 1;
save();
}
function pan(dx: number, dy: number) {
viewport.x += dx;
viewport.y += dy;
}
function zoomAt(factor: number, cx: number, cy: number) {
const newZoom = Math.max(0.1, Math.min(10, viewport.zoom * factor));
const scale = newZoom / viewport.zoom;
viewport.x = cx - (cx - viewport.x) * scale;
viewport.y = cy - (cy - viewport.y) * scale;
viewport.zoom = newZoom;
}
function setZoom(zoom: number) {
viewport.zoom = Math.max(0.1, Math.min(10, zoom));
}
function hasFlag(key: string): boolean {
return !!manifest.flags?.[key];
}
function setFlag(key: string) {
if (!manifest.flags) manifest.flags = {};
manifest.flags[key] = { x: viewport.x, y: viewport.y, zoom: viewport.zoom };
save();
}
function clearFlag(key: string) {
if (manifest.flags?.[key]) {
delete manifest.flags[key];
save();
}
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function animateViewport(targetX: number, targetY: number, targetZoom: number) {
if (animationId) cancelAnimationFrame(animationId);
const startX = viewport.x;
const startY = viewport.y;
const startZoom = viewport.zoom;
const startTime = performance.now();
const duration = 600;
function tick(now: number) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeInOutCubic(t);
viewport.x = startX + (targetX - startX) * eased;
viewport.y = startY + (targetY - startY) * eased;
viewport.zoom = startZoom + (targetZoom - startZoom) * eased;
if (t < 1) {
animationId = requestAnimationFrame(tick);
} else {
animationId = null;
}
}
animationId = requestAnimationFrame(tick);
}
function gotoFlag(key: string) {
const flag = manifest.flags?.[key];
if (flag) {
animateViewport(flag.x, flag.y, flag.zoom);
}
}
function reset() {
Object.values(assets).forEach((a) => URL.revokeObjectURL(a.url));
manifest = {
version: 1,
items: [],
sharedCss: DEFAULT_SHARED_CSS,
appCss: DEFAULT_APP_CSS,
flags: {}
};
assets = {};
viewport = { x: 0, y: 0, zoom: 1 };
selectedId = null;
editingId = null;
editingGlobal = false;
focusedId = null;
localStorage.removeItem(STORAGE_KEY);
}
function load(newManifest: Manifest, newAssets: AssetStore) {
Object.values(assets).forEach((a) => URL.revokeObjectURL(a.url));
manifest = newManifest;
assets = newAssets;
viewport = { x: 0, y: 0, zoom: 1 };
selectedId = null;
editingId = null;
editingGlobal = false;
focusedId = null;
save();
}
restore();
return {
get manifest() {
return manifest;
},
get assets() {
return assets;
},
get viewport() {
return viewport;
},
get selectedId() {
return selectedId;
},
get editingId() {
return editingId;
},
get editingGlobal() {
return editingGlobal;
},
get focusedId() {
return focusedId;
},
get maxZIndex() {
return maxZIndex;
},
addItem,
updateItem,
removeItem,
addAsset,
getItem,
select,
edit,
editGlobal,
focus,
updateSharedCss,
updateAppCss,
bringToFront,
pan,
zoomAt,
setZoom,
hasFlag,
setFlag,
clearFlag,
gotoFlag,
reset,
load
};
}
export const state = createState();

131
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,131 @@
import { EditorView } from '@codemirror/view';
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
function getVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
export function createTheme() {
const surface = getVar('--surface', '#282c34');
const border = getVar('--border', '#333');
const accent = getVar('--accent', '#4a9eff');
const text = getVar('--text', '#fff');
const textDim = getVar('--text-dim', '#666');
const keyword = getVar('--cm-keyword', '#c678dd');
const variable = getVar('--cm-variable', '#e06c75');
const func = getVar('--cm-function', '#61afef');
const string = getVar('--cm-string', '#98c379');
const comment = getVar('--cm-comment', '#7d8799');
const number = getVar('--cm-number', '#d19a66');
const operator = getVar('--cm-operator', '#56b6c2');
const theme = EditorView.theme(
{
'&': {
backgroundColor: surface,
color: '#abb2bf',
height: '100%'
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: "'Departure Mono', monospace"
},
'.cm-content': {
caretColor: accent
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: accent
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor: '#3E4451'
},
'.cm-panels': {
backgroundColor: surface,
color: '#abb2bf'
},
'.cm-panels.cm-panels-top': {
borderBottom: `1px solid ${border}`
},
'.cm-panels.cm-panels-bottom': {
borderTop: `1px solid ${border}`
},
'.cm-searchMatch': {
backgroundColor: '#72a1ff59',
outline: `1px solid ${border}`
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: '#6199ff2f'
},
'.cm-activeLine': {
backgroundColor: '#2c313c50'
},
'.cm-selectionMatch': {
backgroundColor: '#aafe661a'
},
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
backgroundColor: '#bad0f847'
},
'.cm-gutters': {
backgroundColor: surface,
color: textDim,
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor: '#2c313c50'
},
'.cm-foldPlaceholder': {
backgroundColor: 'transparent',
border: 'none',
color: textDim
},
'.cm-tooltip': {
border: 'none',
backgroundColor: surface
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'transparent',
borderBottomColor: 'transparent'
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: surface,
borderBottomColor: surface
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor: accent,
color: text
}
}
},
{ dark: true }
);
const highlighting = HighlightStyle.define([
{ tag: tags.keyword, color: keyword },
{ tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName], color: variable },
{ tag: [tags.function(tags.variableName), tags.labelName], color: func },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: number },
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{ tag: [tags.typeName, tags.className, tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: number },
{ tag: [tags.operator, tags.operatorKeyword, tags.url, tags.escape, tags.regexp, tags.link, tags.special(tags.string)], color: operator },
{ tag: [tags.meta, tags.comment], color: comment, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: comment, textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: variable },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: number },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: string },
{ tag: tags.invalid, color: '#ff0000' },
{ tag: tags.tagName, color: variable },
{ tag: tags.attributeName, color: number },
{ tag: tags.attributeValue, color: string },
{ tag: tags.propertyName, color: func }
]);
return [theme, syntaxHighlighting(highlighting)];
}

42
src/lib/types.ts Normal file
View File

@@ -0,0 +1,42 @@
export interface Item {
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
zIndex: number;
html: string;
css: string;
assetId?: string;
}
export interface Manifest {
version: 1;
items: Item[];
sharedCss: string;
appCss: string;
flags?: Record<string, PositionFlag>;
}
export interface Asset {
blob: Blob;
url: string;
filename: string;
}
export interface AssetStore {
[assetId: string]: Asset;
}
export interface Viewport {
x: number;
y: number;
zoom: number;
}
export interface PositionFlag {
x: number;
y: number;
zoom: number;
}

9
src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app

8
svelte.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
})