chore: bootstrap AstralView monorepo
This commit is contained in:
commit
d42b210608
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# builds
|
||||
dist
|
||||
.cache
|
||||
.vite
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# editor
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# docs build
|
||||
packages/docs/docs/.vitepress/cache
|
||||
packages/docs/docs/.vitepress/dist
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
@ -0,0 +1,52 @@
|
||||
# AGENTS.md — AstralView Repo Rules (AI + Human)
|
||||
|
||||
This repository is a **high-quality monorepo**. Any change must keep the system:
|
||||
- **Correct** (tests + typechecks)
|
||||
- **Understandable** (clean architecture + docs)
|
||||
- **Low-coupling** (SDK is framework-agnostic)
|
||||
- **High availability mindset** (resilience, safe defaults)
|
||||
|
||||
## Non‑Negotiables
|
||||
|
||||
1) **No big-bang rewrites.** Work in small, reviewable steps.
|
||||
2) **SDK is sacred.** `packages/sdk` must not depend on React/Vue/DOM. Pure TS.
|
||||
3) **Editor depends on SDK, not vice versa.**
|
||||
4) **Docs are truthful.** If docs claim something works, it must be tested.
|
||||
5) **Keep boundaries explicit.** Cross-package imports must be via package entrypoints.
|
||||
|
||||
## Quality Gates (must be green)
|
||||
|
||||
- `pnpm -r lint`
|
||||
- `pnpm -r build`
|
||||
- `pnpm -r typecheck`
|
||||
- `pnpm -r test` (when tests exist)
|
||||
|
||||
## Architecture Conventions
|
||||
|
||||
### packages/sdk
|
||||
- Expose only stable APIs via `src/index.ts`.
|
||||
- Use clear layers: `core/` (types + pure functions), `runtime/` (optional adapters).
|
||||
- Prefer **dependency inversion** (interfaces) over direct imports.
|
||||
|
||||
### packages/editor
|
||||
- React + TypeScript.
|
||||
- UI state (view) separated from business logic (SDK).
|
||||
- Any persistence/networking should go through SDK interfaces.
|
||||
|
||||
### packages/docs
|
||||
- VitePress.
|
||||
- Document the public SDK API and editor usage.
|
||||
|
||||
## Code Style
|
||||
- TypeScript strict.
|
||||
- No `any` unless justified.
|
||||
- Prefer explicit names over cleverness.
|
||||
|
||||
## Commit Discipline
|
||||
- Conventional commits recommended: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`.
|
||||
- Each commit should build.
|
||||
|
||||
## Security / Safety
|
||||
- Do not add telemetry.
|
||||
- Do not check in secrets.
|
||||
- Avoid unsafe shell commands in scripts.
|
||||
10
eslint.config.mjs
Normal file
10
eslint.config.mjs
Normal file
@ -0,0 +1,10 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ['**/dist/**', '**/.vitepress/cache/**', '**/.vitepress/dist/**', '**/node_modules/**', '**/third_party/**'],
|
||||
},
|
||||
];
|
||||
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "astralview",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test",
|
||||
"format": "prettier . --write",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"eslint": "^9.20.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.23.0"
|
||||
}
|
||||
}
|
||||
24
packages/docs/.gitignore
vendored
Normal file
24
packages/docs/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
4
packages/docs/docs/.vitepress/config.ts
Normal file
4
packages/docs/docs/.vitepress/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// https://vitepress.vuejs.org/config/app-configs
|
||||
export default defineConfig({})
|
||||
78
packages/docs/docs/architecture.md
Normal file
78
packages/docs/docs/architecture.md
Normal file
@ -0,0 +1,78 @@
|
||||
# AstralView Architecture
|
||||
|
||||
## Packages
|
||||
|
||||
- `@astralview/sdk` — framework-agnostic domain + services (pure TypeScript)
|
||||
- `@astralview/editor` — React + Ant Design application (the runnable editor)
|
||||
- `@astralview/docs` — VitePress documentation site
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **SDK has no UI dependencies** (no React/Vue/DOM).
|
||||
2. **Editor depends on SDK**; SDK never depends on editor.
|
||||
3. **Docs document the SDK public API + editor behavior**.
|
||||
4. **Self-built canvas**: editor uses a custom, modular canvas engine.
|
||||
|
||||
## Domain Model (SDK)
|
||||
|
||||
### Screen
|
||||
Represents one dashboard/screen.
|
||||
- size (width, height)
|
||||
- background
|
||||
- layers[]
|
||||
- version
|
||||
|
||||
### Layer
|
||||
- zIndex
|
||||
- locked/hidden
|
||||
- nodes[] (widgets)
|
||||
|
||||
### Widget (Node)
|
||||
- id
|
||||
- type
|
||||
- rect (x,y,w,h)
|
||||
- transform (rotate, scale)
|
||||
- style
|
||||
- dataBinding
|
||||
- events
|
||||
|
||||
### DataSource
|
||||
- static | http | websocket (future)
|
||||
- request config (method/url/headers/body)
|
||||
- polling policy
|
||||
- transform pipeline
|
||||
|
||||
## Canvas Engine (Editor)
|
||||
|
||||
### Goals
|
||||
- High FPS for drag/resize
|
||||
- Deterministic state updates
|
||||
- Clear separation of rendering vs interaction
|
||||
|
||||
### Layers (suggested)
|
||||
- **State layer**: immutable editor state + history (undo/redo)
|
||||
- **Interaction layer**: pointer/keyboard handlers, hit-testing
|
||||
- **Render layer**: DOM/CSS rendering (initial), optional Canvas/WebGL later
|
||||
|
||||
### Key Subsystems
|
||||
- Selection model (single/multi)
|
||||
- Dragging (move)
|
||||
- Resizing (8 handles)
|
||||
- Snapping (guides, alignment lines)
|
||||
- Grouping/ungrouping
|
||||
- Z-order operations
|
||||
- Clipboard (copy/paste)
|
||||
- History (command pattern)
|
||||
|
||||
## Migration Plan (from go-view)
|
||||
|
||||
We will *not* transplant Vue UI. We will extract **behavior + data contracts** into `sdk`, then rebuild UI in `editor`.
|
||||
|
||||
High-signal sources in go-view:
|
||||
- `src/views/chart/ContentEdit/*` (drag, selection, guides, tools)
|
||||
- `src/store/modules/chartEditStore` (editor state)
|
||||
- `src/hooks/useChartDataFetch.hook.ts` (data fetch)
|
||||
- `src/views/preview/*` (preview scaling + rendering)
|
||||
- `src/packages/*` (widget catalog + configs)
|
||||
|
||||
Next step: write a detailed mapping table (go-view module → sdk/editor target) and implement the first vertical slice: **canvas + one widget + import/export**.
|
||||
6
packages/docs/docs/index.md
Normal file
6
packages/docs/docs/index.md
Normal file
@ -0,0 +1,6 @@
|
||||
# AstralView Docs
|
||||
|
||||
- [Architecture](./architecture)
|
||||
- [Migration Plan](./migration)
|
||||
|
||||
This site documents the AstralView monorepo and the ongoing refactor from go-view.
|
||||
46
packages/docs/docs/migration.md
Normal file
46
packages/docs/docs/migration.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Migration Plan (go-view → AstralView)
|
||||
|
||||
## Strategy
|
||||
|
||||
- Keep go-view as reference under `third_party/go-view`.
|
||||
- Extract domain and behaviors into `@astralview/sdk`.
|
||||
- Rebuild UI in `@astralview/editor` (React + Ant Design).
|
||||
|
||||
## Work Breakdown (high level)
|
||||
|
||||
1. **Model & persistence**
|
||||
- screen schema
|
||||
- versioned migrations
|
||||
- import/export
|
||||
|
||||
2. **Canvas engine (self-built)**
|
||||
- pan/zoom (editor) + preview scaling
|
||||
- selection/drag/resize
|
||||
- snapping/guides
|
||||
- multi-select, group
|
||||
- history (undo/redo)
|
||||
|
||||
3. **Widgets**
|
||||
- widget registry
|
||||
- rendering adapters (React)
|
||||
- data binding and refresh
|
||||
|
||||
4. **Data pipelines**
|
||||
- request config
|
||||
- polling
|
||||
- transform/filter
|
||||
- error handling + fallback
|
||||
|
||||
5. **Preview/Publish**
|
||||
- read-only renderer
|
||||
- assets packaging
|
||||
|
||||
## Initial Vertical Slice (MVP)
|
||||
|
||||
- A blank screen with configurable size
|
||||
- Drag a single "Text" widget onto the canvas
|
||||
- Move/resize it
|
||||
- Save to JSON, reload from JSON
|
||||
- Preview mode renders the same output
|
||||
|
||||
This slice will define the architecture and enforce low coupling.
|
||||
16
packages/docs/package.json
Normal file
16
packages/docs/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@astralview/docs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
"build": "vitepress build docs",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"serve": "vitepress serve docs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
9
packages/docs/tsconfig.json
Normal file
9
packages/docs/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
},
|
||||
"include": ["docs/.vitepress/**/*.ts", "docs/.vitepress/**/*.mts"]
|
||||
}
|
||||
24
packages/editor/.gitignore
vendored
Normal file
24
packages/editor/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
packages/editor/README.md
Normal file
73
packages/editor/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
packages/editor/eslint.config.js
Normal file
23
packages/editor/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
packages/editor/index.html
Normal file
13
packages/editor/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>editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
packages/editor/package.json
Normal file
34
packages/editor/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@astralview/editor",
|
||||
"private": "true",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@astralview/sdk": "workspace:^",
|
||||
"antd": "^6.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"lodash": "^4.17.23",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
packages/editor/public/vite.svg
Normal file
1
packages/editor/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
packages/editor/src/App.css
Normal file
42
packages/editor/src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
1
packages/editor/src/App.tsx
Normal file
1
packages/editor/src/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { EditorApp as App } from './editor/EditorApp';
|
||||
1
packages/editor/src/assets/react.svg
Normal file
1
packages/editor/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
538
packages/editor/src/editor/Canvas.tsx
Normal file
538
packages/editor/src/editor/Canvas.tsx
Normal file
@ -0,0 +1,538 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Screen, WidgetNode } from '@astralview/sdk';
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
import type { ResizeHandle } from './types';
|
||||
import { rectFromPoints } from './geometry';
|
||||
|
||||
export interface CanvasProps {
|
||||
screen: Screen;
|
||||
selectionIds: string[];
|
||||
keyboard: { ctrl: boolean; space: boolean };
|
||||
scale: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
guides: { xs: number[]; ys: number[] };
|
||||
onSelectSingle(id?: string): void;
|
||||
onToggleSelect(id: string): void;
|
||||
onBeginPan(e: React.PointerEvent): void;
|
||||
onUpdatePan(e: PointerEvent): void;
|
||||
onEndPan(): void;
|
||||
onBeginMove(e: React.PointerEvent): void;
|
||||
onUpdateMove(e: PointerEvent): void;
|
||||
onEndMove(): void;
|
||||
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number): void;
|
||||
onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void;
|
||||
onEndBoxSelect(): void;
|
||||
onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): void;
|
||||
onUpdateResize(e: PointerEvent): void;
|
||||
onEndResize(): void;
|
||||
onAddTextAt(x: number, y: number): void;
|
||||
onWheelPan(dx: number, dy: number): void;
|
||||
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
|
||||
onDeleteSelected?(): void;
|
||||
onDuplicateSelected?(): void;
|
||||
}
|
||||
|
||||
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
|
||||
// align with goView: ignore middle click in box select
|
||||
return (e as PointerEvent).buttons === 1 || (e as PointerEvent).button === 0;
|
||||
}
|
||||
|
||||
type ContextMenuState = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
export function Canvas(props: CanvasProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [box, setBox] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
|
||||
const [ctx, setCtx] = useState<ContextMenuState | null>(null);
|
||||
|
||||
const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]);
|
||||
|
||||
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
|
||||
const el = ref.current;
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||
}, []);
|
||||
|
||||
const clientToWorld = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const p = clientToCanvas(clientX, clientY);
|
||||
if (!p) return null;
|
||||
return {
|
||||
x: p.x / props.scale - props.panX,
|
||||
y: p.y / props.scale - props.panY,
|
||||
canvasX: p.x,
|
||||
canvasY: p.y,
|
||||
};
|
||||
},
|
||||
[clientToCanvas, props.panX, props.panY, props.scale],
|
||||
);
|
||||
|
||||
const {
|
||||
onUpdateBoxSelect,
|
||||
onUpdateMove,
|
||||
onUpdatePan,
|
||||
onUpdateResize,
|
||||
onEndBoxSelect,
|
||||
onEndMove,
|
||||
onEndPan,
|
||||
onEndResize,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
// update box selection overlay if active
|
||||
if (box) {
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
|
||||
onUpdateBoxSelect(e, p.x, p.y);
|
||||
setBox((prev) => (prev ? rectFromPoints({ x: prev.x1, y: prev.y1 }, { x: p.x, y: p.y }) : prev));
|
||||
return;
|
||||
}
|
||||
|
||||
// dragging/resizing/panning are handled in store via global listener
|
||||
onUpdatePan(e);
|
||||
onUpdateMove(e);
|
||||
onUpdateResize(e);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (box) {
|
||||
onEndBoxSelect();
|
||||
setBox(null);
|
||||
return;
|
||||
}
|
||||
onEndPan();
|
||||
onEndMove();
|
||||
onEndResize();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
}, [
|
||||
box,
|
||||
clientToWorld,
|
||||
onEndBoxSelect,
|
||||
onEndMove,
|
||||
onEndPan,
|
||||
onEndResize,
|
||||
onUpdateBoxSelect,
|
||||
onUpdateMove,
|
||||
onUpdatePan,
|
||||
onUpdateResize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setCtx(null);
|
||||
};
|
||||
|
||||
const onAnyPointerDown = () => setCtx(null);
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('pointerdown', onAnyPointerDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('pointerdown', onAnyPointerDown);
|
||||
};
|
||||
}, [ctx]);
|
||||
|
||||
const openContextMenu = (e: React.MouseEvent | React.PointerEvent, targetId?: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
|
||||
if (targetId && !props.selectionIds.includes(targetId)) {
|
||||
// goView-ish: right click selects the item.
|
||||
props.onSelectSingle(targetId);
|
||||
}
|
||||
|
||||
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
|
||||
};
|
||||
|
||||
const onBackgroundPointerDown = (e: React.PointerEvent) => {
|
||||
// close any open context menu on normal interactions
|
||||
if (ctx && e.button !== 2) setCtx(null);
|
||||
|
||||
// middle click: do nothing (goView returns)
|
||||
if (e.button === 1) return;
|
||||
|
||||
// space + drag: pan (align with goView's Space mode)
|
||||
if (props.keyboard.space) {
|
||||
props.onBeginPan(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// left click only
|
||||
if (!isPrimaryButton(e)) return;
|
||||
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
|
||||
props.onSelectSingle(undefined);
|
||||
props.onBeginBoxSelect(e, p.x, p.y);
|
||||
setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y }));
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: React.MouseEvent) => {
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
props.onAddTextAt(p.x, p.y);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button onClick={() => props.onAddTextAt(120, 120)}>Add Text</Button>
|
||||
<div style={{ color: '#8892b0' }}>
|
||||
Double-click canvas to add a Text widget. Hold <b>Space</b> and drag to pan.
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
onPointerDown={onBackgroundPointerDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={(e) => openContextMenu(e)}
|
||||
onWheel={(e) => {
|
||||
// Prevent the page from scrolling while interacting with the canvas.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const p = clientToWorld(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
|
||||
const isZoom = e.ctrlKey || e.metaKey;
|
||||
if (isZoom) {
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
props.onZoomAt(props.scale * factor, p.canvasX, p.canvasY);
|
||||
return;
|
||||
}
|
||||
|
||||
// goView-ish: wheel pans the view.
|
||||
props.onWheelPan(-e.deltaX / props.scale, -e.deltaY / props.scale);
|
||||
}}
|
||||
style={{
|
||||
width: bounds.w,
|
||||
height: bounds.h,
|
||||
background: props.screen.background?.color ?? '#0b1020',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
borderRadius: 8,
|
||||
outline: '1px solid rgba(255,255,255,0.08)',
|
||||
cursor: props.keyboard.space ? 'grab' : 'default',
|
||||
}}
|
||||
>
|
||||
{ctx && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: ctx.clientX,
|
||||
top: ctx.clientY,
|
||||
zIndex: 10_000,
|
||||
background: '#111827',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 8,
|
||||
minWidth: 180,
|
||||
padding: 6,
|
||||
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// keep it open when clicking inside
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
label="Add Text Here"
|
||||
onClick={() => {
|
||||
props.onAddTextAt(ctx.worldX, ctx.worldY);
|
||||
setCtx(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<MenuItem
|
||||
label="Duplicate"
|
||||
disabled={!props.selectionIds.length || !props.onDuplicateSelected}
|
||||
onClick={() => {
|
||||
props.onDuplicateSelected?.();
|
||||
setCtx(null);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Delete"
|
||||
danger
|
||||
disabled={!props.selectionIds.length || !props.onDeleteSelected}
|
||||
onClick={() => {
|
||||
props.onDeleteSelected?.();
|
||||
setCtx(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography.Text style={{ display: 'block', marginTop: 6, color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>
|
||||
({Math.round(ctx.worldX)}, {Math.round(ctx.worldY)})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${props.panX}px, ${props.panY}px) scale(${props.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
width: bounds.w,
|
||||
height: bounds.h,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* guides (snap lines) */}
|
||||
{props.guides.xs.map((x) => (
|
||||
<div
|
||||
key={`gx_${x}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: bounds.h,
|
||||
background: 'rgba(24, 144, 255, 0.7)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{props.guides.ys.map((y) => (
|
||||
<div
|
||||
key={`gy_${y}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: y,
|
||||
width: bounds.w,
|
||||
height: 1,
|
||||
background: 'rgba(24, 144, 255, 0.7)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.screen.nodes.map((node) => (
|
||||
<NodeView
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={props.selectionIds.includes(node.id)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (node.locked) return;
|
||||
|
||||
// ctrl click: multi-select toggle
|
||||
if (props.keyboard.ctrl) {
|
||||
props.onToggleSelect(node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSelectSingle(node.id);
|
||||
// right click should not start move
|
||||
if (e.button === 2) return;
|
||||
|
||||
props.onBeginMove(e);
|
||||
}}
|
||||
onContextMenu={(e) => openContextMenu(e, node.id)}
|
||||
onResizePointerDown={(e, handle) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onBeginResize(e, node.id, handle);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{box && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: box.x1,
|
||||
top: box.y1,
|
||||
width: box.x2 - box.x1,
|
||||
height: box.y2 - box.y1,
|
||||
border: '1px dashed rgba(24, 144, 255, 0.9)',
|
||||
background: 'rgba(24, 144, 255, 0.12)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
props.onClick();
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
cursor: props.disabled ? 'not-allowed' : 'pointer',
|
||||
color: props.disabled
|
||||
? 'rgba(255,255,255,0.35)'
|
||||
: props.danger
|
||||
? '#ff7875'
|
||||
: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (props.disabled) return;
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'rgba(255,255,255,0.06)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeView(props: {
|
||||
node: WidgetNode;
|
||||
selected: boolean;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
onResizePointerDown: (e: React.PointerEvent, handle: ResizeHandle) => void;
|
||||
}) {
|
||||
const { node } = props;
|
||||
const rect = node.rect;
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={props.onPointerDown}
|
||||
onContextMenu={props.onContextMenu}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.w,
|
||||
height: rect.h,
|
||||
border: props.selected ? '1px solid rgba(24,144,255,0.9)' : '1px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: props.selected ? '0 0 0 2px rgba(24,144,255,0.25)' : undefined,
|
||||
padding: 8,
|
||||
color: '#fff',
|
||||
boxSizing: 'border-box',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
{node.type === 'text' ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent:
|
||||
node.props.textAlign === 'left'
|
||||
? 'flex-start'
|
||||
: node.props.textAlign === 'right'
|
||||
? 'flex-end'
|
||||
: 'center',
|
||||
backgroundColor: node.props.backgroundColor ?? 'transparent',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: `${node.props.borderWidth ?? 0}px`,
|
||||
borderColor: node.props.borderColor ?? 'transparent',
|
||||
borderRadius: `${node.props.borderRadius ?? 0}px`,
|
||||
boxSizing: 'border-box',
|
||||
padding: `${node.props.paddingY ?? 0}px ${node.props.paddingX ?? 0}px`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: node.props.fontSize ?? 24,
|
||||
color: node.props.color ?? '#fff',
|
||||
fontWeight: node.props.fontWeight ?? 400,
|
||||
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
|
||||
writingMode: node.props.writingMode as unknown as React.CSSProperties['writingMode'],
|
||||
cursor: node.props.link ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!node.props.link) return;
|
||||
const head = node.props.linkHead ?? 'http://';
|
||||
window.open(`${head}${node.props.link}`);
|
||||
}}
|
||||
>
|
||||
{node.props.text}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>{node.type}</div>
|
||||
)}
|
||||
|
||||
{props.selected && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizeHandles(props: { onPointerDown: (e: React.PointerEvent, handle: ResizeHandle) => void }) {
|
||||
const size = 8;
|
||||
const handles: Array<{ handle: ResizeHandle; style: React.CSSProperties }> = [
|
||||
{ handle: 'tl', style: { left: -size / 2, top: -size / 2, cursor: 'nwse-resize' } },
|
||||
{ handle: 't', style: { left: '50%', top: -size / 2, marginLeft: -size / 2, cursor: 'ns-resize' } },
|
||||
{ handle: 'tr', style: { right: -size / 2, top: -size / 2, cursor: 'nesw-resize' } },
|
||||
{ handle: 'r', style: { right: -size / 2, top: '50%', marginTop: -size / 2, cursor: 'ew-resize' } },
|
||||
{ handle: 'br', style: { right: -size / 2, bottom: -size / 2, cursor: 'nwse-resize' } },
|
||||
{ handle: 'b', style: { left: '50%', bottom: -size / 2, marginLeft: -size / 2, cursor: 'ns-resize' } },
|
||||
{ handle: 'bl', style: { left: -size / 2, bottom: -size / 2, cursor: 'nesw-resize' } },
|
||||
{ handle: 'l', style: { left: -size / 2, top: '50%', marginTop: -size / 2, cursor: 'ew-resize' } },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{handles.map((h) => (
|
||||
<div
|
||||
key={h.handle}
|
||||
onPointerDown={(e) => props.onPointerDown(e, h.handle)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
background: '#1890ff',
|
||||
borderRadius: 2,
|
||||
...h.style,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
packages/editor/src/editor/ContextMenu.tsx
Normal file
44
packages/editor/src/editor/ContextMenu.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Button, Card, Space } from 'antd';
|
||||
|
||||
export type ContextMenuAction = 'delete' | 'duplicate';
|
||||
|
||||
export function ContextMenu(props: {
|
||||
x: number;
|
||||
y: number;
|
||||
onAction: (a: ContextMenuAction) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* click-away backdrop */}
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
props.onClose();
|
||||
}}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
|
||||
/>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: props.x,
|
||||
top: props.y,
|
||||
zIndex: 1000,
|
||||
width: 160,
|
||||
}}
|
||||
styles={{ body: { padding: 8 } }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button block onClick={() => props.onAction('duplicate')}>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button danger block onClick={() => props.onAction('delete')}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
packages/editor/src/editor/EditorApp.tsx
Normal file
165
packages/editor/src/editor/EditorApp.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { Button, Divider, Input, Layout, Space, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { createInitialState, editorReducer, exportScreenJSON } from './store';
|
||||
import { bindEditorHotkeys } from './hotkeys';
|
||||
import { Canvas } from './Canvas';
|
||||
import { Inspector } from './Inspector';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
export function EditorApp() {
|
||||
const [state, dispatch] = useReducer(editorReducer, undefined, createInitialState);
|
||||
const [importText, setImportText] = useState('');
|
||||
|
||||
const bounds = useMemo(
|
||||
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
|
||||
[state.doc.screen.width, state.doc.screen.height],
|
||||
);
|
||||
|
||||
// keyboard tracking (ctrl/space) — goView uses window.$KeyboardActive
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space: e.code === 'Space' || state.keyboard.space });
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
const space = e.code === 'Space' ? false : state.keyboard.space;
|
||||
dispatch({ type: 'keyboard', ctrl: e.ctrlKey || e.metaKey, space });
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [state.keyboard.space]);
|
||||
|
||||
// Canvas handles its own context menu.
|
||||
|
||||
// Hotkeys (goView-like)
|
||||
useEffect(() => {
|
||||
return bindEditorHotkeys(() => false, dispatch);
|
||||
}, []);
|
||||
|
||||
const api = {
|
||||
exportJSON: () => exportScreenJSON(state.doc.screen),
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0, color: '#fff' }}>
|
||||
AstralView Editor
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Button onClick={() => dispatch({ type: 'undo' })} disabled={!state.history.past.length}>
|
||||
Undo
|
||||
</Button>
|
||||
<Button onClick={() => dispatch({ type: 'redo' })} disabled={!state.history.future.length}>
|
||||
Redo
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const json = api.exportJSON();
|
||||
setImportText(json);
|
||||
void navigator.clipboard?.writeText(json);
|
||||
}}
|
||||
>
|
||||
Export JSON (copy)
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Layout>
|
||||
<Sider width={360} theme="light" style={{ padding: 16, overflow: 'auto' }}>
|
||||
<Typography.Title level={5}>Inspector</Typography.Title>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||
Selected: {state.selection.ids.length ? state.selection.ids.join(', ') : 'None'}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Inspector
|
||||
selected={state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0])}
|
||||
onUpdateTextProps={(id, props) => dispatch({ type: 'updateTextProps', id, props })}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography.Title level={5}>Import/Export</Typography.Title>
|
||||
<Input.TextArea
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder="Paste screen JSON here"
|
||||
/>
|
||||
<Space style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
dispatch({ type: 'importJSON', json: importText });
|
||||
}}
|
||||
disabled={!importText.trim()}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button onClick={() => setImportText(api.exportJSON())}>Load current</Button>
|
||||
</Space>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title level={5}>Notes</Typography.Title>
|
||||
<Typography.Paragraph style={{ color: '#666' }}>
|
||||
Interactions target goView semantics: Ctrl multi-select, box-select full containment, scale-aware movement.
|
||||
</Typography.Paragraph>
|
||||
</Sider>
|
||||
|
||||
<Content style={{ padding: 16, overflow: 'auto' }}>
|
||||
<Canvas
|
||||
screen={state.doc.screen}
|
||||
selectionIds={state.selection.ids}
|
||||
keyboard={state.keyboard}
|
||||
scale={state.canvas.scale}
|
||||
panX={state.canvas.panX}
|
||||
panY={state.canvas.panY}
|
||||
guides={state.canvas.guides}
|
||||
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
||||
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
|
||||
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
|
||||
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
|
||||
onBeginPan={(e) => {
|
||||
dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } });
|
||||
}}
|
||||
onUpdatePan={(e: PointerEvent) => {
|
||||
dispatch({ type: 'updatePan', current: { screenX: e.screenX, screenY: e.screenY } });
|
||||
}}
|
||||
onEndPan={() => dispatch({ type: 'endPan' })}
|
||||
onBeginMove={(e) => {
|
||||
dispatch({ type: 'beginMove', start: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
||||
}}
|
||||
onUpdateMove={(e: PointerEvent) => {
|
||||
dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
||||
}}
|
||||
onEndMove={() => dispatch({ type: 'endMove' })}
|
||||
onBeginBoxSelect={(e, offsetX, offsetY) => {
|
||||
dispatch({ type: 'beginBoxSelect', start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });
|
||||
}}
|
||||
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => {
|
||||
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });
|
||||
}}
|
||||
onEndBoxSelect={() => dispatch({ type: 'endBoxSelect' })}
|
||||
onBeginResize={(e, id, handle) => {
|
||||
dispatch({ type: 'beginResize', id, handle, start: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
||||
}}
|
||||
onUpdateResize={(e: PointerEvent) => {
|
||||
dispatch({ type: 'updateResize', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
|
||||
}}
|
||||
onEndResize={() => dispatch({ type: 'endResize' })}
|
||||
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
||||
onWheelPan={(dx, dy) => dispatch({ type: 'panBy', dx, dy })}
|
||||
onZoomAt={(scale, anchorX, anchorY) => {
|
||||
const next = Math.max(0.1, Math.min(4, scale));
|
||||
dispatch({ type: 'zoomAt', scale: next, anchor: { x: anchorX, y: anchorY } });
|
||||
}}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
216
packages/editor/src/editor/Inspector.tsx
Normal file
216
packages/editor/src/editor/Inspector.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import type { TextWidgetNode, WidgetNode } from '@astralview/sdk';
|
||||
|
||||
export function Inspector(props: {
|
||||
selected?: WidgetNode;
|
||||
onUpdateTextProps: (id: string, patch: Partial<TextWidgetNode['props']>) => void;
|
||||
}) {
|
||||
const node = props.selected;
|
||||
|
||||
if (!node) {
|
||||
return <Typography.Paragraph style={{ color: '#666' }}>No selection.</Typography.Paragraph>;
|
||||
}
|
||||
|
||||
if (node.type !== 'text') {
|
||||
return <Typography.Paragraph style={{ color: '#666' }}>Unsupported widget type: {node.type}</Typography.Paragraph>;
|
||||
}
|
||||
|
||||
const fontWeight = node.props.fontWeight ?? 400;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
Text
|
||||
</Typography.Title>
|
||||
|
||||
<Typography.Text type="secondary">Content</Typography.Text>
|
||||
<Input
|
||||
value={node.props.text}
|
||||
onChange={(e) => props.onUpdateTextProps(node.id, { text: e.target.value })}
|
||||
placeholder="Text"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<Space style={{ width: '100%' }} size={12}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Font size</Typography.Text>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={node.props.fontSize ?? 24}
|
||||
min={1}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { fontSize: typeof v === 'number' ? v : 24 })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Weight</Typography.Text>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
value={String(fontWeight)}
|
||||
onChange={(v) => {
|
||||
const asNum = Number(v);
|
||||
props.onUpdateTextProps(node.id, { fontWeight: Number.isFinite(asNum) ? asNum : v });
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: '300', label: 'Light (300)' },
|
||||
{ value: '400', label: 'Regular (400)' },
|
||||
{ value: '500', label: 'Medium (500)' },
|
||||
{ value: '600', label: 'Semibold (600)' },
|
||||
{ value: '700', label: 'Bold (700)' },
|
||||
{ value: '800', label: 'Extrabold (800)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Typography.Text type="secondary">Color</Typography.Text>
|
||||
<Input
|
||||
value={node.props.color ?? '#ffffff'}
|
||||
onChange={(e) => props.onUpdateTextProps(node.id, { color: e.target.value })}
|
||||
placeholder="#ffffff"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<Space style={{ width: '100%' }} size={12}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Padding X</Typography.Text>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={node.props.paddingX ?? 0}
|
||||
min={0}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { paddingX: typeof v === 'number' ? v : 0 })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Padding Y</Typography.Text>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={node.props.paddingY ?? 0}
|
||||
min={0}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { paddingY: typeof v === 'number' ? v : 0 })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%' }} size={12}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Letter spacing</Typography.Text>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={node.props.letterSpacing ?? 0}
|
||||
min={0}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { letterSpacing: typeof v === 'number' ? v : 0 })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary">Align</Typography.Text>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
value={node.props.textAlign ?? 'center'}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { textAlign: v })}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Typography.Text type="secondary">Background</Typography.Text>
|
||||
<Input
|
||||
value={node.props.backgroundColor ?? 'transparent'}
|
||||
onChange={(e) => props.onUpdateTextProps(node.id, { backgroundColor: e.target.value })}
|
||||
placeholder="transparent or #00000000"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<Typography.Text type="secondary">Writing mode</Typography.Text>
|
||||
<Select
|
||||
value={node.props.writingMode ?? 'horizontal-tb'}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { writingMode: v })}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
options={[
|
||||
{ value: 'horizontal-tb', label: 'Horizontal' },
|
||||
{ value: 'vertical-rl', label: 'Vertical' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Text type="secondary">Link</Typography.Text>
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} size={8}>
|
||||
<Input
|
||||
value={node.props.linkHead ?? 'http://'}
|
||||
onChange={(e) => props.onUpdateTextProps(node.id, { linkHead: e.target.value })}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Input
|
||||
value={node.props.link ?? ''}
|
||||
onChange={(e) => props.onUpdateTextProps(node.id, { link: e.target.value })}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Typography.Text type="secondary">Border</Typography.Text>
|
||||
<Space style={{ width: '100%' }} size={12}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<InputNumber
|
||||
value={node.props.borderWidth ?? 0}
|
||||
min={0}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { borderWidth: typeof v === 'number' ? v : 0 })}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="W"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<Input
|
||||
value={node.props.borderColor ?? '#ffffff'}
|
||||
onChange={(e) => props.onUpdateTextProps(node.id, { borderColor: e.target.value })}
|
||||
placeholder="#ffffff"
|
||||
addonBefore="C"
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8, marginBottom: 12 }}>
|
||||
<InputNumber
|
||||
value={node.props.borderRadius ?? 0}
|
||||
min={0}
|
||||
onChange={(v) => props.onUpdateTextProps(node.id, { borderRadius: typeof v === 'number' ? v : 0 })}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="R"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Typography.Text type="secondary">Quick colors</Typography.Text>
|
||||
<div style={{ marginTop: 6, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#d1d5db', '#93c5fd', '#a7f3d0', '#fca5a5', '#fbbf24'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => props.onUpdateTextProps(node.id, { color: c })}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
background: c,
|
||||
border: '1px solid rgba(0,0,0,0.2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
packages/editor/src/editor/geometry.ts
Normal file
17
packages/editor/src/editor/geometry.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Rect } from '@astralview/sdk';
|
||||
|
||||
export function rectFromPoints(a: { x: number; y: number }, b: { x: number; y: number }): { x1: number; y1: number; x2: number; y2: number } {
|
||||
const x1 = Math.min(a.x, b.x);
|
||||
const y1 = Math.min(a.y, b.y);
|
||||
const x2 = Math.max(a.x, b.x);
|
||||
const y2 = Math.max(a.y, b.y);
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
|
||||
export function rectContains(outer: { x1: number; y1: number; x2: number; y2: number }, inner: Rect): boolean {
|
||||
const ix1 = inner.x;
|
||||
const iy1 = inner.y;
|
||||
const ix2 = inner.x + inner.w;
|
||||
const iy2 = inner.y + inner.h;
|
||||
return ix1 - outer.x1 >= 0 && iy1 - outer.y1 >= 0 && ix2 - outer.x2 <= 0 && iy2 - outer.y2 <= 0;
|
||||
}
|
||||
11
packages/editor/src/editor/history.ts
Normal file
11
packages/editor/src/editor/history.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { Rect, Screen } from '@astralview/sdk';
|
||||
|
||||
export function didRectsChange(snapshot: Map<string, Rect>, current: Screen): boolean {
|
||||
for (const [id, prev] of snapshot) {
|
||||
const node = current.nodes.find((n) => n.id === id);
|
||||
if (!node) return true;
|
||||
const r = node.rect;
|
||||
if (r.x !== prev.x || r.y !== prev.y || r.w !== prev.w || r.h !== prev.h) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
44
packages/editor/src/editor/hotkeys.ts
Normal file
44
packages/editor/src/editor/hotkeys.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { EditorAction } from './store';
|
||||
|
||||
export function bindEditorHotkeys(getShift: () => boolean, dispatch: (a: EditorAction) => void) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Undo/redo
|
||||
if (ctrl && e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault();
|
||||
dispatch({ type: 'undo' });
|
||||
return;
|
||||
}
|
||||
if (ctrl && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) {
|
||||
e.preventDefault();
|
||||
dispatch({ type: 'redo' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
dispatch({ type: 'deleteSelected' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Nudge
|
||||
const step = e.shiftKey || getShift() ? 10 : 1;
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
dispatch({ type: 'nudgeSelected', dx: -step, dy: 0 });
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
dispatch({ type: 'nudgeSelected', dx: step, dy: 0 });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
dispatch({ type: 'nudgeSelected', dx: 0, dy: -step });
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
dispatch({ type: 'nudgeSelected', dx: 0, dy: step });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}
|
||||
10
packages/editor/src/editor/keyboard.ts
Normal file
10
packages/editor/src/editor/keyboard.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface KeyboardState {
|
||||
ctrl: boolean;
|
||||
space: boolean;
|
||||
}
|
||||
|
||||
export function keyboardReducer(prev: KeyboardState, e: KeyboardEvent): KeyboardState {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const space = prev.space || e.code === 'Space';
|
||||
return { ctrl, space };
|
||||
}
|
||||
6
packages/editor/src/editor/pan.ts
Normal file
6
packages/editor/src/editor/pan.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface PanSession {
|
||||
startScreenX: number;
|
||||
startScreenY: number;
|
||||
startPanX: number;
|
||||
startPanY: number;
|
||||
}
|
||||
1
packages/editor/src/editor/snap.test.ts
Normal file
1
packages/editor/src/editor/snap.test.ts
Normal file
@ -0,0 +1 @@
|
||||
// placeholder
|
||||
172
packages/editor/src/editor/snap.ts
Normal file
172
packages/editor/src/editor/snap.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import type { Rect } from '@astralview/sdk';
|
||||
import type { ResizeHandle } from './types';
|
||||
|
||||
export interface Guides {
|
||||
xs: number[];
|
||||
ys: number[];
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
rect: Rect;
|
||||
guides: Guides;
|
||||
}
|
||||
|
||||
export function snapRect(
|
||||
moving: Rect,
|
||||
movingSize: { w: number; h: number } | undefined,
|
||||
others: Rect[],
|
||||
canvas: { w: number; h: number },
|
||||
threshold = 6,
|
||||
): SnapResult {
|
||||
const guides: Guides = { xs: [], ys: [] };
|
||||
|
||||
const mw = movingSize?.w ?? moving.w;
|
||||
const mh = movingSize?.h ?? moving.h;
|
||||
|
||||
const m = {
|
||||
l: moving.x,
|
||||
c: moving.x + mw / 2,
|
||||
r: moving.x + mw,
|
||||
t: moving.y,
|
||||
m: moving.y + mh / 2,
|
||||
b: moving.y + mh,
|
||||
};
|
||||
|
||||
// Candidate lines include other rect edges/centers + canvas edges/centers.
|
||||
const xLines: number[] = [0, canvas.w / 2, canvas.w];
|
||||
const yLines: number[] = [0, canvas.h / 2, canvas.h];
|
||||
|
||||
for (const o of others) {
|
||||
xLines.push(o.x, o.x + o.w / 2, o.x + o.w);
|
||||
yLines.push(o.y, o.y + o.h / 2, o.y + o.h);
|
||||
}
|
||||
|
||||
const bestX = bestSnap1D([m.l, m.c, m.r], xLines, threshold);
|
||||
const bestY = bestSnap1D([m.t, m.m, m.b], yLines, threshold);
|
||||
|
||||
let x = moving.x;
|
||||
let y = moving.y;
|
||||
|
||||
if (bestX) {
|
||||
guides.xs.push(bestX.line);
|
||||
x += bestX.delta;
|
||||
}
|
||||
if (bestY) {
|
||||
guides.ys.push(bestY.line);
|
||||
y += bestY.delta;
|
||||
}
|
||||
|
||||
return {
|
||||
rect: { ...moving, x: Math.round(x), y: Math.round(y) },
|
||||
guides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a rect being resized.
|
||||
*
|
||||
* goView-ish behavior: only snap the edges that are actively being dragged.
|
||||
*
|
||||
* - resizing left: snap left edge (changes x + w)
|
||||
* - resizing right: snap right edge (changes w)
|
||||
* - resizing top: snap top edge (changes y + h)
|
||||
* - resizing bottom: snap bottom edge (changes h)
|
||||
*/
|
||||
export function snapRectResize(
|
||||
resizing: Rect,
|
||||
handle: ResizeHandle,
|
||||
others: Rect[],
|
||||
canvas: { w: number; h: number },
|
||||
threshold = 6,
|
||||
): SnapResult {
|
||||
const guides: Guides = { xs: [], ys: [] };
|
||||
|
||||
// Candidate lines include other rect edges/centers + canvas edges/centers.
|
||||
const xLines: number[] = [0, canvas.w / 2, canvas.w];
|
||||
const yLines: number[] = [0, canvas.h / 2, canvas.h];
|
||||
|
||||
for (const o of others) {
|
||||
xLines.push(o.x, o.x + o.w / 2, o.x + o.w);
|
||||
yLines.push(o.y, o.y + o.h / 2, o.y + o.h);
|
||||
}
|
||||
|
||||
const isTop = /t/.test(handle);
|
||||
const isBottom = /b/.test(handle);
|
||||
const isLeft = /l/.test(handle);
|
||||
const isRight = /r/.test(handle);
|
||||
|
||||
const xPoints: number[] = [];
|
||||
const yPoints: number[] = [];
|
||||
|
||||
if (isLeft) xPoints.push(resizing.x);
|
||||
if (isRight) xPoints.push(resizing.x + resizing.w);
|
||||
|
||||
if (isTop) yPoints.push(resizing.y);
|
||||
if (isBottom) yPoints.push(resizing.y + resizing.h);
|
||||
|
||||
const bestX = xPoints.length ? bestSnap1D(xPoints, xLines, threshold) : null;
|
||||
const bestY = yPoints.length ? bestSnap1D(yPoints, yLines, threshold) : null;
|
||||
|
||||
let rect = { ...resizing };
|
||||
|
||||
if (bestX) {
|
||||
guides.xs.push(bestX.line);
|
||||
|
||||
if (isLeft) {
|
||||
rect = {
|
||||
...rect,
|
||||
x: rect.x + bestX.delta,
|
||||
w: rect.w - bestX.delta,
|
||||
};
|
||||
} else if (isRight) {
|
||||
rect = {
|
||||
...rect,
|
||||
w: rect.w + bestX.delta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (bestY) {
|
||||
guides.ys.push(bestY.line);
|
||||
|
||||
if (isTop) {
|
||||
rect = {
|
||||
...rect,
|
||||
y: rect.y + bestY.delta,
|
||||
h: rect.h - bestY.delta,
|
||||
};
|
||||
} else if (isBottom) {
|
||||
rect = {
|
||||
...rect,
|
||||
h: rect.h + bestY.delta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Keep integers, like the move snap.
|
||||
rect = {
|
||||
...rect,
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
w: Math.round(rect.w),
|
||||
h: Math.round(rect.h),
|
||||
};
|
||||
|
||||
return { rect, guides };
|
||||
}
|
||||
|
||||
function bestSnap1D(points: number[], lines: number[], threshold: number): { line: number; delta: number } | null {
|
||||
let best: { line: number; delta: number; dist: number } | null = null;
|
||||
|
||||
for (const p of points) {
|
||||
for (const line of lines) {
|
||||
const delta = line - p;
|
||||
const dist = Math.abs(delta);
|
||||
if (dist > threshold) continue;
|
||||
if (!best || dist < best.dist) best = { line, delta, dist };
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return null;
|
||||
return { line: best.line, delta: best.delta };
|
||||
}
|
||||
644
packages/editor/src/editor/store.ts
Normal file
644
packages/editor/src/editor/store.ts
Normal file
@ -0,0 +1,644 @@
|
||||
import {
|
||||
convertGoViewProjectToScreen,
|
||||
createEmptyScreen,
|
||||
migrateScreen,
|
||||
type Rect,
|
||||
type Screen,
|
||||
type TextWidgetNode,
|
||||
type WidgetNode,
|
||||
} from '@astralview/sdk';
|
||||
import { rectContains, rectFromPoints } from './geometry';
|
||||
import { didRectsChange } from './history';
|
||||
import { snapRect, snapRectResize } from './snap';
|
||||
import type { EditorState, ResizeHandle } from './types';
|
||||
import { clampRectToBounds } from './types';
|
||||
|
||||
export type EditorAction =
|
||||
| { type: 'keyboard'; ctrl: boolean; space: boolean }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| { type: 'setScale'; scale: number }
|
||||
| { type: 'panBy'; dx: number; dy: number }
|
||||
| { type: 'zoomAt'; scale: number; anchor: { x: number; y: number } }
|
||||
| { type: 'beginPan'; start: { screenX: number; screenY: number } }
|
||||
| { type: 'updatePan'; current: { screenX: number; screenY: number } }
|
||||
| { type: 'endPan' }
|
||||
| { type: 'selectSingle'; id?: string }
|
||||
| { type: 'toggleSelect'; id: string }
|
||||
| { type: 'beginBoxSelect'; start: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
|
||||
| { type: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
|
||||
| { type: 'endBoxSelect' }
|
||||
| { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
|
||||
| { type: 'updateMove'; current: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
|
||||
| { type: 'endMove' }
|
||||
| { type: 'beginResize'; id: string; handle: ResizeHandle; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
|
||||
| { type: 'updateResize'; current: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
|
||||
| { type: 'endResize' }
|
||||
| { type: 'addTextAt'; x: number; y: number }
|
||||
| { type: 'importJSON'; json: string }
|
||||
| { type: 'deleteSelected' }
|
||||
| { type: 'nudgeSelected'; dx: number; dy: number }
|
||||
| { type: 'duplicateSelected' }
|
||||
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> };
|
||||
|
||||
interface DragSession {
|
||||
kind: 'move' | 'resize';
|
||||
startScreenX: number;
|
||||
startScreenY: number;
|
||||
beforeScreen: Screen;
|
||||
targetId?: string;
|
||||
handle?: ResizeHandle;
|
||||
snapshot: Map<string, Rect>;
|
||||
}
|
||||
|
||||
function historyPush(state: EditorState): EditorState {
|
||||
return {
|
||||
...state,
|
||||
history: {
|
||||
past: [...state.history.past, { screen: state.doc.screen }],
|
||||
future: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSelected(state: EditorState, id: string): EditorState {
|
||||
if (state.selection.ids.includes(id)) return state;
|
||||
return { ...state, selection: { ids: [id] } };
|
||||
}
|
||||
|
||||
export function createInitialState(): EditorState {
|
||||
const screen = createEmptyScreen({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
name: 'Demo Screen',
|
||||
nodes: [
|
||||
{
|
||||
id: 'text_1',
|
||||
type: 'text',
|
||||
rect: { x: 100, y: 80, w: 300, h: 60 },
|
||||
props: { text: 'Hello AstralView', fontSize: 32, color: '#ffffff', fontWeight: 600 },
|
||||
} satisfies TextWidgetNode,
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
doc: { screen },
|
||||
selection: { ids: [] },
|
||||
canvas: {
|
||||
scale: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
guides: { xs: [], ys: [] },
|
||||
isDragging: false,
|
||||
isPanning: false,
|
||||
isBoxSelecting: false,
|
||||
mouse: {
|
||||
screenStartX: 0,
|
||||
screenStartY: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
offsetStartX: 0,
|
||||
offsetStartY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
},
|
||||
keyboard: { ctrl: false, space: false },
|
||||
history: { past: [], future: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||
switch (action.type) {
|
||||
case 'keyboard':
|
||||
return { ...state, keyboard: { ctrl: action.ctrl, space: action.space } };
|
||||
|
||||
case 'undo': {
|
||||
const past = state.history.past;
|
||||
if (!past.length) return state;
|
||||
const prev = past[past.length - 1]!.screen;
|
||||
return {
|
||||
...state,
|
||||
doc: { screen: prev },
|
||||
selection: { ids: [] },
|
||||
history: {
|
||||
past: past.slice(0, -1),
|
||||
future: [{ screen: state.doc.screen }, ...state.history.future],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'redo': {
|
||||
const future = state.history.future;
|
||||
if (!future.length) return state;
|
||||
const next = future[0]!.screen;
|
||||
return {
|
||||
...state,
|
||||
doc: { screen: next },
|
||||
selection: { ids: [] },
|
||||
history: {
|
||||
past: [...state.history.past, { screen: state.doc.screen }],
|
||||
future: future.slice(1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'setScale':
|
||||
return { ...state, canvas: { ...state.canvas, scale: action.scale } };
|
||||
|
||||
case 'panBy': {
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
panX: Math.round(state.canvas.panX + action.dx),
|
||||
panY: Math.round(state.canvas.panY + action.dy),
|
||||
guides: { xs: [], ys: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'zoomAt': {
|
||||
const nextScale = action.scale;
|
||||
const prevScale = state.canvas.scale;
|
||||
if (nextScale === prevScale) return state;
|
||||
|
||||
// anchor is in canvas-local pixels (CSS px) relative to the canvas element.
|
||||
// Keep the world point under the cursor stable while zooming.
|
||||
const anchorWorldX = action.anchor.x / prevScale - state.canvas.panX;
|
||||
const anchorWorldY = action.anchor.y / prevScale - state.canvas.panY;
|
||||
const nextPanX = action.anchor.x / nextScale - anchorWorldX;
|
||||
const nextPanY = action.anchor.y / nextScale - anchorWorldY;
|
||||
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
scale: nextScale,
|
||||
panX: Math.round(nextPanX),
|
||||
panY: Math.round(nextPanY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'updateTextProps': {
|
||||
const node = state.doc.screen.nodes.find((n) => n.id === action.id);
|
||||
if (!node || node.type !== 'text') return state;
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: {
|
||||
screen: {
|
||||
...state.doc.screen,
|
||||
nodes: state.doc.screen.nodes.map((n) => {
|
||||
if (n.id !== action.id || n.type !== 'text') return n;
|
||||
return { ...n, props: { ...n.props, ...action.props } };
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'deleteSelected': {
|
||||
if (!state.selection.ids.length) return state;
|
||||
const ids = new Set(state.selection.ids);
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: {
|
||||
screen: {
|
||||
...state.doc.screen,
|
||||
nodes: state.doc.screen.nodes.filter((n) => !ids.has(n.id)),
|
||||
},
|
||||
},
|
||||
selection: { ids: [] },
|
||||
};
|
||||
}
|
||||
|
||||
case 'nudgeSelected': {
|
||||
if (!state.selection.ids.length) return state;
|
||||
const bounds = { w: state.doc.screen.width, h: state.doc.screen.height };
|
||||
const ids = new Set(state.selection.ids);
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: {
|
||||
screen: {
|
||||
...state.doc.screen,
|
||||
nodes: state.doc.screen.nodes.map((n) => {
|
||||
if (!ids.has(n.id)) return n;
|
||||
const rect = clampRectToBounds(
|
||||
{ ...n.rect, x: n.rect.x + action.dx, y: n.rect.y + action.dy },
|
||||
bounds,
|
||||
50,
|
||||
);
|
||||
return { ...n, rect };
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'duplicateSelected': {
|
||||
if (!state.selection.ids.length) return state;
|
||||
const ids = new Set(state.selection.ids);
|
||||
const clones: WidgetNode[] = [];
|
||||
|
||||
for (const n of state.doc.screen.nodes) {
|
||||
if (!ids.has(n.id)) continue;
|
||||
const id = `${n.type}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
clones.push({
|
||||
...n,
|
||||
id,
|
||||
rect: { ...n.rect, x: n.rect.x + 10, y: n.rect.y + 10 },
|
||||
} as WidgetNode);
|
||||
}
|
||||
|
||||
if (!clones.length) return state;
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen: { ...state.doc.screen, nodes: [...state.doc.screen.nodes, ...clones] } },
|
||||
selection: { ids: clones.map((c) => c.id) },
|
||||
};
|
||||
}
|
||||
|
||||
case 'beginPan': {
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
isPanning: true,
|
||||
},
|
||||
// internal session
|
||||
__pan: {
|
||||
startScreenX: action.start.screenX,
|
||||
startScreenY: action.start.screenY,
|
||||
startPanX: state.canvas.panX,
|
||||
startPanY: state.canvas.panY,
|
||||
},
|
||||
} as EditorState & { __pan: { startScreenX: number; startScreenY: number; startPanX: number; startPanY: number } };
|
||||
}
|
||||
|
||||
case 'updatePan': {
|
||||
if (!state.canvas.isPanning) return state;
|
||||
const pan = (state as EditorState & { __pan?: { startScreenX: number; startScreenY: number; startPanX: number; startPanY: number } }).__pan;
|
||||
if (!pan) return state;
|
||||
const scale = state.canvas.scale;
|
||||
const dx = (action.current.screenX - pan.startScreenX) / scale;
|
||||
const dy = (action.current.screenY - pan.startScreenY) / scale;
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
panX: Math.round(pan.startPanX + dx),
|
||||
panY: Math.round(pan.startPanY + dy),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'endPan': {
|
||||
if (!state.canvas.isPanning) return state;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __pan: _pan, ...rest } = state as EditorState & { __pan?: unknown };
|
||||
return {
|
||||
...rest,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
isPanning: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'selectSingle':
|
||||
return { ...state, selection: { ids: action.id ? [action.id] : [] } };
|
||||
|
||||
case 'toggleSelect': {
|
||||
const ids = state.selection.ids;
|
||||
if (ids.includes(action.id)) {
|
||||
return { ...state, selection: { ids: ids.filter((x) => x !== action.id) } };
|
||||
}
|
||||
return { ...state, selection: { ids: [...ids, action.id] } };
|
||||
}
|
||||
|
||||
case 'beginBoxSelect': {
|
||||
if (action.start.screenX === 0 && action.start.screenY === 0) return state;
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
isBoxSelecting: true,
|
||||
mouse: {
|
||||
...state.canvas.mouse,
|
||||
offsetStartX: action.start.offsetX,
|
||||
offsetStartY: action.start.offsetY,
|
||||
offsetX: action.start.offsetX,
|
||||
offsetY: action.start.offsetY,
|
||||
screenStartX: action.start.screenX,
|
||||
screenStartY: action.start.screenY,
|
||||
screenX: action.start.screenX,
|
||||
screenY: action.start.screenY,
|
||||
},
|
||||
},
|
||||
selection: { ids: [] },
|
||||
};
|
||||
}
|
||||
|
||||
case 'updateBoxSelect': {
|
||||
if (!state.canvas.isBoxSelecting) return state;
|
||||
const start = { x: state.canvas.mouse.offsetStartX, y: state.canvas.mouse.offsetStartY };
|
||||
|
||||
// `offsetX/offsetY` are world coordinates (screen-local units) computed by Canvas.
|
||||
const curr = {
|
||||
x: Math.round(action.current.offsetX),
|
||||
y: Math.round(action.current.offsetY),
|
||||
};
|
||||
|
||||
const box = rectFromPoints(start, curr);
|
||||
|
||||
const selected: string[] = [];
|
||||
for (const node of state.doc.screen.nodes) {
|
||||
if (node.locked || node.hidden) continue;
|
||||
if (rectContains(box, node.rect)) selected.push(node.id);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selection: { ids: selected },
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
mouse: {
|
||||
...state.canvas.mouse,
|
||||
offsetX: curr.x,
|
||||
offsetY: curr.y,
|
||||
screenX: action.current.screenX,
|
||||
screenY: action.current.screenY,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'endBoxSelect': {
|
||||
if (!state.canvas.isBoxSelecting) return state;
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
isBoxSelecting: false,
|
||||
mouse: { ...state.canvas.mouse, offsetX: 0, offsetY: 0, offsetStartX: 0, offsetStartY: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'beginMove': {
|
||||
const drag: DragSession = {
|
||||
kind: 'move',
|
||||
startScreenX: action.start.screenX,
|
||||
startScreenY: action.start.screenY,
|
||||
beforeScreen: state.doc.screen,
|
||||
snapshot: new Map<string, Rect>(),
|
||||
};
|
||||
|
||||
for (const id of state.selection.ids) {
|
||||
const node = state.doc.screen.nodes.find((n) => n.id === id);
|
||||
if (!node) continue;
|
||||
drag.snapshot.set(id, { ...node.rect });
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
isDragging: true,
|
||||
mouse: {
|
||||
...state.canvas.mouse,
|
||||
screenStartX: action.start.screenX,
|
||||
screenStartY: action.start.screenY,
|
||||
screenX: action.start.screenX,
|
||||
screenY: action.start.screenY,
|
||||
},
|
||||
},
|
||||
__drag: drag,
|
||||
} as EditorState & { __drag: DragSession };
|
||||
}
|
||||
|
||||
case 'updateMove': {
|
||||
const drag = (state as EditorState & { __drag?: DragSession }).__drag;
|
||||
if (!state.canvas.isDragging || !drag || drag.kind !== 'move') return state;
|
||||
const scale = state.canvas.scale;
|
||||
const dx = (action.current.screenX - drag.startScreenX) / scale;
|
||||
const dy = (action.current.screenY - drag.startScreenY) / scale;
|
||||
|
||||
const bounds = action.bounds;
|
||||
|
||||
// goView-like: only snap when a single node is selected.
|
||||
const canSnap = state.selection.ids.length === 1;
|
||||
const movingId = state.selection.ids[0];
|
||||
|
||||
const others = canSnap
|
||||
? state.doc.screen.nodes
|
||||
.filter((n) => n.id !== movingId)
|
||||
.map((n) => ({ ...n.rect }))
|
||||
: [];
|
||||
|
||||
let guides = { xs: [], ys: [] } as { xs: number[]; ys: number[] };
|
||||
|
||||
const nodes = state.doc.screen.nodes.map((n) => {
|
||||
if (!state.selection.ids.includes(n.id)) return n;
|
||||
const snap0 = drag.snapshot.get(n.id);
|
||||
if (!snap0) return n;
|
||||
|
||||
let rect = clampRectToBounds({ ...snap0, x: snap0.x + dx, y: snap0.y + dy }, bounds, 50);
|
||||
|
||||
if (canSnap && n.id === movingId) {
|
||||
const snapped = snapRect(rect, { w: rect.w, h: rect.h }, others, { w: bounds.w, h: bounds.h }, 6);
|
||||
rect = clampRectToBounds(snapped.rect, bounds, 50);
|
||||
guides = snapped.guides;
|
||||
}
|
||||
|
||||
return { ...n, rect };
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
doc: { screen: { ...state.doc.screen, nodes } },
|
||||
canvas: {
|
||||
...state.canvas,
|
||||
guides,
|
||||
mouse: { ...state.canvas.mouse, screenX: action.current.screenX, screenY: action.current.screenY },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'endMove': {
|
||||
const s = state as EditorState & { __drag?: DragSession };
|
||||
const drag = s.__drag;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __drag: _drag, ...rest } = s as EditorState & { __drag?: unknown };
|
||||
|
||||
if (!drag || drag.kind !== 'move') {
|
||||
return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } };
|
||||
}
|
||||
|
||||
const changed = didRectsChange(drag.snapshot, state.doc.screen);
|
||||
return {
|
||||
...rest,
|
||||
canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } },
|
||||
history: changed
|
||||
? {
|
||||
past: [...state.history.past, { screen: drag.beforeScreen }],
|
||||
future: [],
|
||||
}
|
||||
: state.history,
|
||||
};
|
||||
}
|
||||
|
||||
case 'beginResize': {
|
||||
const next = ensureSelected(state, action.id);
|
||||
const drag: DragSession = {
|
||||
kind: 'resize',
|
||||
startScreenX: action.start.screenX,
|
||||
startScreenY: action.start.screenY,
|
||||
beforeScreen: next.doc.screen,
|
||||
targetId: action.id,
|
||||
handle: action.handle,
|
||||
snapshot: new Map<string, Rect>(),
|
||||
};
|
||||
|
||||
for (const node of next.doc.screen.nodes) {
|
||||
if (next.selection.ids.includes(node.id)) drag.snapshot.set(node.id, { ...node.rect });
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
canvas: { ...next.canvas, isDragging: true, guides: { xs: [], ys: [] } },
|
||||
__drag: drag,
|
||||
} as EditorState & { __drag: DragSession };
|
||||
}
|
||||
|
||||
case 'updateResize': {
|
||||
const drag = (state as EditorState & { __drag?: DragSession }).__drag;
|
||||
if (!state.canvas.isDragging || !drag || drag.kind !== 'resize' || !drag.targetId || !drag.handle) return state;
|
||||
|
||||
const scale = state.canvas.scale;
|
||||
const dx = Math.round((action.current.screenX - drag.startScreenX) / scale);
|
||||
const dy = Math.round((action.current.screenY - drag.startScreenY) / scale);
|
||||
|
||||
const handle = drag.handle;
|
||||
const target = drag.snapshot.get(drag.targetId);
|
||||
if (!target) return state;
|
||||
|
||||
const isTop = /t/.test(handle);
|
||||
const isBottom = /b/.test(handle);
|
||||
const isLeft = /l/.test(handle);
|
||||
const isRight = /r/.test(handle);
|
||||
|
||||
const newH = target.h + (isTop ? -dy : isBottom ? dy : 0);
|
||||
const newW = target.w + (isLeft ? -dx : isRight ? dx : 0);
|
||||
|
||||
const rect: Rect = {
|
||||
x: target.x + (isLeft ? dx : 0),
|
||||
y: target.y + (isTop ? dy : 0),
|
||||
w: Math.max(0, newW),
|
||||
h: Math.max(0, newH),
|
||||
};
|
||||
|
||||
const bounds = action.bounds;
|
||||
|
||||
// goView-like: only snap when resizing a single selected node.
|
||||
const canSnap = state.selection.ids.length === 1;
|
||||
const movingId = drag.targetId;
|
||||
|
||||
const others = canSnap
|
||||
? state.doc.screen.nodes
|
||||
.filter((n) => n.id !== movingId)
|
||||
.map((n) => ({ ...n.rect }))
|
||||
: [];
|
||||
|
||||
let nextRect = clampRectToBounds(rect, bounds, 50);
|
||||
let guides = { xs: [], ys: [] } as { xs: number[]; ys: number[] };
|
||||
|
||||
if (canSnap) {
|
||||
const snapped = snapRectResize(nextRect, drag.handle, others, { w: bounds.w, h: bounds.h }, 6);
|
||||
nextRect = clampRectToBounds(snapped.rect, bounds, 50);
|
||||
guides = snapped.guides;
|
||||
}
|
||||
|
||||
const nodes = state.doc.screen.nodes.map((n) => {
|
||||
if (n.id !== drag.targetId) return n;
|
||||
return { ...n, rect: nextRect };
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
doc: { screen: { ...state.doc.screen, nodes } },
|
||||
canvas: { ...state.canvas, guides },
|
||||
};
|
||||
}
|
||||
|
||||
case 'endResize': {
|
||||
const s = state as EditorState & { __drag?: DragSession };
|
||||
const drag = s.__drag;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __drag: _drag, ...rest } = s as EditorState & { __drag?: unknown };
|
||||
|
||||
if (!drag || drag.kind !== 'resize') {
|
||||
return { ...rest, canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } } };
|
||||
}
|
||||
|
||||
const changed = didRectsChange(drag.snapshot, state.doc.screen);
|
||||
return {
|
||||
...rest,
|
||||
canvas: { ...state.canvas, isDragging: false, guides: { xs: [], ys: [] } },
|
||||
history: changed
|
||||
? {
|
||||
past: [...state.history.past, { screen: drag.beforeScreen }],
|
||||
future: [],
|
||||
}
|
||||
: state.history,
|
||||
};
|
||||
}
|
||||
|
||||
case 'addTextAt': {
|
||||
const id = `text_${Date.now()}`;
|
||||
const node: TextWidgetNode = {
|
||||
id,
|
||||
type: 'text',
|
||||
rect: { x: Math.round(action.x), y: Math.round(action.y), w: 320, h: 60 },
|
||||
props: { text: 'New Text', fontSize: 24, color: '#ffffff' },
|
||||
};
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen: { ...state.doc.screen, nodes: [...state.doc.screen.nodes, node] } },
|
||||
selection: { ids: [id] },
|
||||
};
|
||||
}
|
||||
|
||||
case 'importJSON': {
|
||||
const parsed = JSON.parse(action.json) as unknown;
|
||||
try {
|
||||
const screen = migrateScreen(parsed);
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen },
|
||||
selection: { ids: [] },
|
||||
};
|
||||
} catch {
|
||||
// Fallback: attempt goView project conversion.
|
||||
const screen = convertGoViewProjectToScreen(parsed as unknown as object);
|
||||
return {
|
||||
...historyPush(state),
|
||||
doc: { screen },
|
||||
selection: { ids: [] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function exportScreenJSON(screen: Screen): string {
|
||||
return JSON.stringify(screen, null, 2);
|
||||
}
|
||||
|
||||
export function getNodeById(nodes: WidgetNode[], id: string): WidgetNode | undefined {
|
||||
return nodes.find((n) => n.id === id);
|
||||
}
|
||||
87
packages/editor/src/editor/types.ts
Normal file
87
packages/editor/src/editor/types.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { Rect, Screen, WidgetNode } from '@astralview/sdk';
|
||||
|
||||
export type NodeId = string;
|
||||
|
||||
export type ResizeHandle =
|
||||
| 'tl'
|
||||
| 't'
|
||||
| 'tr'
|
||||
| 'r'
|
||||
| 'br'
|
||||
| 'b'
|
||||
| 'bl'
|
||||
| 'l';
|
||||
|
||||
export interface EditorCanvasState {
|
||||
scale: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
guides: {
|
||||
xs: number[];
|
||||
ys: number[];
|
||||
};
|
||||
isDragging: boolean;
|
||||
isPanning: boolean;
|
||||
isBoxSelecting: boolean;
|
||||
mouse: {
|
||||
screenStartX: number;
|
||||
screenStartY: number;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
offsetStartX: number;
|
||||
offsetStartY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditorSelection {
|
||||
ids: NodeId[];
|
||||
}
|
||||
|
||||
export interface EditorDocument {
|
||||
screen: Screen;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
screen: Screen;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
doc: EditorDocument;
|
||||
selection: EditorSelection;
|
||||
canvas: EditorCanvasState;
|
||||
keyboard: {
|
||||
ctrl: boolean;
|
||||
space: boolean;
|
||||
};
|
||||
history: {
|
||||
past: HistoryEntry[];
|
||||
future: HistoryEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditorAPI {
|
||||
screen: Screen;
|
||||
nodes: WidgetNode[];
|
||||
selectionIds: string[];
|
||||
scale: number;
|
||||
setScale(scale: number): void;
|
||||
addTextNodeAt(x: number, y: number): void;
|
||||
exportJSON(): string;
|
||||
importJSON(json: string): void;
|
||||
}
|
||||
|
||||
export function clampRectToBounds(rect: Rect, bounds: { w: number; h: number }, distance = 50): Rect {
|
||||
let { x, y } = rect;
|
||||
const { w, h } = rect;
|
||||
const minX = -w + distance;
|
||||
const minY = -h + distance;
|
||||
const maxX = bounds.w - distance;
|
||||
const maxY = bounds.h - distance;
|
||||
|
||||
x = Math.min(Math.max(x, minX), maxX);
|
||||
y = Math.min(Math.max(y, minY), maxY);
|
||||
|
||||
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
||||
}
|
||||
68
packages/editor/src/index.css
Normal file
68
packages/editor/src/index.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
11
packages/editor/src/main.tsx
Normal file
11
packages/editor/src/main.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import 'antd/dist/reset.css';
|
||||
import './index.css';
|
||||
import { App } from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
28
packages/editor/tsconfig.app.json
Normal file
28
packages/editor/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/editor/tsconfig.json
Normal file
7
packages/editor/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
packages/editor/tsconfig.node.json
Normal file
26
packages/editor/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
packages/editor/vite.config.ts
Normal file
7
packages/editor/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
21
packages/sdk/package.json
Normal file
21
packages/sdk/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@astralview/sdk",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "node -e \"console.log('no tests yet')\""
|
||||
}
|
||||
}
|
||||
13
packages/sdk/src/core/goview.ts
Normal file
13
packages/sdk/src/core/goview.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Screen } from './schema';
|
||||
|
||||
/**
|
||||
* goView JSON converter (stub).
|
||||
*
|
||||
* The legacy goView format isn't implemented yet; this is a placeholder so we can
|
||||
* start wiring UI + migration flows without committing to the full mapping.
|
||||
*/
|
||||
export function convertGoViewJSONToScreen(input: unknown): Screen {
|
||||
// keep reference to avoid unused-vars lint until implemented
|
||||
void input;
|
||||
throw new Error('convertGoViewJSONToScreen: not implemented yet');
|
||||
}
|
||||
56
packages/sdk/src/core/goview/convert.ts
Normal file
56
packages/sdk/src/core/goview/convert.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { ASTRALVIEW_SCHEMA_VERSION, createEmptyScreen, type Screen, type TextWidgetNode } from '../schema';
|
||||
import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text';
|
||||
|
||||
export interface GoViewComponentLike {
|
||||
id?: string;
|
||||
key?: string;
|
||||
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
|
||||
status?: { lock?: boolean; hide?: boolean };
|
||||
option?: unknown;
|
||||
}
|
||||
|
||||
export interface GoViewProjectLike {
|
||||
// very loose input shape; goView has different versions/branches.
|
||||
width?: number;
|
||||
height?: number;
|
||||
canvas?: { width?: number; height?: number };
|
||||
componentList?: GoViewComponentLike[];
|
||||
}
|
||||
|
||||
export function convertGoViewProjectToScreen(input: GoViewProjectLike): Screen {
|
||||
const width = input.canvas?.width ?? input.width ?? 1920;
|
||||
const height = input.canvas?.height ?? input.height ?? 1080;
|
||||
|
||||
const screen = createEmptyScreen({
|
||||
version: ASTRALVIEW_SCHEMA_VERSION,
|
||||
width,
|
||||
height,
|
||||
name: 'Imported from goView',
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
const nodes: TextWidgetNode[] = [];
|
||||
for (const c of input.componentList ?? []) {
|
||||
// Only first: TextCommon-like
|
||||
const key = c.key ?? '';
|
||||
if (!/text/i.test(key)) continue;
|
||||
|
||||
const rect = c.attr ? { x: c.attr.x, y: c.attr.y, w: c.attr.w, h: c.attr.h } : { x: 0, y: 0, w: 320, h: 60 };
|
||||
const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) as GoViewTextOption);
|
||||
|
||||
nodes.push({
|
||||
id: c.id ?? `goview_text_${Math.random().toString(16).slice(2)}`,
|
||||
type: 'text',
|
||||
rect,
|
||||
zIndex: c.attr?.zIndex,
|
||||
locked: c.status?.lock ?? false,
|
||||
hidden: c.status?.hide ?? false,
|
||||
props,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...screen,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
19
packages/sdk/src/core/migrate.ts
Normal file
19
packages/sdk/src/core/migrate.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ASTRALVIEW_SCHEMA_VERSION, type Screen } from './schema';
|
||||
|
||||
/**
|
||||
* Placeholder for future schema migrations.
|
||||
* Keep it pure and versioned.
|
||||
*/
|
||||
export function migrateScreen(input: unknown): Screen {
|
||||
const s = input as Partial<Screen>;
|
||||
if (!s || typeof s !== 'object') {
|
||||
throw new Error('Invalid screen: not an object');
|
||||
}
|
||||
const version = (s as Screen).version;
|
||||
if (version === ASTRALVIEW_SCHEMA_VERSION) {
|
||||
return s as Screen;
|
||||
}
|
||||
|
||||
// Future: apply incremental migrations.
|
||||
throw new Error(`Unsupported screen version: ${String(version)}`);
|
||||
}
|
||||
23
packages/sdk/src/core/registry.ts
Normal file
23
packages/sdk/src/core/registry.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { WidgetDefinition } from './types';
|
||||
|
||||
export interface Registry {
|
||||
register(def: WidgetDefinition): void;
|
||||
get(id: string): WidgetDefinition | undefined;
|
||||
list(): WidgetDefinition[];
|
||||
}
|
||||
|
||||
export function createRegistry(): Registry {
|
||||
const map = new Map<string, WidgetDefinition>();
|
||||
|
||||
return {
|
||||
register(def) {
|
||||
map.set(def.id, def);
|
||||
},
|
||||
get(id) {
|
||||
return map.get(id);
|
||||
},
|
||||
list() {
|
||||
return Array.from(map.values());
|
||||
},
|
||||
};
|
||||
}
|
||||
87
packages/sdk/src/core/schema.ts
Normal file
87
packages/sdk/src/core/schema.ts
Normal file
@ -0,0 +1,87 @@
|
||||
export const ASTRALVIEW_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type SchemaVersion = typeof ASTRALVIEW_SCHEMA_VERSION;
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
rotate?: number; // degrees
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
}
|
||||
|
||||
export interface WidgetNodeBase {
|
||||
id: string;
|
||||
type: string;
|
||||
rect: Rect;
|
||||
transform?: Transform;
|
||||
locked?: boolean;
|
||||
hidden?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface TextWidgetNode extends WidgetNodeBase {
|
||||
type: 'text';
|
||||
props: {
|
||||
text: string;
|
||||
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontWeight?: number | string;
|
||||
|
||||
// goView parity (TextCommon)
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
letterSpacing?: number;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
writingMode?: string;
|
||||
|
||||
backgroundColor?: string;
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
borderRadius?: number;
|
||||
|
||||
link?: string;
|
||||
linkHead?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type WidgetNode = TextWidgetNode;
|
||||
|
||||
export interface Screen {
|
||||
version: SchemaVersion;
|
||||
id: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
background?: {
|
||||
color?: string;
|
||||
};
|
||||
nodes: WidgetNode[];
|
||||
}
|
||||
|
||||
export function createEmptyScreen(partial?: Partial<Screen>): Screen {
|
||||
return {
|
||||
version: ASTRALVIEW_SCHEMA_VERSION,
|
||||
id: partial?.id ?? cryptoRandomId(),
|
||||
name: partial?.name ?? 'Untitled Screen',
|
||||
width: partial?.width ?? 1920,
|
||||
height: partial?.height ?? 1080,
|
||||
background: partial?.background ?? { color: '#0b1020' },
|
||||
nodes: partial?.nodes ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function assertNever(x: never): never {
|
||||
throw new Error(`Unexpected variant: ${String(x)}`);
|
||||
}
|
||||
|
||||
function cryptoRandomId(): string {
|
||||
// deterministic enough for editor state; replace with UUID later.
|
||||
return `av_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
|
||||
}
|
||||
14
packages/sdk/src/core/types.ts
Normal file
14
packages/sdk/src/core/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export interface DataSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WidgetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
// UI framework-specific rendering must live outside SDK.
|
||||
// SDK only defines data contracts and behavior.
|
||||
propsSchema?: Record<string, unknown>;
|
||||
}
|
||||
44
packages/sdk/src/core/widgets/text.ts
Normal file
44
packages/sdk/src/core/widgets/text.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { TextWidgetNode } from '../schema';
|
||||
|
||||
export interface GoViewTextOption {
|
||||
dataset: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
fontWeight?: string;
|
||||
letterSpacing?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
backgroundColor?: string;
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
borderRadius?: number;
|
||||
writingMode?: string;
|
||||
link?: string;
|
||||
linkHead?: string;
|
||||
}
|
||||
|
||||
/** Convert go-view TextCommon-like option into AstralView TextWidgetNode props. */
|
||||
export function convertGoViewTextOptionToNodeProps(option: GoViewTextOption): TextWidgetNode['props'] {
|
||||
return {
|
||||
text: option.dataset ?? '',
|
||||
|
||||
fontSize: option.fontSize,
|
||||
color: option.fontColor,
|
||||
fontWeight: option.fontWeight,
|
||||
|
||||
paddingX: option.paddingX,
|
||||
paddingY: option.paddingY,
|
||||
letterSpacing: option.letterSpacing,
|
||||
textAlign: option.textAlign,
|
||||
writingMode: option.writingMode,
|
||||
|
||||
backgroundColor: option.backgroundColor,
|
||||
borderWidth: option.borderWidth,
|
||||
borderColor: option.borderColor,
|
||||
borderRadius: option.borderRadius,
|
||||
|
||||
link: option.link,
|
||||
linkHead: option.linkHead,
|
||||
};
|
||||
}
|
||||
26
packages/sdk/src/index.ts
Normal file
26
packages/sdk/src/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type { DataSource, WidgetDefinition } from './core/types';
|
||||
export { createRegistry } from './core/registry';
|
||||
|
||||
export {
|
||||
ASTRALVIEW_SCHEMA_VERSION,
|
||||
createEmptyScreen,
|
||||
assertNever,
|
||||
} from './core/schema';
|
||||
|
||||
export type {
|
||||
SchemaVersion,
|
||||
Rect,
|
||||
Transform,
|
||||
Screen,
|
||||
WidgetNode,
|
||||
TextWidgetNode,
|
||||
} from './core/schema';
|
||||
|
||||
export { migrateScreen } from './core/migrate';
|
||||
|
||||
export type { GoViewTextOption } from './core/widgets/text';
|
||||
export { convertGoViewTextOptionToNodeProps } from './core/widgets/text';
|
||||
|
||||
export type { GoViewProjectLike, GoViewComponentLike } from './core/goview/convert';
|
||||
export { convertGoViewProjectToScreen } from './core/goview/convert';
|
||||
export { convertGoViewJSONToScreen } from './core/goview';
|
||||
9
packages/sdk/tsconfig.json
Normal file
9
packages/sdk/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/sdk/tsconfig.tsbuildinfo
Normal file
1
packages/sdk/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
4628
pnpm-lock.yaml
Normal file
4628
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
- 'apps/*'
|
||||
12
skills/create-package.md
Normal file
12
skills/create-package.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Skill: create-package
|
||||
|
||||
## Goal
|
||||
Create a new package under `packages/<name>` with correct configs and workspace wiring.
|
||||
|
||||
## Checklist
|
||||
1. Create folder `packages/<name>`.
|
||||
2. Add `package.json` with `name: @astralview/<name>` and scripts: `build`, `lint`, `typecheck`, `test`.
|
||||
3. Add `tsconfig.json` extending `../../tsconfig.base.json`.
|
||||
4. Expose entrypoint `src/index.ts`.
|
||||
5. Add minimal README.
|
||||
6. `pnpm install` then `pnpm -r build`.
|
||||
11
skills/refactor-guide.md
Normal file
11
skills/refactor-guide.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Skill: refactor-guide
|
||||
|
||||
## Principle
|
||||
Refactor by **extracting stable, framework-agnostic logic into `packages/sdk`**, then build UI shells in `packages/editor`.
|
||||
|
||||
## Steps
|
||||
1. Identify domain concepts (widgets, data sources, canvas, layout).
|
||||
2. Define types and pure operations in `sdk/core`.
|
||||
3. Implement adapters behind interfaces (storage, network) in `sdk/runtime`.
|
||||
4. In editor, consume only SDK public APIs.
|
||||
5. Add tests to SDK for every extracted behavior.
|
||||
11
skills/release-guide.md
Normal file
11
skills/release-guide.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Skill: release-guide
|
||||
|
||||
## Versioning
|
||||
- Use semver.
|
||||
- Prefer changesets in future if publishing becomes needed.
|
||||
|
||||
## Release checklist
|
||||
1. All quality gates green.
|
||||
2. Update docs.
|
||||
3. Tag release.
|
||||
4. Build artifacts reproducibly.
|
||||
1
third_party/go-view
vendored
Submodule
1
third_party/go-view
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 8e7f9fcda02dfed86493f77a9126c2a388c40a8c
|
||||
23
tsconfig.base.json
Normal file
23
tsconfig.base.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user