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