chore: bootstrap AstralView monorepo

This commit is contained in:
ErSan 2026-01-27 18:55:05 +08:00
commit d42b210608
59 changed files with 7622 additions and 0 deletions

12
.editorconfig Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}

52
AGENTS.md Normal file
View 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)
## NonNegotiables
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
View 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
View 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
View 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?

View File

@ -0,0 +1,4 @@
import { defineConfig } from 'vitepress'
// https://vitepress.vuejs.org/config/app-configs
export default defineConfig({})

View 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**.

View 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.

View 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.

View 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"
}
}

View 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
View 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
View 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...
},
},
])
```

View 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,
},
},
])

View File

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

View 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"
}
}

View 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

View 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;
}

View File

@ -0,0 +1 @@
export { EditorApp as App } from './editor/EditorApp';

View 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

View 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,
}}
/>
))}
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
}

View 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);
}

View 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 };
}

View File

@ -0,0 +1,6 @@
export interface PanSession {
startScreenX: number;
startScreenY: number;
startPanX: number;
startPanY: number;
}

View File

@ -0,0 +1 @@
// placeholder

View 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 };
}

View 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);
}

View 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) };
}

View 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;
}
}

View 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>,
);

View 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"]
}

View File

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

View File

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

View 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
View 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')\""
}
}

View 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');
}

View 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,
};
}

View 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)}`);
}

View 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());
},
};
}

View 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)}`;
}

View 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>;
}

View 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
View 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';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

4628
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- 'packages/*'
- 'apps/*'

12
skills/create-package.md Normal file
View 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
View 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
View 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

@ -0,0 +1 @@
Subproject commit 8e7f9fcda02dfed86493f77a9126c2a388c40a8c

23
tsconfig.base.json Normal file
View 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
}
}