OK
This commit is contained in:
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
47
README.md
Normal file
47
README.md
Normal 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
13
index.html
Normal 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
41
package.json
Normal 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
1138
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/fonts/DepartureMono-Regular.woff
Normal file
BIN
public/fonts/DepartureMono-Regular.woff
Normal file
Binary file not shown.
BIN
public/fonts/DepartureMono-Regular.woff2
Normal file
BIN
public/fonts/DepartureMono-Regular.woff2
Normal file
Binary file not shown.
163
src/App.svelte
Normal file
163
src/App.svelte
Normal 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
28
src/app.css
Normal 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
174
src/lib/Canvas.svelte
Normal 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
258
src/lib/Editor.svelte
Normal 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
298
src/lib/Item.svelte
Normal 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
224
src/lib/Palette.svelte
Normal 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
207
src/lib/Toolbar.svelte
Normal 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
81
src/lib/geometry.ts
Normal 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
93
src/lib/io.ts
Normal 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
358
src/lib/state.svelte.ts
Normal 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
131
src/lib/theme.ts
Normal 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
42
src/lib/types.ts
Normal 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
9
src/main.ts
Normal 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
8
svelte.config.js
Normal 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
21
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
7
vite.config.ts
Normal 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()],
|
||||
})
|
||||
Reference in New Issue
Block a user