mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
Add Vue gui project (#7696)
Add a separate `gui2` project with Vue application. Does not modify any existing project files. ![image](https://github.com/enso-org/enso/assets/919491/c7a83521-bf83-4c6a-8d17-91c5eab1f827) # Important Notes Currently not integrated with existing build and testing system.
This commit is contained in:
parent
8a60bc6dcd
commit
82f634b7f8
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -24,6 +24,7 @@ Cargo.toml
|
|||||||
/app/gui/ @MichaelMauderer @farmaazon @mwu-tow @kazcw @vitvakatu @Frizi
|
/app/gui/ @MichaelMauderer @farmaazon @mwu-tow @kazcw @vitvakatu @Frizi
|
||||||
/app/gui/view/ @MichaelMauderer @farmaazon @kazcw @vitvakatu @Frizi
|
/app/gui/view/ @MichaelMauderer @farmaazon @kazcw @vitvakatu @Frizi
|
||||||
/app/gui/view/graph-editor/src/builtin/visualization/java_script/ @MichaelMauderer @farmaazon @kazcw @jdunkerley @vitvakatu @Frizi
|
/app/gui/view/graph-editor/src/builtin/visualization/java_script/ @MichaelMauderer @farmaazon @kazcw @jdunkerley @vitvakatu @Frizi
|
||||||
|
/app/gui2/ @Frizi
|
||||||
|
|
||||||
# Engine (old)
|
# Engine (old)
|
||||||
# This section should be removed once the engine moves to /app/engine
|
# This section should be removed once the engine moves to /app/engine
|
||||||
|
15
app/gui2/.eslintrc.cjs
Normal file
15
app/gui2/.eslintrc.cjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: [
|
||||||
|
'plugin:vue/vue3-essential',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@vue/eslint-config-typescript',
|
||||||
|
'@vue/eslint-config-prettier/skip-formatting',
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
},
|
||||||
|
}
|
24
app/gui2/.gitignore
vendored
Normal file
24
app/gui2/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
8
app/gui2/.prettierrc.json
Normal file
8
app/gui2/.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
9
app/gui2/.vscode/extensions.json
vendored
Normal file
9
app/gui2/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"Vue.vscode-typescript-vue-plugin",
|
||||||
|
"ms-playwright.playwright",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
71
app/gui2/README.md
Normal file
71
app/gui2/README.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# enso-ide
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run End-to-End Tests with [Playwright](https://playwright.dev)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Install browsers for the first run
|
||||||
|
npx playwright install
|
||||||
|
|
||||||
|
# When testing on CI, must build the project first
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Runs the end-to-end tests
|
||||||
|
npm run test:e2e
|
||||||
|
# Runs the tests only on Chromium
|
||||||
|
npm run test:e2e -- --project=chromium
|
||||||
|
# Runs the tests of a specific file
|
||||||
|
npm run test:e2e -- tests/example.spec.ts
|
||||||
|
# Runs the tests in debug mode
|
||||||
|
npm run test:e2e -- --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
4
app/gui2/e2e/tsconfig.json
Normal file
4
app/gui2/e2e/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node18/tsconfig.json",
|
||||||
|
"include": ["./**/*"]
|
||||||
|
}
|
8
app/gui2/e2e/vue.spec.ts
Normal file
8
app/gui2/e2e/vue.spec.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// See here how to get started:
|
||||||
|
// https://playwright.dev/docs/intro
|
||||||
|
test('visits the app root url', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('div.greetings > h1')).toHaveText('You did it!')
|
||||||
|
})
|
6
app/gui2/env.d.ts
vendored
Normal file
6
app/gui2/env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
module 'y-websocket' {
|
||||||
|
// hack for bad module resolution
|
||||||
|
export * from 'node_modules/y-websocket/dist/src/y-websocket'
|
||||||
|
}
|
13
app/gui2/index.html
Normal file
13
app/gui2/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
6272
app/gui2/package-lock.json
generated
Normal file
6272
app/gui2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
app/gui2/package.json
Normal file
51
app/gui2/package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "enso-ide",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check build-only",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"yjs-server": "PORT=1234 YPERSISTENCE=./dbDir node ./node_modules/y-websocket/bin/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
|
"@vueuse/core": "^10.4.1",
|
||||||
|
"lib0": "^0.2.83",
|
||||||
|
"pinia": "^2.1.6",
|
||||||
|
"postcss-nesting": "^12.0.1",
|
||||||
|
"vue": "^3.3.4",
|
||||||
|
"ws": "^8.13.0",
|
||||||
|
"y-protocols": "^1.0.5",
|
||||||
|
"y-textarea": "^1.0.0",
|
||||||
|
"y-websocket": "^1.5.0",
|
||||||
|
"yjs": "^13.6.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.37.0",
|
||||||
|
"@rushstack/eslint-patch": "^1.3.2",
|
||||||
|
"@tsconfig/node18": "^18.2.0",
|
||||||
|
"@types/jsdom": "^21.1.1",
|
||||||
|
"@types/node": "^18.17.5",
|
||||||
|
"@vitejs/plugin-vue": "^4.3.1",
|
||||||
|
"@vue/eslint-config-prettier": "^8.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "^11.0.3",
|
||||||
|
"@vue/test-utils": "^2.4.1",
|
||||||
|
"@vue/tsconfig": "^0.4.0",
|
||||||
|
"eslint": "^8.46.0",
|
||||||
|
"eslint-plugin-vue": "^9.16.1",
|
||||||
|
"jsdom": "^22.1.0",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"typescript": "~5.1.6",
|
||||||
|
"vite": "^4.4.9",
|
||||||
|
"vitest": "^0.34.2",
|
||||||
|
"vue-tsc": "^1.8.8"
|
||||||
|
}
|
||||||
|
}
|
112
app/gui2/playwright.config.ts
Normal file
112
app/gui2/playwright.config.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import type { PlaywrightTestConfig } from '@playwright/test'
|
||||||
|
import { devices } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
const config: PlaywrightTestConfig = {
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Maximum time one test can run for. */
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
/**
|
||||||
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
|
* For example in `await expect(locator).toHaveText();`
|
||||||
|
*/
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
|
actionTimeout: 0,
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
/* Only on CI systems run the tests headless */
|
||||||
|
headless: !!process.env.CI,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Firefox'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Safari'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: {
|
||||||
|
// ...devices['Pixel 5'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: {
|
||||||
|
// ...devices['iPhone 12'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: {
|
||||||
|
// channel: 'msedge',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: {
|
||||||
|
// channel: 'chrome',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
|
// outputDir: 'test-results/',
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
/**
|
||||||
|
* Use the dev server by default for faster feedback loop.
|
||||||
|
* Use the preview server on CI for more realistic testing.
|
||||||
|
Playwright will re-use the local server if there is already a dev-server running.
|
||||||
|
*/
|
||||||
|
command: process.env.CI ? 'vite preview --port 5173' : 'vite dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
5
app/gui2/postcss.config.js
Normal file
5
app/gui2/postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
BIN
app/gui2/public/favicon.ico
Normal file
BIN
app/gui2/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
361
app/gui2/shared/yjs-model.ts
Normal file
361
app/gui2/shared/yjs-model.ts
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import * as y from 'yjs'
|
||||||
|
import { setIfUndefined } from 'lib0/map'
|
||||||
|
import { isSome, type Opt } from '@/util/opt'
|
||||||
|
|
||||||
|
export type Uuid = ReturnType<typeof crypto.randomUUID>
|
||||||
|
declare const BRAND_ExprId: unique symbol
|
||||||
|
export type ExprId = Uuid & { [BRAND_ExprId]: never }
|
||||||
|
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
|
||||||
|
|
||||||
|
interface Handle {
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeMetadata {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vis?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const enum NamedDocKey {
|
||||||
|
NAME = 'name',
|
||||||
|
DOC = 'doc',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NamedDoc {
|
||||||
|
name: y.Text
|
||||||
|
doc: y.Doc
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_id = 0
|
||||||
|
|
||||||
|
type NamedDocMap = y.Map<y.Text | y.Doc>
|
||||||
|
|
||||||
|
export class NamedDocArray {
|
||||||
|
_name: string
|
||||||
|
array: y.Array<NamedDocMap>
|
||||||
|
nameToIndex: Map<string, y.RelativePosition[]>
|
||||||
|
|
||||||
|
constructor(doc: y.Doc, name: string) {
|
||||||
|
this._name = `${name}#${next_id++}`
|
||||||
|
this.array = doc.getArray(name)
|
||||||
|
this.nameToIndex = new Map()
|
||||||
|
this.array.forEach(this.integrateAddedItem.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private integrateAddedItem(item: NamedDocMap, index: number): void {
|
||||||
|
const name = item.get(NamedDocKey.NAME)?.toString()
|
||||||
|
if (name == null) return
|
||||||
|
const indices = setIfUndefined(this.nameToIndex, name, () => [] as y.RelativePosition[])
|
||||||
|
indices.push(y.createRelativePositionFromTypeIndex(this.array, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
names(): string[] {
|
||||||
|
return this.array.map((item) => item.get(NamedDocKey.NAME)?.toString()).filter(isSome)
|
||||||
|
}
|
||||||
|
|
||||||
|
createNew(name: string): NamedDoc {
|
||||||
|
const map = new y.Map<y.Text | y.Doc>()
|
||||||
|
const yName = map.set(NamedDocKey.NAME, new y.Text(name))
|
||||||
|
const doc = map.set(NamedDocKey.DOC, new y.Doc())
|
||||||
|
const pos = y.createRelativePositionFromTypeIndex(this.array, this.array.length)
|
||||||
|
this.array.push([map])
|
||||||
|
return { name: yName, doc }
|
||||||
|
}
|
||||||
|
|
||||||
|
open(name: string): NamedDoc | undefined {
|
||||||
|
const index = this.names().indexOf(name)
|
||||||
|
if (index < 0) return
|
||||||
|
const item = this.array.get(index)
|
||||||
|
if (item == null) return
|
||||||
|
const yName = item.get(NamedDocKey.NAME)
|
||||||
|
const doc = item.get(NamedDocKey.DOC)
|
||||||
|
if (!(yName instanceof y.Text && doc instanceof y.Doc)) return
|
||||||
|
return { name: yName, doc }
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(name: string): void {
|
||||||
|
const relPos = this.nameToIndex.get(name)
|
||||||
|
if (relPos == null) return
|
||||||
|
const pos = y.createAbsolutePositionFromRelativePosition(relPos[0], this.array.doc!)
|
||||||
|
if (pos == null) return
|
||||||
|
this.array.delete(pos.index, 1)
|
||||||
|
this.nameToIndex.delete(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DistributedModel {
|
||||||
|
doc: y.Doc
|
||||||
|
projects: NamedDocArray
|
||||||
|
|
||||||
|
constructor(doc: y.Doc) {
|
||||||
|
this.doc = doc
|
||||||
|
this.projects = new NamedDocArray(this.doc, 'projects')
|
||||||
|
}
|
||||||
|
|
||||||
|
projectNames(): string[] {
|
||||||
|
return this.projects.names()
|
||||||
|
}
|
||||||
|
|
||||||
|
async openProject(name: string): Promise<Opt<DistributedProject>> {
|
||||||
|
await this.doc.whenLoaded
|
||||||
|
const pair = this.projects.open(name)
|
||||||
|
if (pair == null) return null
|
||||||
|
return await DistributedProject.load(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNewProject(name: string): Promise<DistributedProject> {
|
||||||
|
await this.doc.whenLoaded
|
||||||
|
const pair = this.projects.createNew(name)
|
||||||
|
return await DistributedProject.load(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
async openOrCreateProject(name: string): Promise<DistributedProject> {
|
||||||
|
return (await this.openProject(name)) ?? (await this.createNewProject(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProject(name: string): void {
|
||||||
|
this.projects.delete(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.projects.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DistributedProject {
|
||||||
|
doc: y.Doc
|
||||||
|
name: y.Text
|
||||||
|
modules: NamedDocArray
|
||||||
|
|
||||||
|
static async load(pair: NamedDoc) {
|
||||||
|
const project = new DistributedProject(pair)
|
||||||
|
project.doc.load()
|
||||||
|
await project.doc.whenLoaded
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(pair: NamedDoc) {
|
||||||
|
this.doc = pair.doc
|
||||||
|
this.name = pair.name
|
||||||
|
this.modules = new NamedDocArray(this.doc, 'modules')
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleNames(): string[] {
|
||||||
|
return this.modules.names()
|
||||||
|
}
|
||||||
|
|
||||||
|
async openModule(name: string): Promise<DistributedModule | null> {
|
||||||
|
const pair = this.modules.open(name)
|
||||||
|
if (pair == null) return null
|
||||||
|
return await DistributedModule.load(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNewModule(name: string): Promise<DistributedModule> {
|
||||||
|
const pair = this.modules.createNew(name)
|
||||||
|
return await DistributedModule.load(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
async openOrCreateModule(name: string): Promise<DistributedModule> {
|
||||||
|
return (await this.openModule(name)) ?? (await this.createNewModule(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModule(name: string): void {
|
||||||
|
this.modules.delete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DistributedModule {
|
||||||
|
name: y.Text
|
||||||
|
doc: y.Doc
|
||||||
|
contents: y.Text
|
||||||
|
idMap: y.Map<[any, any]>
|
||||||
|
metadata: y.Map<NodeMetadata>
|
||||||
|
|
||||||
|
static async load(pair: NamedDoc) {
|
||||||
|
const module = new DistributedModule(pair)
|
||||||
|
module.doc.load()
|
||||||
|
await module.doc.whenLoaded
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(pair: NamedDoc) {
|
||||||
|
this.name = pair.name
|
||||||
|
this.doc = pair.doc
|
||||||
|
this.contents = this.doc.getText('contents')
|
||||||
|
this.idMap = this.doc.getMap('idMap')
|
||||||
|
this.metadata = this.doc.getMap('metadata')
|
||||||
|
}
|
||||||
|
|
||||||
|
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
|
||||||
|
const range = [offset, offset + content.length]
|
||||||
|
const newId = crypto.randomUUID() as ExprId
|
||||||
|
this.doc.transact(() => {
|
||||||
|
this.contents.insert(offset, content + '\n')
|
||||||
|
const start = y.createRelativePositionFromTypeIndex(this.contents, range[0])
|
||||||
|
const end = y.createRelativePositionFromTypeIndex(this.contents, range[1])
|
||||||
|
const startJson = y.relativePositionToJSON(start)
|
||||||
|
const endJson = y.relativePositionToJSON(end)
|
||||||
|
this.idMap.set(newId, [startJson, endJson])
|
||||||
|
this.metadata.set(newId, meta)
|
||||||
|
})
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpressionContent(id: ExprId, content: string): void {
|
||||||
|
const rangeJson = this.idMap.get(id)
|
||||||
|
if (rangeJson == null) return
|
||||||
|
const range = [
|
||||||
|
y.createRelativePositionFromJSON(rangeJson[0]),
|
||||||
|
y.createRelativePositionFromJSON(rangeJson[1]),
|
||||||
|
]
|
||||||
|
const start = y.createAbsolutePositionFromRelativePosition(range[0], this.doc)?.index
|
||||||
|
const end = y.createAbsolutePositionFromRelativePosition(range[1], this.doc)?.index
|
||||||
|
const idMapData = this.idMap.toJSON()
|
||||||
|
if (start == null || end == null) return
|
||||||
|
this.doc.transact(() => {
|
||||||
|
this.contents.delete(start, end - start)
|
||||||
|
this.contents.insert(start, content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
|
||||||
|
const existing = this.metadata.get(id) ?? { x: 0, y: 0 }
|
||||||
|
this.metadata.set(id, { ...existing, ...meta })
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdMap(): IdMap {
|
||||||
|
return new IdMap(this.idMap, this.contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.doc.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelativeRange {
|
||||||
|
start: y.RelativePosition
|
||||||
|
end: y.RelativePosition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessor for the ID map stored in shared yjs map as relative ranges. Synchronizes the ranges
|
||||||
|
* that were accessed during parsing, throws away stale ones. The text contents is used to translate
|
||||||
|
* the relative ranges to absolute ranges, but it is not modified.
|
||||||
|
*/
|
||||||
|
export class IdMap {
|
||||||
|
private contents: y.Text
|
||||||
|
private doc: y.Doc
|
||||||
|
private yMap: y.Map<[any, any]>
|
||||||
|
private rangeToExpr: Map<string, ExprId>
|
||||||
|
private accessed: Set<ExprId>
|
||||||
|
private finished: boolean
|
||||||
|
|
||||||
|
constructor(yMap: y.Map<[any, any]>, contents: y.Text) {
|
||||||
|
if (yMap.doc == null) {
|
||||||
|
throw new Error('IdMap must be associated with a document')
|
||||||
|
}
|
||||||
|
this.doc = yMap.doc
|
||||||
|
this.contents = contents
|
||||||
|
this.yMap = yMap
|
||||||
|
this.rangeToExpr = new Map()
|
||||||
|
this.accessed = new Set()
|
||||||
|
|
||||||
|
yMap.forEach((range, expr) => {
|
||||||
|
if (!isUuid(expr)) return
|
||||||
|
const indices = this.modelToIndices(range)
|
||||||
|
if (indices == null) return
|
||||||
|
this.rangeToExpr.set(IdMap.keyForRange(indices), expr as ExprId)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.finished = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static keyForRange(range: [number, number]): string {
|
||||||
|
return `${range[0]}:${range[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private modelToIndices(range: [any, any]): [number, number] | null {
|
||||||
|
const relStart = y.createRelativePositionFromJSON(range[0])
|
||||||
|
const relEnd = y.createRelativePositionFromJSON(range[1])
|
||||||
|
const start = y.createAbsolutePositionFromRelativePosition(relStart, this.doc)
|
||||||
|
const end = y.createAbsolutePositionFromRelativePosition(relEnd, this.doc)
|
||||||
|
if (start == null || end == null) return null
|
||||||
|
return [start.index, end.index]
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrInsertUniqueId(range: [number, number]): ExprId {
|
||||||
|
if (this.finished) {
|
||||||
|
throw new Error('IdMap already finished')
|
||||||
|
}
|
||||||
|
if (range[0] === 0 && range[1] == 1) {
|
||||||
|
debugger
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = IdMap.keyForRange(range)
|
||||||
|
const val = this.rangeToExpr.get(key)
|
||||||
|
if (val !== undefined) {
|
||||||
|
this.accessed.add(val)
|
||||||
|
return val
|
||||||
|
} else {
|
||||||
|
const newId = crypto.randomUUID() as ExprId
|
||||||
|
this.rangeToExpr.set(key, newId)
|
||||||
|
this.accessed.add(newId)
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessedSoFar(): ReadonlySet<ExprId> {
|
||||||
|
return this.accessed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish accessing or modifying ID map. Synchronizes the accessed keys back to the shared map,
|
||||||
|
* removes keys that were present previously, but were not accessed.
|
||||||
|
*
|
||||||
|
* Can be called at most once. After calling this method, the ID map is no longer usable.
|
||||||
|
*/
|
||||||
|
finishAndSynchronize(): void {
|
||||||
|
if (this.finished) {
|
||||||
|
throw new Error('IdMap already finished')
|
||||||
|
}
|
||||||
|
this.finished = true
|
||||||
|
|
||||||
|
const doc = this.doc
|
||||||
|
|
||||||
|
doc.transact(() => {
|
||||||
|
this.yMap.forEach((currentRange, expr) => {
|
||||||
|
// Expressions that were accessed and present in the map are guaranteed to match. There is
|
||||||
|
// no mechanism for modifying them, so we don't need to check for equality. We only need to
|
||||||
|
// delete the expressions ones that are not used anymore.
|
||||||
|
if (!this.accessed.delete(expr as ExprId)) {
|
||||||
|
this.yMap.delete(expr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.rangeToExpr.forEach((expr, key) => {
|
||||||
|
// for all remaining expressions, we need to write them into the map
|
||||||
|
if (!this.accessed.has(expr)) return
|
||||||
|
const range = key.split(':').map((x) => parseInt(x, 10)) as [number, number]
|
||||||
|
const start = y.createRelativePositionFromTypeIndex(this.contents, range[0])
|
||||||
|
const end = y.createRelativePositionFromTypeIndex(this.contents, range[1])
|
||||||
|
const startJson = y.relativePositionToJSON(start)
|
||||||
|
const endJson = y.relativePositionToJSON(end)
|
||||||
|
this.yMap.set(expr, [startJson, endJson])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/
|
||||||
|
export function isUuid(x: any): x is Uuid {
|
||||||
|
return typeof x === 'string' && x.length === 36 && uuidRegex.test(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentRange = [number, number]
|
||||||
|
|
||||||
|
export function rangeEncloses(a: ContentRange, b: ContentRange): boolean {
|
||||||
|
return a[0] <= b[0] && a[1] >= b[1]
|
||||||
|
}
|
13
app/gui2/src/App.vue
Normal file
13
app/gui2/src/App.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProjectView from './views/ProjectView.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProjectView class="flex" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flex {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
73
app/gui2/src/assets/base.css
Normal file
73
app/gui2/src/assets/base.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition: color 0.5s, background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
12
app/gui2/src/assets/main.css
Normal file
12
app/gui2/src/assets/main.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
70
app/gui2/src/components/ComponentBrowser.vue
Normal file
70
app/gui2/src/components/ComponentBrowser.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useWindowEvent } from '@/util/events'
|
||||||
|
import type { useNavigator } from '@/util/navigator'
|
||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
navigator: ReturnType<typeof useNavigator>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const shown = ref(false)
|
||||||
|
const scenePosition = ref(Vec2.Zero())
|
||||||
|
|
||||||
|
function positionAtMouse(): boolean {
|
||||||
|
const nav = props.navigator
|
||||||
|
const mousePos = nav.sceneMousePos
|
||||||
|
if (mousePos == null) return false
|
||||||
|
scenePosition.value = mousePos
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = computed(() => {
|
||||||
|
const nav = props.navigator
|
||||||
|
const pos = scenePosition.value
|
||||||
|
return `${nav.transform} translate(${pos.x}px, ${pos.y}px) scale(${
|
||||||
|
1 / nav.scale
|
||||||
|
}) translateY(-100%)`
|
||||||
|
})
|
||||||
|
|
||||||
|
useWindowEvent('keypress', (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Enter':
|
||||||
|
if (!shown.value && positionAtMouse()) {
|
||||||
|
shown.value = true
|
||||||
|
} else {
|
||||||
|
shown.value = false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ComponentBrowser" v-if="shown" :style="{ transform }">
|
||||||
|
<div class="panel components">COMPONENTS</div>
|
||||||
|
<div class="panel docs">DOCS</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ComponentBrowser {
|
||||||
|
color: red;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 10px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
56
app/gui2/src/components/GraphEdge.vue
Normal file
56
app/gui2/src/components/GraphEdge.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Edge } from '@/stores/graph'
|
||||||
|
import type { Rect } from '@/stores/rect'
|
||||||
|
import { clamp } from '@vueuse/core'
|
||||||
|
import type { ExprId } from 'shared/yjs-model'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
edge: Edge
|
||||||
|
nodeRects: Map<ExprId, Rect>
|
||||||
|
exprRects: Map<ExprId, Rect>
|
||||||
|
exprNodes: Map<ExprId, ExprId>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const edgePath = computed(() => {
|
||||||
|
let edge = props.edge
|
||||||
|
const targetNodeId = props.exprNodes.get(edge.target)
|
||||||
|
if (targetNodeId == null) return
|
||||||
|
let sourceNodeRect = props.nodeRects.get(edge.source)
|
||||||
|
let targetNodeRect = props.nodeRects.get(targetNodeId)
|
||||||
|
let targetRect = props.exprRects.get(edge.target)
|
||||||
|
if (sourceNodeRect == null || targetRect == null || targetNodeRect == null) return
|
||||||
|
let sourcePos = sourceNodeRect.center()
|
||||||
|
let targetPos = targetRect.center().add(targetNodeRect.pos)
|
||||||
|
|
||||||
|
let sourceRangeX = sourceNodeRect.rangeX()
|
||||||
|
const EDGE_PADDING = 20
|
||||||
|
const sourceX = clamp(targetPos.x, sourceRangeX[0] + EDGE_PADDING, sourceRangeX[1] - EDGE_PADDING)
|
||||||
|
const LINE_OUT = 20
|
||||||
|
const QUAD_OUT = 50
|
||||||
|
|
||||||
|
const midpointX = (sourceX + targetPos.x) / 2
|
||||||
|
const midpointY = (sourcePos.y + targetPos.y) / 2
|
||||||
|
|
||||||
|
return `
|
||||||
|
M ${sourceX} ${sourcePos.y}
|
||||||
|
L ${sourceX} ${sourcePos.y + LINE_OUT}
|
||||||
|
Q ${sourceX} ${sourcePos.y + QUAD_OUT} ${midpointX} ${midpointY}
|
||||||
|
Q ${targetPos.x} ${targetPos.y - QUAD_OUT} ${targetPos.x} ${targetPos.y - LINE_OUT}
|
||||||
|
L ${targetPos.x} ${targetPos.y}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<path :d="edgePath" stroke="black" stroke-width="4" fill="none" class="edge" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edge {
|
||||||
|
stroke: tan;
|
||||||
|
}
|
||||||
|
.edge:hover {
|
||||||
|
stroke: red;
|
||||||
|
}
|
||||||
|
</style>
|
115
app/gui2/src/components/GraphEditor.vue
Normal file
115
app/gui2/src/components/GraphEditor.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||||
|
import GraphEdge from '@/components/GraphEdge.vue'
|
||||||
|
import GraphNode from '@/components/GraphNode.vue'
|
||||||
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import type { Rect } from '@/stores/rect'
|
||||||
|
import { useWindowEvent } from '@/util/events'
|
||||||
|
import { useNavigator } from '@/util/navigator'
|
||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import type { ContentRange, ExprId } from '../../shared/yjs-model'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
const viewportNode = ref<HTMLElement>()
|
||||||
|
const navigator = useNavigator(viewportNode)
|
||||||
|
const graphStore = useGraphStore()
|
||||||
|
|
||||||
|
const nodeRects = reactive(new Map<ExprId, Rect>())
|
||||||
|
const exprRects = reactive(new Map<ExprId, Rect>())
|
||||||
|
|
||||||
|
function updateNodeRect(id: ExprId, rect: Rect) {
|
||||||
|
nodeRects.set(id, rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExprRect(id: ExprId, rect: Rect) {
|
||||||
|
exprRects.set(id, rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
const circlePos = ref(Vec2.Zero())
|
||||||
|
|
||||||
|
function updateMousePos() {
|
||||||
|
const pos = navigator.sceneMousePos
|
||||||
|
if (pos != null) {
|
||||||
|
circlePos.value = pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardBusy() {
|
||||||
|
return document.activeElement != document.body
|
||||||
|
}
|
||||||
|
|
||||||
|
useWindowEvent('keypress', (e) => {
|
||||||
|
if (keyboardBusy()) return
|
||||||
|
const pos = navigator.sceneMousePos
|
||||||
|
if (pos == null) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'n':
|
||||||
|
const n = graphStore.createNode(pos)
|
||||||
|
if (n == null) return
|
||||||
|
graphStore.setNodeContent(n, 'hello "world"! 123 + x')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateNodeContent(id: ExprId, range: ContentRange, content: string) {
|
||||||
|
graphStore.replaceNodeSubexpression(id, range, content)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="viewportNode" class="viewport" v-on="navigator.events" @mousemove="updateMousePos">
|
||||||
|
<svg :viewBox="navigator.viewBox">
|
||||||
|
<GraphEdge
|
||||||
|
v-for="edge in graphStore.edges"
|
||||||
|
:edge="edge"
|
||||||
|
:nodeRects="nodeRects"
|
||||||
|
:exprRects="exprRects"
|
||||||
|
:exprNodes="graphStore.exprNodes"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div :style="{ transform: navigator.transform }" class="htmlLayer">
|
||||||
|
<GraphNode
|
||||||
|
v-for="[id, node] in graphStore.nodes"
|
||||||
|
:key="id"
|
||||||
|
:node="node"
|
||||||
|
@updateRect="updateNodeRect(id, $event)"
|
||||||
|
@delete="graphStore.deleteNode(id)"
|
||||||
|
@updateExprRect="updateExprRect"
|
||||||
|
@updateContent="(range, c) => updateNodeContent(id, range, c)"
|
||||||
|
@updatePosition="graphStore.setNodePosition(id, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ComponentBrowser :navigator="navigator" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
contain: layout;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmlLayer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: purple;
|
||||||
|
}
|
||||||
|
</style>
|
320
app/gui2/src/components/GraphNode.vue
Normal file
320
app/gui2/src/components/GraphNode.vue
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Node } from '@/stores/graph'
|
||||||
|
import { Rect } from '@/stores/rect'
|
||||||
|
import { usePointer, useResizeObserver } from '@/util/events'
|
||||||
|
import { computed, onUpdated, reactive, ref, watch, watchEffect } from 'vue'
|
||||||
|
import NodeSpan from './NodeSpan.vue'
|
||||||
|
import type { ContentRange, ExprId } from '../../shared/yjs-model'
|
||||||
|
import type { Vec2 } from '@/util/vec2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: Node
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updateRect: [rect: Rect]
|
||||||
|
updateExprRect: [id: ExprId, rect: Rect]
|
||||||
|
updateContent: [range: ContentRange, content: string]
|
||||||
|
updatePosition: [pos: Vec2]
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const rootNode = ref<HTMLElement>()
|
||||||
|
const nodeSize = useResizeObserver(rootNode)
|
||||||
|
const editableRoot = ref<HTMLElement>()
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const size = nodeSize.value
|
||||||
|
if (!size.isZero()) {
|
||||||
|
emit('updateRect', new Rect(props.node.position, nodeSize.value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragPointer = usePointer((event) => {
|
||||||
|
emit('updatePosition', props.node.position.add(event.delta))
|
||||||
|
})
|
||||||
|
|
||||||
|
const transform = computed(() => {
|
||||||
|
let pos = props.node.position
|
||||||
|
return `translate(${pos.x}px, ${pos.y}px)`
|
||||||
|
})
|
||||||
|
|
||||||
|
function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): number {
|
||||||
|
if (domNode instanceof HTMLElement && domOffset == 1) {
|
||||||
|
const offsetData = domNode.dataset.spanStart
|
||||||
|
const offset = (offsetData != null && parseInt(offsetData)) || 0
|
||||||
|
const length = domNode.textContent?.length ?? 0
|
||||||
|
return offset + length
|
||||||
|
} else if (domNode instanceof Text) {
|
||||||
|
const siblingEl = domNode.previousElementSibling
|
||||||
|
if (siblingEl instanceof HTMLElement) {
|
||||||
|
const offsetData = siblingEl.dataset.spanStart
|
||||||
|
if (offsetData != null)
|
||||||
|
return parseInt(offsetData) + domOffset + (siblingEl.textContent?.length ?? 0)
|
||||||
|
}
|
||||||
|
const offsetData = domNode.parentElement?.dataset.spanStart
|
||||||
|
if (offsetData != null) return parseInt(offsetData) + domOffset
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRange(range: ContentRange, threhsold: number, adjust: number) {
|
||||||
|
range[0] = updateOffset(range[0], threhsold, adjust)
|
||||||
|
range[1] = updateOffset(range[1], threhsold, adjust)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOffset(offset: number, threhsold: number, adjust: number) {
|
||||||
|
return offset >= threhsold ? offset + adjust : offset
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExprRect(id: ExprId, rect: Rect) {
|
||||||
|
emit('updateExprRect', id, rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextEdit {
|
||||||
|
range: ContentRange
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const editsToApply = reactive<TextEdit[]>([])
|
||||||
|
|
||||||
|
function editContent(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!(e instanceof InputEvent)) return
|
||||||
|
|
||||||
|
const domRanges = e.getTargetRanges()
|
||||||
|
const ranges = domRanges.map<ContentRange>((r) => {
|
||||||
|
return [
|
||||||
|
getRelatedSpanOffset(r.startContainer, r.startOffset),
|
||||||
|
getRelatedSpanOffset(r.endContainer, r.endOffset),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
switch (e.inputType) {
|
||||||
|
case 'insertText': {
|
||||||
|
const content = e.data ?? ''
|
||||||
|
for (let range of ranges) {
|
||||||
|
if (range[0] != range[1]) {
|
||||||
|
editsToApply.push({ range, content: '' })
|
||||||
|
}
|
||||||
|
editsToApply.push({ range: [range[1], range[1]], content })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'insertFromDrop':
|
||||||
|
case 'insertFromPaste': {
|
||||||
|
const content = e.dataTransfer?.getData('text/plain')
|
||||||
|
if (content != null) {
|
||||||
|
for (let range of ranges) {
|
||||||
|
editsToApply.push({ range, content })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'deleteByCut':
|
||||||
|
case 'deleteWordBackward':
|
||||||
|
case 'deleteWordForward':
|
||||||
|
case 'deleteContentBackward':
|
||||||
|
case 'deleteContentForward':
|
||||||
|
case 'deleteByDrag': {
|
||||||
|
for (let range of ranges) {
|
||||||
|
editsToApply.push({ range, content: '' })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(editsToApply, () => {
|
||||||
|
if (editsToApply.length === 0) return
|
||||||
|
saveSelections()
|
||||||
|
let edit: TextEdit | undefined
|
||||||
|
while ((edit = editsToApply.shift())) {
|
||||||
|
const range = edit.range
|
||||||
|
const content = edit.content
|
||||||
|
const adjust = content.length - (range[1] - range[0])
|
||||||
|
editsToApply.forEach((e) => updateRange(e.range, range[1], adjust))
|
||||||
|
if (selectionToRecover) {
|
||||||
|
selectionToRecover.ranges.forEach((r) => updateRange(r, range[1], adjust))
|
||||||
|
if (selectionToRecover.anchor != null) {
|
||||||
|
selectionToRecover.anchor = updateOffset(selectionToRecover.anchor, range[1], adjust)
|
||||||
|
}
|
||||||
|
if (selectionToRecover.focus != null) {
|
||||||
|
selectionToRecover.focus = updateOffset(selectionToRecover.focus, range[1], adjust)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('updateContent', range, content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SavedSelections {
|
||||||
|
anchor: number | null
|
||||||
|
focus: number | null
|
||||||
|
ranges: ContentRange[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectionToRecover: SavedSelections | null = null
|
||||||
|
|
||||||
|
function saveSelections() {
|
||||||
|
const root = editableRoot.value
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (root == null || selection == null || !selection.containsNode(root, true)) return
|
||||||
|
const ranges: ContentRange[] = Array.from({ length: selection.rangeCount }, (_, i) =>
|
||||||
|
selection.getRangeAt(i),
|
||||||
|
)
|
||||||
|
.filter((r) => r.intersectsNode(root))
|
||||||
|
.map((r) => [
|
||||||
|
getRelatedSpanOffset(r.startContainer, r.startOffset),
|
||||||
|
getRelatedSpanOffset(r.endContainer, r.endOffset),
|
||||||
|
])
|
||||||
|
|
||||||
|
let anchor =
|
||||||
|
selection.anchorNode && root.contains(selection.anchorNode)
|
||||||
|
? getRelatedSpanOffset(selection.anchorNode, selection.anchorOffset)
|
||||||
|
: null
|
||||||
|
let focus =
|
||||||
|
selection.focusNode && root.contains(selection.focusNode)
|
||||||
|
? getRelatedSpanOffset(selection.focusNode, selection.focusOffset)
|
||||||
|
: null
|
||||||
|
|
||||||
|
selectionToRecover = {
|
||||||
|
anchor,
|
||||||
|
focus,
|
||||||
|
ranges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdated(() => {
|
||||||
|
if (selectionToRecover != null && editableRoot.value != null) {
|
||||||
|
const saved = selectionToRecover
|
||||||
|
const root = editableRoot.value
|
||||||
|
selectionToRecover = null
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection == null) return
|
||||||
|
|
||||||
|
function findTextNodeAtOffset(offset: number | null): { node: Text; offset: number } | null {
|
||||||
|
if (offset == null) return null
|
||||||
|
for (let textSpan of root.querySelectorAll<HTMLSpanElement>('span[data-span-start]')) {
|
||||||
|
if (textSpan.children.length > 0) continue
|
||||||
|
const start = parseInt(textSpan.dataset.spanStart ?? '0')
|
||||||
|
const text = textSpan.textContent ?? ''
|
||||||
|
const end = start + text.length
|
||||||
|
if (start <= offset && offset <= end) {
|
||||||
|
let remainingOffset = offset - start
|
||||||
|
for (let node of textSpan.childNodes) {
|
||||||
|
if (node instanceof Text) {
|
||||||
|
let length = node.data.length
|
||||||
|
if (remainingOffset > length) {
|
||||||
|
remainingOffset -= length
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
offset: remainingOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let range of saved.ranges) {
|
||||||
|
const start = findTextNodeAtOffset(range[0])
|
||||||
|
const end = findTextNodeAtOffset(range[1])
|
||||||
|
if (start == null || end == null) continue
|
||||||
|
let newRange = document.createRange()
|
||||||
|
newRange.setStart(start.node, start.offset)
|
||||||
|
newRange.setEnd(end.node, end.offset)
|
||||||
|
selection.addRange(newRange)
|
||||||
|
}
|
||||||
|
if (saved.anchor != null || saved.focus != null) {
|
||||||
|
const anchor = findTextNodeAtOffset(saved.anchor) ?? {
|
||||||
|
node: selection.anchorNode,
|
||||||
|
offset: selection.anchorOffset,
|
||||||
|
}
|
||||||
|
const focus = findTextNodeAtOffset(saved.focus) ?? {
|
||||||
|
node: selection.focusNode,
|
||||||
|
offset: selection.focusOffset,
|
||||||
|
}
|
||||||
|
if (anchor.node == null || focus.node == null) return
|
||||||
|
selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClick(e: PointerEvent) {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
emit('delete')
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="Node"
|
||||||
|
ref="rootNode"
|
||||||
|
:style="{ transform }"
|
||||||
|
v-on="dragPointer.events"
|
||||||
|
:class="{ dragging: dragPointer.dragging }"
|
||||||
|
>
|
||||||
|
<div class="icon" @pointerdown="handleClick">@  </div>
|
||||||
|
<div class="binding" @pointerdown.stop>{{ node.binding }}</div>
|
||||||
|
<div
|
||||||
|
class="editable"
|
||||||
|
contenteditable
|
||||||
|
ref="editableRoot"
|
||||||
|
@beforeinput="editContent"
|
||||||
|
spellcheck="false"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<NodeSpan
|
||||||
|
:content="node.content"
|
||||||
|
:span="node.rootSpan"
|
||||||
|
:offset="0"
|
||||||
|
@updateExprRect="updateExprRect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.Node {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
caret-shape: bar;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #222;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding {
|
||||||
|
color: #ccc;
|
||||||
|
margin-right: 10px;
|
||||||
|
position: absolute;
|
||||||
|
right: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Node.dragging,
|
||||||
|
.Node.dragging .icon {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
41
app/gui2/src/components/HelloWorld.vue
Normal file
41
app/gui2/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
msg: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="greetings">
|
||||||
|
<h1 class="green">{{ msg }}</h1>
|
||||||
|
<h3>
|
||||||
|
You’ve successfully created a project with
|
||||||
|
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||||
|
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
position: relative;
|
||||||
|
top: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greetings h1,
|
||||||
|
.greetings h3 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.greetings h1,
|
||||||
|
.greetings h3 {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
105
app/gui2/src/components/NodeSpan.vue
Normal file
105
app/gui2/src/components/NodeSpan.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { spanKindName, type Span } from '@/stores/graph'
|
||||||
|
import { Rect } from '@/stores/rect'
|
||||||
|
import { useResizeObserver } from '@/util/events'
|
||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import type { ExprId } from '../../shared/yjs-model'
|
||||||
|
import { computed, onUpdated, ref, shallowRef, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string
|
||||||
|
span: Span
|
||||||
|
offset: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'updateExprRect', expr: ExprId, rect: Rect): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const spanClass = computed(() => spanKindName(props.span.kind))
|
||||||
|
|
||||||
|
const exprPart = computed(() => {
|
||||||
|
return props.content.substring(props.offset, props.offset + props.span.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rootNode = ref<HTMLElement>()
|
||||||
|
const nodeSize = useResizeObserver(rootNode, false)
|
||||||
|
|
||||||
|
const exprRect = shallowRef<Rect>()
|
||||||
|
|
||||||
|
function updateRect() {
|
||||||
|
let domNode = rootNode.value
|
||||||
|
if (domNode == null) return
|
||||||
|
const pos = new Vec2(domNode.offsetLeft, domNode.offsetTop)
|
||||||
|
const size = nodeSize.value
|
||||||
|
const rect = new Rect(pos, size)
|
||||||
|
if (exprRect.value != null && rect.equals(exprRect.value)) return
|
||||||
|
exprRect.value = rect
|
||||||
|
}
|
||||||
|
|
||||||
|
const childOffsets = computed(() => {
|
||||||
|
let offset = props.offset
|
||||||
|
return props.span.children.map((child) => {
|
||||||
|
const start = offset
|
||||||
|
offset += child.length
|
||||||
|
return start
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(nodeSize, updateRect)
|
||||||
|
|
||||||
|
onUpdated(() => {
|
||||||
|
updateRect()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(exprRect, (rect) => {
|
||||||
|
if (rect == null) return
|
||||||
|
emit('updateExprRect', props.span.id, rect)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="['Span', spanClass]"
|
||||||
|
ref="rootNode"
|
||||||
|
style="{ transform }"
|
||||||
|
:data-span-id="props.span.id"
|
||||||
|
:data-span-start="props.offset"
|
||||||
|
><template v-if="props.span.children.length > 0"
|
||||||
|
><NodeSpan
|
||||||
|
v-for="(child, index) in props.span.children"
|
||||||
|
:key="child.id"
|
||||||
|
:content="props.content"
|
||||||
|
:span="child"
|
||||||
|
:offset="childOffsets[index]"
|
||||||
|
@updateExprRect="(id, rect) => emit('updateExprRect', id, rect)" /></template
|
||||||
|
><template v-else>{{ exprPart }}</template></span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.Span {
|
||||||
|
white-space: pre;
|
||||||
|
|
||||||
|
&.Root {
|
||||||
|
display: inline-block;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Ident {
|
||||||
|
color: #f97;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Token {
|
||||||
|
color: #7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Literal {
|
||||||
|
color: #77f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Group {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
11
app/gui2/src/components/__tests__/HelloWorld.spec.ts
Normal file
11
app/gui2/src/components/__tests__/HelloWorld.spec.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import HelloWorld from '../HelloWorld.vue'
|
||||||
|
|
||||||
|
describe('HelloWorld', () => {
|
||||||
|
it('renders properly', () => {
|
||||||
|
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
||||||
|
expect(wrapper.text()).toContain('Hello Vitest')
|
||||||
|
})
|
||||||
|
})
|
12
app/gui2/src/main.ts
Normal file
12
app/gui2/src/main.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
|
||||||
|
app.mount('#app')
|
12
app/gui2/src/stores/counter.ts
Normal file
12
app/gui2/src/stores/counter.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useCounterStore = defineStore('counter', () => {
|
||||||
|
const count = ref(0)
|
||||||
|
const doubleCount = computed(() => count.value * 2)
|
||||||
|
function increment() {
|
||||||
|
count.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, doubleCount, increment }
|
||||||
|
})
|
461
app/gui2/src/stores/graph.ts
Normal file
461
app/gui2/src/stores/graph.ts
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
import { computed, reactive, ref, watch, watchEffect } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { map, set } from 'lib0'
|
||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
import { assertNever, assert } from '@/util/assert'
|
||||||
|
import { useProjectStore } from './project'
|
||||||
|
import * as Y from 'yjs'
|
||||||
|
import { useObserveYjs, useObserveYjsDeep } from '@/util/crdt'
|
||||||
|
import {
|
||||||
|
rangeEncloses,
|
||||||
|
type ContentRange,
|
||||||
|
type ExprId,
|
||||||
|
type IdMap,
|
||||||
|
type NodeMetadata,
|
||||||
|
} from '../../shared/yjs-model'
|
||||||
|
import type { Opt } from '@/util/opt'
|
||||||
|
|
||||||
|
export const useGraphStore = defineStore('graph', () => {
|
||||||
|
const proj = useProjectStore()
|
||||||
|
|
||||||
|
proj.setProjectName('test')
|
||||||
|
proj.setObservedFileName('Main.enso')
|
||||||
|
|
||||||
|
let text = computed(() => proj.module?.contents)
|
||||||
|
let metadata = computed(() => proj.module?.metadata)
|
||||||
|
|
||||||
|
const nodes = reactive(new Map<ExprId, Node>())
|
||||||
|
const exprNodes = reactive(new Map<ExprId, ExprId>())
|
||||||
|
|
||||||
|
useObserveYjs(text, (event) => {
|
||||||
|
readState()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(text, (value) => {
|
||||||
|
if (value != null) readState()
|
||||||
|
})
|
||||||
|
|
||||||
|
const _parsed = ref([] as Statement[])
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
if (proj.module == null) return
|
||||||
|
const idMap = proj.module.getIdMap()
|
||||||
|
const meta = proj.module.metadata
|
||||||
|
const text = proj.module.contents
|
||||||
|
const textContent = text.toString()
|
||||||
|
const parsed = parseBlock(0, textContent, idMap)
|
||||||
|
_parsed.value = parsed
|
||||||
|
|
||||||
|
const accessed = idMap.accessedSoFar()
|
||||||
|
|
||||||
|
for (const nodeId of nodes.keys()) {
|
||||||
|
if (!accessed.has(nodeId)) {
|
||||||
|
nodeDeleted(nodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const stmt of parsed) {
|
||||||
|
const nodeMeta = meta.get(stmt.id)
|
||||||
|
nodeInsertedOrUpdated(stmt, text, textContent, nodeMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
idMap.finishAndSynchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
useObserveYjs(metadata, (event) => {
|
||||||
|
const meta = event.target
|
||||||
|
for (const [id, op] of event.changes.keys) {
|
||||||
|
if (op.action === 'update') {
|
||||||
|
const data = meta.get(id)
|
||||||
|
let node = nodes.get(id as ExprId)
|
||||||
|
if (data != null && node != null) {
|
||||||
|
const pos = new Vec2(data.x, data.y)
|
||||||
|
if (!node.position.equals(pos)) {
|
||||||
|
node.position = pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const identDefinitions = reactive(new Map<string, ExprId>())
|
||||||
|
const identUsages = reactive(new Map<string, Set<ExprId>>())
|
||||||
|
|
||||||
|
function nodeInsertedOrUpdated(
|
||||||
|
stmt: Statement,
|
||||||
|
text: Y.Text,
|
||||||
|
moduleContent: string,
|
||||||
|
meta: Opt<NodeMetadata>,
|
||||||
|
) {
|
||||||
|
const id = stmt.id
|
||||||
|
let node = nodes.get(id)
|
||||||
|
const content = moduleContent.substring(
|
||||||
|
stmt.exprOffset,
|
||||||
|
stmt.exprOffset + stmt.expression.length,
|
||||||
|
)
|
||||||
|
if (node == null) {
|
||||||
|
node = {
|
||||||
|
content,
|
||||||
|
binding: stmt.binding ?? '',
|
||||||
|
rootSpan: stmt.expression,
|
||||||
|
position: meta == null ? Vec2.Zero() : new Vec2(meta.x, meta.y),
|
||||||
|
docRange: [
|
||||||
|
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset),
|
||||||
|
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
identDefinitions.set(node.binding, id)
|
||||||
|
addSpanUsages(id, node)
|
||||||
|
nodes.set(id, node)
|
||||||
|
} else {
|
||||||
|
clearSpanUsages(id, node)
|
||||||
|
if (node.content !== content) {
|
||||||
|
node.content = content
|
||||||
|
for (let [span, offset] of walkSpansBfs(stmt.expression, stmt.exprOffset)) {
|
||||||
|
if (span.kind === SpanKind.Ident) {
|
||||||
|
let ident = moduleContent.substring(offset, offset + span.length)
|
||||||
|
map.setIfUndefined(identUsages, ident, set.create).add(span.id)
|
||||||
|
exprNodes.set(span.id, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.binding !== stmt.binding) {
|
||||||
|
identDefinitions.delete(node.binding)
|
||||||
|
node.binding = stmt.binding ?? ''
|
||||||
|
identDefinitions.set(node.binding, id)
|
||||||
|
}
|
||||||
|
if (node.rootSpan.id === stmt.expression.id) {
|
||||||
|
patchSpan(node.rootSpan, stmt.expression)
|
||||||
|
} else {
|
||||||
|
node.rootSpan = stmt.expression
|
||||||
|
}
|
||||||
|
if (meta != null && !node.position.equals(new Vec2(meta.x, meta.y))) {
|
||||||
|
node.position = new Vec2(meta.x, meta.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpanUsages(id, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSpanUsages(id: ExprId, node: Node) {
|
||||||
|
for (let [span, offset] of walkSpansBfs(node.rootSpan)) {
|
||||||
|
exprNodes.set(span.id, id)
|
||||||
|
let ident = node.content.substring(offset, offset + span.length)
|
||||||
|
if (span.kind === SpanKind.Ident) {
|
||||||
|
map.setIfUndefined(identUsages, ident, set.create).add(span.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSpanUsages(id: ExprId, node: Node) {
|
||||||
|
for (let [span, offset] of walkSpansBfs(node.rootSpan)) {
|
||||||
|
exprNodes.delete(span.id)
|
||||||
|
if (span.kind === SpanKind.Ident) {
|
||||||
|
let ident = node.content.substring(offset, offset + span.length)
|
||||||
|
let usages = identUsages.get(ident)
|
||||||
|
if (usages != null) {
|
||||||
|
usages.delete(span.id)
|
||||||
|
if (usages.size === 0) {
|
||||||
|
identUsages.delete(ident)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeDeleted(id: ExprId) {
|
||||||
|
const node = nodes.get(id)
|
||||||
|
nodes.delete(id)
|
||||||
|
if (node != null) {
|
||||||
|
identDefinitions.delete(node.binding)
|
||||||
|
clearSpanUsages(id, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSpan(span: Span, newSpan: Span) {
|
||||||
|
assert(span.id === newSpan.id)
|
||||||
|
// TODO: deep patching of children of matching ID
|
||||||
|
span.length = newSpan.length
|
||||||
|
span.kind = newSpan.kind
|
||||||
|
span.children = newSpan.children
|
||||||
|
// for (let i = 0; i < span.children.length; i++) {
|
||||||
|
// patchSpan(span.children[i], newSpan.children[i])
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUniqueIdent() {
|
||||||
|
let ident: string
|
||||||
|
do {
|
||||||
|
ident = randomString()
|
||||||
|
} while (identDefinitions.has(ident))
|
||||||
|
return ident
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges = computed(() => {
|
||||||
|
const edges = []
|
||||||
|
for (let [ident, usages] of identUsages) {
|
||||||
|
let source = identDefinitions.get(ident)
|
||||||
|
if (source == null) continue
|
||||||
|
for (let target of usages) {
|
||||||
|
edges.push({ source, target })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
})
|
||||||
|
|
||||||
|
function createNode(position: Vec2): Opt<ExprId> {
|
||||||
|
const mod = proj.module
|
||||||
|
if (mod == null) return
|
||||||
|
const { contents } = mod
|
||||||
|
|
||||||
|
const meta: NodeMetadata = {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
}
|
||||||
|
const ident = generateUniqueIdent()
|
||||||
|
const content = `${ident} = x`
|
||||||
|
return mod.insertNewNode(contents.length, content, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNode(id: ExprId) {
|
||||||
|
const mod = proj.module
|
||||||
|
if (mod == null) return
|
||||||
|
mod.setExpressionContent(id, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNodeContent(id: ExprId, content: string) {
|
||||||
|
const node = nodes.get(id)
|
||||||
|
if (node == null) return
|
||||||
|
setExpressionContent(node.rootSpan.id, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExpressionContent(id: ExprId, content: string) {
|
||||||
|
proj.module?.setExpressionContent(id, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceNodeSubexpression(id: ExprId, range: ContentRange, content: string) {
|
||||||
|
const node = nodes.get(id)
|
||||||
|
if (node == null) return
|
||||||
|
const newContent =
|
||||||
|
node.content.substring(0, range[0]) + content + node.content.substring(range[1])
|
||||||
|
setExpressionContent(node.rootSpan.id, newContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNodePosition(id: ExprId, position: Vec2) {
|
||||||
|
const node = nodes.get(id)
|
||||||
|
if (node == null) return
|
||||||
|
proj.module?.updateNodeMetadata(id, { x: position.x, y: position.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_parsed,
|
||||||
|
nodes,
|
||||||
|
exprNodes,
|
||||||
|
edges,
|
||||||
|
identDefinitions,
|
||||||
|
identUsages,
|
||||||
|
createNode,
|
||||||
|
deleteNode,
|
||||||
|
setNodeContent,
|
||||||
|
setExpressionContent,
|
||||||
|
replaceNodeSubexpression,
|
||||||
|
setNodePosition,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function randomString() {
|
||||||
|
return Math.random().toString(36).substring(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
content: string
|
||||||
|
binding: string
|
||||||
|
rootSpan: Span
|
||||||
|
position: Vec2
|
||||||
|
docRange: [Y.RelativePosition, Y.RelativePosition]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SpanKind {
|
||||||
|
Root = 0,
|
||||||
|
Spacing,
|
||||||
|
Group,
|
||||||
|
Token,
|
||||||
|
Ident,
|
||||||
|
Literal,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spanKindName(kind: SpanKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case SpanKind.Root:
|
||||||
|
return 'Root'
|
||||||
|
case SpanKind.Spacing:
|
||||||
|
return 'Spacing'
|
||||||
|
case SpanKind.Group:
|
||||||
|
return 'Group'
|
||||||
|
case SpanKind.Token:
|
||||||
|
return 'Token'
|
||||||
|
case SpanKind.Ident:
|
||||||
|
return 'Ident'
|
||||||
|
case SpanKind.Literal:
|
||||||
|
return 'Literal'
|
||||||
|
default:
|
||||||
|
assertNever(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Span {
|
||||||
|
id: ExprId
|
||||||
|
kind: SpanKind
|
||||||
|
length: number
|
||||||
|
children: Span[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSpanEnclosing(
|
||||||
|
span: Span,
|
||||||
|
spanOffset: number,
|
||||||
|
range: ContentRange,
|
||||||
|
): [Span, number] | null {
|
||||||
|
let deepestSpan: [Span, number] | null = null
|
||||||
|
for (let [innerSpan, offset] of walkSpansBfs(span, spanOffset, (s, offset) =>
|
||||||
|
rangeEncloses([offset, offset + s.length], range),
|
||||||
|
)) {
|
||||||
|
if (rangeEncloses([offset, offset + span.length], range)) {
|
||||||
|
deepestSpan = [innerSpan, offset]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deepestSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkSpansBfs(
|
||||||
|
span: Span,
|
||||||
|
offset: number = 0,
|
||||||
|
visitChildren?: (span: Span, offset: number) => boolean,
|
||||||
|
): IterableIterator<[Span, number]> {
|
||||||
|
let stack: [Span, number][] = [[span, offset]]
|
||||||
|
return {
|
||||||
|
next() {
|
||||||
|
if (stack.length === 0) {
|
||||||
|
return { done: true, value: undefined }
|
||||||
|
}
|
||||||
|
const [span, spanOffset] = stack.shift()!
|
||||||
|
if (visitChildren?.(span, spanOffset) !== false) {
|
||||||
|
let offset = spanOffset
|
||||||
|
for (let i = 0; i < span.children.length; i++) {
|
||||||
|
const child = span.children[i]
|
||||||
|
stack.push([child, offset])
|
||||||
|
offset += child.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { done: false, value: [span, spanOffset] }
|
||||||
|
},
|
||||||
|
[Symbol.iterator]() {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Edge {
|
||||||
|
source: ExprId
|
||||||
|
target: ExprId
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Statement {
|
||||||
|
id: ExprId
|
||||||
|
binding: Opt<string>
|
||||||
|
exprOffset: number
|
||||||
|
expression: Span
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlock(offset: number, content: string, idMap: IdMap): Statement[] {
|
||||||
|
const stmtRegex = /^( *)(([a-zA-Z0-9_]+) *= *)?(.*)$/gm
|
||||||
|
const stmts: Statement[] = []
|
||||||
|
content.replace(stmtRegex, (stmt, ident, beforeExpr, binding, expr, index) => {
|
||||||
|
if (stmt.trim().length === 0) return stmt
|
||||||
|
const pos = offset + index + ident.length
|
||||||
|
const id = idMap.getOrInsertUniqueId([pos, pos + stmt.length])
|
||||||
|
const exprOffset = pos + (beforeExpr?.length ?? 0)
|
||||||
|
stmts.push({
|
||||||
|
id,
|
||||||
|
binding,
|
||||||
|
exprOffset,
|
||||||
|
expression: parseNodeExpression(exprOffset, expr, idMap),
|
||||||
|
})
|
||||||
|
return stmt
|
||||||
|
})
|
||||||
|
return stmts
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodeExpression(offset: number, content: string, idMap: IdMap): Span {
|
||||||
|
const root = mkSpanGroup(SpanKind.Root)
|
||||||
|
let span: Span = root
|
||||||
|
let spanOffset = offset
|
||||||
|
const stack: [Span, number][] = []
|
||||||
|
|
||||||
|
const tokenRegex = /(?:(\".*?\"|[0-9]+\b)|(\s+)|([a-zA-Z0-9_]+)|(.))/g
|
||||||
|
content.replace(tokenRegex, (token, tokLit, tokSpace, tokIdent, tokSymbol, index) => {
|
||||||
|
const pos = offset + index
|
||||||
|
if (tokSpace != null) {
|
||||||
|
span.children.push(mkSpan(idMap, SpanKind.Spacing, pos, token.length))
|
||||||
|
} else if (tokIdent != null) {
|
||||||
|
span.children.push(mkSpan(idMap, SpanKind.Ident, pos, token.length))
|
||||||
|
} else if (tokLit != null) {
|
||||||
|
span.children.push(mkSpan(idMap, SpanKind.Literal, pos, token.length))
|
||||||
|
} else if (tokSymbol != null) {
|
||||||
|
if (token === '(') {
|
||||||
|
stack.push([span, spanOffset])
|
||||||
|
span = mkSpanGroup(SpanKind.Group)
|
||||||
|
spanOffset = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
span.children.push(mkSpan(idMap, SpanKind.Token, pos, token.length))
|
||||||
|
|
||||||
|
if (token === ')') {
|
||||||
|
const popped = stack.pop()
|
||||||
|
if (popped != null) {
|
||||||
|
finishSpanGroup(span, idMap, spanOffset)
|
||||||
|
popped[0].children.push(span)
|
||||||
|
span = popped[0]
|
||||||
|
spanOffset = popped[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
|
||||||
|
let popped
|
||||||
|
while ((popped = stack.pop())) {
|
||||||
|
finishSpanGroup(span, idMap, spanOffset)
|
||||||
|
popped[0].children.push(span)
|
||||||
|
span = popped[0]
|
||||||
|
spanOffset = popped[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
finishSpanGroup(root, idMap, offset)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const NULL_ID: ExprId = '00000-' as ExprId
|
||||||
|
|
||||||
|
function mkSpanGroup(kind: SpanKind): Span {
|
||||||
|
return {
|
||||||
|
id: NULL_ID,
|
||||||
|
kind,
|
||||||
|
length: 0,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkSpan(idMap: IdMap, kind: SpanKind, offset: number, length: number): Span {
|
||||||
|
const range: ContentRange = [offset, offset + length]
|
||||||
|
return {
|
||||||
|
id: idMap.getOrInsertUniqueId(range),
|
||||||
|
kind,
|
||||||
|
length,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSpanGroup(span: Span, idMap: IdMap, offset: number) {
|
||||||
|
const totalLength = span.children.reduce((acc, child) => acc + child.length, 0)
|
||||||
|
span.length = totalLength
|
||||||
|
span.id = idMap.getOrInsertUniqueId([offset, offset + span.length])
|
||||||
|
}
|
55
app/gui2/src/stores/project.ts
Normal file
55
app/gui2/src/stores/project.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import * as Y from 'yjs'
|
||||||
|
import { attachProvider } from '@/util/crdt'
|
||||||
|
import { DistributedModel } from '@/../shared/yjs-model'
|
||||||
|
import { computedAsync } from '@vueuse/core'
|
||||||
|
import { Awareness } from 'y-protocols/awareness'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project store synchronizes and holds the open project-related data. The synchronization is
|
||||||
|
* performed using a CRDT data types from Yjs. Once the data is synchronized with a "LS bridge"
|
||||||
|
* client, it is submitted to the language server as a document update.
|
||||||
|
*/
|
||||||
|
export const useProjectStore = defineStore('project', () => {
|
||||||
|
// inputs
|
||||||
|
const projectName = ref<string>()
|
||||||
|
const observedFileName = ref<string>()
|
||||||
|
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const awareness = new Awareness(doc)
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
// For now, let's assume that the websocket server is running on the same host as the web server.
|
||||||
|
// Eventually, we can make this configurable, or even runtime variable.
|
||||||
|
let socketUrl = location.origin.replace(/^http/, 'ws') + '/room'
|
||||||
|
const provider = attachProvider(socketUrl, 'enso-projects', doc, awareness)
|
||||||
|
onCleanup(() => {
|
||||||
|
provider.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = new DistributedModel(doc)
|
||||||
|
const project = computedAsync(async () => {
|
||||||
|
const name = projectName.value
|
||||||
|
if (name == null) return
|
||||||
|
return await model.openOrCreateProject(name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const module = computedAsync(async () => {
|
||||||
|
const moduleName = observedFileName.value
|
||||||
|
const p = project.value
|
||||||
|
if (moduleName == null || p == null) return
|
||||||
|
return await p.openOrCreateModule(moduleName)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
setProjectName(name: string) {
|
||||||
|
projectName.value = name
|
||||||
|
},
|
||||||
|
setObservedFileName(name: string) {
|
||||||
|
observedFileName.value = name
|
||||||
|
},
|
||||||
|
module,
|
||||||
|
}
|
||||||
|
})
|
29
app/gui2/src/stores/rect.ts
Normal file
29
app/gui2/src/stores/rect.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Vec2 } from '@/util/vec2'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axis-aligned rectangle. Defined in terms of a top-left point and a size.
|
||||||
|
*/
|
||||||
|
export class Rect {
|
||||||
|
pos: Vec2
|
||||||
|
size: Vec2
|
||||||
|
constructor(pos: Vec2, size: Vec2) {
|
||||||
|
this.pos = pos
|
||||||
|
this.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: Rect): boolean {
|
||||||
|
return this.pos.equals(other.pos) && this.size.equals(other.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
static Zero(): Rect {
|
||||||
|
return new Rect(Vec2.Zero(), Vec2.Zero())
|
||||||
|
}
|
||||||
|
|
||||||
|
center(): Vec2 {
|
||||||
|
return this.pos.addScaled(this.size, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeX(): [number, number] {
|
||||||
|
return [this.pos.x, this.pos.x + this.size.x]
|
||||||
|
}
|
||||||
|
}
|
14
app/gui2/src/template.vue
Normal file
14
app/gui2/src/template.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{}>()
|
||||||
|
const emit = defineEmits<{}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="MyComponent"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.MyComponent {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
128
app/gui2/src/util/animation.ts
Normal file
128
app/gui2/src/util/animation.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
isRef,
|
||||||
|
onUnmounted,
|
||||||
|
proxyRefs,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
type WatchSource,
|
||||||
|
type Ref,
|
||||||
|
} from 'vue'
|
||||||
|
import { evalWatchSource } from './reactivity'
|
||||||
|
|
||||||
|
const rafCallbacks: { fn: (t: number, dt: number) => void; priority: number }[] = []
|
||||||
|
|
||||||
|
const animTime = ref(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `requestAnimationFrame` API to perform animation. The callback will be called every frame
|
||||||
|
* while the `active` watch source returns true value.
|
||||||
|
*
|
||||||
|
* For performing simple easing animations, see [`useApproach`].
|
||||||
|
*
|
||||||
|
* @param active As long as it returns true value, the `fn` callback will be called every frame.
|
||||||
|
* @param fn The callback to call every animation frame.
|
||||||
|
* @param priority When multiple callbacks are registered, the one with the lowest priority number
|
||||||
|
* will be called first. Default priority is 0. For callbacks with the same priority, the order of
|
||||||
|
* execution matches the order of registration.
|
||||||
|
*/
|
||||||
|
export function useRaf(
|
||||||
|
active: WatchSource<boolean>,
|
||||||
|
fn: (t: number, dt: number) => void,
|
||||||
|
priority = 0,
|
||||||
|
): void {
|
||||||
|
const callback = { fn, priority }
|
||||||
|
function mountRaf() {
|
||||||
|
const idx = rafCallbacks.findIndex((cb) => cb.priority > priority)
|
||||||
|
if (idx >= 0) {
|
||||||
|
rafCallbacks.splice(idx, 0, callback)
|
||||||
|
} else {
|
||||||
|
rafCallbacks.push(callback)
|
||||||
|
runRaf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function unmountRaf() {
|
||||||
|
const i = rafCallbacks.indexOf(callback)
|
||||||
|
if (i >= 0) {
|
||||||
|
rafCallbacks.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(active, (isActive, previous) => {
|
||||||
|
if (isActive === previous) return
|
||||||
|
if (isActive) {
|
||||||
|
mountRaf()
|
||||||
|
} else {
|
||||||
|
unmountRaf()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(unmountRaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rafRunning = false
|
||||||
|
function tick(time: number) {
|
||||||
|
const lastTime = animTime.value
|
||||||
|
const delta = Math.max(0, time - lastTime)
|
||||||
|
animTime.value = time
|
||||||
|
if (rafCallbacks.length > 0) {
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
rafCallbacks.forEach((cb) => cb.fn(time, delta))
|
||||||
|
} else {
|
||||||
|
rafRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRaf() {
|
||||||
|
if (!rafRunning) {
|
||||||
|
rafRunning = true
|
||||||
|
animTime.value = window.performance.now()
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDiffFn = (a: number, b: number): number => b - a
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate value over time using exponential approach.
|
||||||
|
* http://badladns.com/stories/exp-approach
|
||||||
|
*
|
||||||
|
* @param to Target value to approach.
|
||||||
|
* @param timeHorizon Time at which the approach will be at 63% of the target value. Effectively
|
||||||
|
* represents a speed of the approach. Lower values means faster animation.
|
||||||
|
* @param epsilon The approach will stop when the difference between the current value and the
|
||||||
|
* target value is less than `epsilon`. This is to prevent the animation from running forever.
|
||||||
|
* @param diffFn Function that will be used to calculate the difference between two values.
|
||||||
|
* By default, the difference is calculated as simple number difference `b - a`.
|
||||||
|
* Custom `diffFn` can be used to implement e.g. angle value approach over the shortest arc.
|
||||||
|
*/
|
||||||
|
export function useApproach(
|
||||||
|
to: WatchSource<number>,
|
||||||
|
timeHorizon: number,
|
||||||
|
epsilon = 0.005,
|
||||||
|
diffFn = defaultDiffFn,
|
||||||
|
) {
|
||||||
|
const target = evalWatchSource(to)
|
||||||
|
const current = ref(target.value)
|
||||||
|
|
||||||
|
useRaf(
|
||||||
|
() => target.value != current.value,
|
||||||
|
(_, dt) => {
|
||||||
|
const targetVal = target.value
|
||||||
|
const currentValue = current.value
|
||||||
|
if (targetVal != currentValue) {
|
||||||
|
const diff = diffFn(targetVal, currentValue)
|
||||||
|
if (Math.abs(diff) > epsilon) {
|
||||||
|
current.value = targetVal + diff / Math.exp(dt / timeHorizon)
|
||||||
|
} else {
|
||||||
|
current.value = targetVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
current.value = target.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyRefs({ value: current, skip })
|
||||||
|
}
|
11
app/gui2/src/util/assert.ts
Normal file
11
app/gui2/src/util/assert.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export function assertNever(x: never): never {
|
||||||
|
throw new Error('Unexpected object: ' + x)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assert(condition: boolean): asserts condition {
|
||||||
|
if (!condition) throw new Error('Assertion failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertUnreachable(): never {
|
||||||
|
throw new Error('Unreachable code')
|
||||||
|
}
|
81
app/gui2/src/util/crdt.ts
Normal file
81
app/gui2/src/util/crdt.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { watchEffect, type Ref } from 'vue'
|
||||||
|
import type { Awareness } from 'y-protocols/awareness'
|
||||||
|
import { WebsocketProvider } from 'y-websocket'
|
||||||
|
import * as Y from 'yjs'
|
||||||
|
import type { Opt } from './opt'
|
||||||
|
|
||||||
|
export function useObserveYjs<T>(
|
||||||
|
textRef: Ref<Opt<Y.AbstractType<T>>>,
|
||||||
|
observer: (event: T, transaction: Y.Transaction) => void,
|
||||||
|
) {
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const text = textRef.value
|
||||||
|
if (text == null) return
|
||||||
|
|
||||||
|
text.observe(observer)
|
||||||
|
onCleanup(() => {
|
||||||
|
text.unobserve(observer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObserveYjsDeep(
|
||||||
|
textRef: Ref<Opt<Y.AbstractType<any>>>,
|
||||||
|
observer: (event: Y.YEvent<any>[], transaction: Y.Transaction) => void,
|
||||||
|
) {
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const text = textRef.value
|
||||||
|
if (text == null) return
|
||||||
|
|
||||||
|
text.observeDeep(observer)
|
||||||
|
onCleanup(() => {
|
||||||
|
text.unobserveDeep(observer)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubdocsEvent {
|
||||||
|
loaded: Set<Y.Doc>
|
||||||
|
added: Set<Y.Doc>
|
||||||
|
removed: Set<Y.Doc>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachProvider(url: string, room: string, doc: Y.Doc, awareness: Awareness) {
|
||||||
|
const provider = new WebsocketProvider(url, room, doc, { awareness })
|
||||||
|
const onSync = () => doc.emit('sync', [true])
|
||||||
|
const onDrop = () => doc.emit('sync', [false])
|
||||||
|
|
||||||
|
const attachedSubdocs = new Map<Y.Doc, ReturnType<typeof attachProvider>>()
|
||||||
|
|
||||||
|
function onSubdocs(e: SubdocsEvent) {
|
||||||
|
e.loaded.forEach((subdoc) => {
|
||||||
|
const subdocRoom = `${room}--${subdoc.guid}`
|
||||||
|
attachedSubdocs.set(subdoc, attachProvider(url, subdocRoom, subdoc, awareness))
|
||||||
|
})
|
||||||
|
e.removed.forEach((subdoc) => {
|
||||||
|
const subdocProvider = attachedSubdocs.get(subdoc)
|
||||||
|
attachedSubdocs.delete(subdoc)
|
||||||
|
if (subdocProvider != null) {
|
||||||
|
subdocProvider.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.on('sync', onSync)
|
||||||
|
provider.on('connection-close', onDrop)
|
||||||
|
provider.on('connection-error', onDrop)
|
||||||
|
doc.on('subdocs', onSubdocs)
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
provider.disconnect()
|
||||||
|
provider.off('sync', onSync)
|
||||||
|
provider.off('connection-close', onDrop)
|
||||||
|
provider.off('connection-error', onDrop)
|
||||||
|
doc.off('subdocs', onSubdocs)
|
||||||
|
attachedSubdocs.forEach((subdocProvider) => {
|
||||||
|
subdocProvider.dispose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
doc.on('subdocs', (e: SubdocsEvent) => {})
|
||||||
|
return { provider, dispose: dispose }
|
||||||
|
}
|
226
app/gui2/src/util/events.ts
Normal file
226
app/gui2/src/util/events.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
proxyRefs,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
type Ref,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
watchEffect,
|
||||||
|
type WatchSource,
|
||||||
|
} from 'vue'
|
||||||
|
import { Vec2 } from './vec2'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an event listener on window for the duration of component lifetime.
|
||||||
|
* @param event name of event to register
|
||||||
|
* @param handler event handler
|
||||||
|
*/
|
||||||
|
export function useWindowEvent<K extends keyof WindowEventMap>(
|
||||||
|
event: K,
|
||||||
|
handler: (e: WindowEventMap[K]) => void,
|
||||||
|
options?: boolean | AddEventListenerOptions,
|
||||||
|
): void {
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(event, handler, options)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(event, handler, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an event listener on window for the duration of condition being true.
|
||||||
|
* @param condition the condition that determines if event is bound
|
||||||
|
* @param event name of event to register
|
||||||
|
* @param handler event handler
|
||||||
|
*/
|
||||||
|
export function useWindowEventConditional<K extends keyof WindowEventMap>(
|
||||||
|
event: K,
|
||||||
|
condition: WatchSource<boolean>,
|
||||||
|
handler: (e: WindowEventMap[K]) => void,
|
||||||
|
options?: boolean | AddEventListenerOptions,
|
||||||
|
): void {
|
||||||
|
watch(condition, (conditionMet, _, onCleanup) => {
|
||||||
|
if (conditionMet) {
|
||||||
|
window.addEventListener(event, handler, options)
|
||||||
|
onCleanup(() => window.removeEventListener(event, handler, options))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// const hasWindow = typeof window !== 'undefined'
|
||||||
|
// const platform = hasWindow ? window.navigator?.platform ?? '' : ''
|
||||||
|
// const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DOM node size and keep it up to date.
|
||||||
|
*
|
||||||
|
* # Warning:
|
||||||
|
* Updating DOM node layout based on values derived from their size can introduce unwanted feedback
|
||||||
|
* loops across the script and layout reflow. Avoid doing that.
|
||||||
|
*
|
||||||
|
* @param elementRef DOM node to observe.
|
||||||
|
* @returns Reactive value with the DOM node size.
|
||||||
|
*/
|
||||||
|
export function useResizeObserver(
|
||||||
|
elementRef: Ref<Element | undefined | null>,
|
||||||
|
useContentRect = true,
|
||||||
|
): Ref<Vec2> {
|
||||||
|
const sizeRef = shallowRef<Vec2>(Vec2.Zero())
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
let rect: { width: number; height: number } | null = null
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target === elementRef.value) {
|
||||||
|
if (useContentRect) {
|
||||||
|
rect = entry.contentRect
|
||||||
|
} else {
|
||||||
|
rect = entry.target.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rect != null) {
|
||||||
|
sizeRef.value = new Vec2(rect.width, rect.height)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
const element = elementRef.value
|
||||||
|
if (element != null) {
|
||||||
|
observer.observe(element)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (elementRef.value != null) {
|
||||||
|
observer.unobserve(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sizeRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventPosition {
|
||||||
|
/** The event position at the initialization of the drag. */
|
||||||
|
initial: Vec2
|
||||||
|
/** Absolute event position, equivalent to clientX/Y. */
|
||||||
|
absolute: Vec2
|
||||||
|
/** Event position relative to the initial position. Total movement of the drag so far. */
|
||||||
|
relative: Vec2
|
||||||
|
/** Difference of the event position since last event. */
|
||||||
|
delta: Vec2
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointerEventType = 'start' | 'move' | 'stop'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mask of all available pointer buttons. The values are compatible with DOM's `PointerEvent.buttons` value. The mask values
|
||||||
|
* can be ORed together to create a mask of multiple buttons.
|
||||||
|
*/
|
||||||
|
export const enum PointerButtonMask {
|
||||||
|
/** No buttons are pressed. */
|
||||||
|
Empty = 0,
|
||||||
|
/** Main mouse button, usually left. */
|
||||||
|
Main = 1,
|
||||||
|
/** Secondary mouse button, usually right. */
|
||||||
|
Secondary = 2,
|
||||||
|
/** Auxiliary mouse button, usually middle or wheel press. */
|
||||||
|
Auxiliary = 4,
|
||||||
|
/** Additional fourth mouse button, usually assigned to "browser back" action. */
|
||||||
|
ExtBack = 8,
|
||||||
|
/** Additional fifth mouse button, usually assigned to "browser forward" action. */
|
||||||
|
ExtForward = 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register for a pointer dragging events.
|
||||||
|
*
|
||||||
|
* @param handler callback on any pointer event
|
||||||
|
* @param requiredButtonMask declare which buttons to look for. The value represents a `PointerEvent.buttons` mask.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function usePointer(
|
||||||
|
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void,
|
||||||
|
requiredButtonMask: number = PointerButtonMask.Main,
|
||||||
|
) {
|
||||||
|
const trackedPointer: Ref<number | null> = ref(null)
|
||||||
|
let trackedElement: Element | null = null
|
||||||
|
let initialGrabPos: Vec2 | null = null
|
||||||
|
let lastPos: Vec2 | null = null
|
||||||
|
|
||||||
|
const isTracking = () => trackedPointer.value != null
|
||||||
|
|
||||||
|
function doStop(e: PointerEvent) {
|
||||||
|
if (trackedElement != null && trackedPointer.value != null) {
|
||||||
|
trackedElement.releasePointerCapture(trackedPointer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedPointer.value = null
|
||||||
|
|
||||||
|
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||||
|
handler(computePosition(e, initialGrabPos, lastPos), e, 'stop')
|
||||||
|
lastPos = null
|
||||||
|
trackedElement = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doMove(e: PointerEvent) {
|
||||||
|
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||||
|
handler(computePosition(e, initialGrabPos, lastPos), e, 'move')
|
||||||
|
lastPos = new Vec2(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useWindowEventConditional('pointerup', isTracking, (e: PointerEvent) => {
|
||||||
|
if (trackedPointer.value === e.pointerId) {
|
||||||
|
e.preventDefault()
|
||||||
|
doStop(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useWindowEventConditional('pointermove', isTracking, (e: PointerEvent) => {
|
||||||
|
if (trackedPointer.value === e.pointerId) {
|
||||||
|
e.preventDefault()
|
||||||
|
// handle release of all masked buttons as stop
|
||||||
|
if ((e.buttons & requiredButtonMask) != 0) {
|
||||||
|
doMove(e)
|
||||||
|
} else {
|
||||||
|
doStop(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const events = {
|
||||||
|
pointerdown(e: PointerEvent) {
|
||||||
|
// pointers should not respond to unmasked mouse buttons
|
||||||
|
if ((e.buttons & requiredButtonMask) == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackedPointer.value == null && e.currentTarget instanceof Element) {
|
||||||
|
e.preventDefault()
|
||||||
|
trackedPointer.value = e.pointerId
|
||||||
|
trackedElement = e.currentTarget
|
||||||
|
trackedElement.setPointerCapture(e.pointerId)
|
||||||
|
initialGrabPos = new Vec2(e.clientX, e.clientY)
|
||||||
|
lastPos = initialGrabPos
|
||||||
|
handler(computePosition(e, initialGrabPos, lastPos), e, 'start')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyRefs({
|
||||||
|
events,
|
||||||
|
dragging: computed(() => trackedPointer.value != null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePosition(event: PointerEvent, initial: Vec2, last: Vec2): EventPosition {
|
||||||
|
return {
|
||||||
|
initial,
|
||||||
|
absolute: new Vec2(event.clientX, event.clientY),
|
||||||
|
relative: new Vec2(event.clientX - initial.x, event.clientY - initial.y),
|
||||||
|
delta: new Vec2(event.clientX - last.x, event.clientY - last.y),
|
||||||
|
}
|
||||||
|
}
|
119
app/gui2/src/util/navigator.ts
Normal file
119
app/gui2/src/util/navigator.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { computed, nextTick, proxyRefs, ref, type Ref } from 'vue'
|
||||||
|
import { PointerButtonMask, usePointer, useResizeObserver, useWindowEvent } from './events'
|
||||||
|
import { Vec2 } from './vec2'
|
||||||
|
import { Rect } from '@/stores/rect'
|
||||||
|
|
||||||
|
function elemRect(target: Element | undefined): Rect {
|
||||||
|
if (target != null && target instanceof Element) {
|
||||||
|
let domRect = target.getBoundingClientRect()
|
||||||
|
return new Rect(new Vec2(domRect.x, domRect.y), new Vec2(domRect.width, domRect.height))
|
||||||
|
}
|
||||||
|
return Rect.Zero()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||||
|
const size = useResizeObserver(viewportNode)
|
||||||
|
const center = ref<Vec2>(Vec2.Zero())
|
||||||
|
const scale = ref(1)
|
||||||
|
const panPointer = usePointer((pos) => {
|
||||||
|
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
|
||||||
|
}, PointerButtonMask.Auxiliary)
|
||||||
|
|
||||||
|
function eventToScenePos(event: PointerEvent, client?: Vec2): Vec2 {
|
||||||
|
const rect = elemRect(viewportNode.value)
|
||||||
|
const clientPos = client ?? new Vec2(event.clientX, event.clientY)
|
||||||
|
const canvasPos = clientPos.sub(rect.pos)
|
||||||
|
const v = viewport.value
|
||||||
|
return new Vec2(
|
||||||
|
v.pos.x + v.size.x * (canvasPos.x / rect.size.x),
|
||||||
|
v.pos.y + v.size.y * (canvasPos.y / rect.size.y),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastDragTimestamp: number = 0
|
||||||
|
let dragHasMoved: boolean = false
|
||||||
|
let zoomPivot = Vec2.Zero()
|
||||||
|
const zoomPointer = usePointer((pos, event, ty) => {
|
||||||
|
if (ty === 'start') {
|
||||||
|
dragHasMoved = false
|
||||||
|
zoomPivot = eventToScenePos(event, pos.initial)
|
||||||
|
}
|
||||||
|
if (ty === 'move') {
|
||||||
|
dragHasMoved = true
|
||||||
|
}
|
||||||
|
if (dragHasMoved) {
|
||||||
|
lastDragTimestamp = event.timeStamp
|
||||||
|
}
|
||||||
|
const prevScale = scale.value
|
||||||
|
scale.value = Math.max(0.1, Math.min(10, scale.value * Math.exp(-pos.delta.y / 100)))
|
||||||
|
center.value = center.value
|
||||||
|
.sub(zoomPivot)
|
||||||
|
.scale(prevScale / scale.value)
|
||||||
|
.add(zoomPivot)
|
||||||
|
}, PointerButtonMask.Secondary)
|
||||||
|
|
||||||
|
const viewport = computed(() => {
|
||||||
|
const nodeSize = size.value
|
||||||
|
const { x, y } = center.value
|
||||||
|
const s = scale.value
|
||||||
|
const w = nodeSize.x / s
|
||||||
|
const h = nodeSize.y / s
|
||||||
|
return new Rect(new Vec2(x - w / 2, y - h / 2), new Vec2(w, h))
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewBox = computed(() => {
|
||||||
|
const v = viewport.value
|
||||||
|
return `${v.pos.x} ${v.pos.y} ${v.size.x} ${v.size.y}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const transform = computed(() => {
|
||||||
|
const nodeSize = size.value
|
||||||
|
const { x, y } = center.value
|
||||||
|
const s = scale.value
|
||||||
|
const w = nodeSize.x / s
|
||||||
|
const h = nodeSize.y / s
|
||||||
|
return `scale(${s}) translate(${-x + w / 2}px, ${-y + h / 2}px)`
|
||||||
|
})
|
||||||
|
|
||||||
|
useWindowEvent(
|
||||||
|
'contextmenu',
|
||||||
|
(e) => {
|
||||||
|
if (lastDragTimestamp >= e.timeStamp) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const sceneMousePos = ref<Vec2 | null>(null)
|
||||||
|
|
||||||
|
return proxyRefs({
|
||||||
|
events: {
|
||||||
|
pointermove(e: PointerEvent) {
|
||||||
|
sceneMousePos.value = eventToScenePos(e)
|
||||||
|
},
|
||||||
|
pointerleave() {
|
||||||
|
sceneMousePos.value = null
|
||||||
|
},
|
||||||
|
pointerdown(e: PointerEvent) {
|
||||||
|
panPointer.events.pointerdown(e)
|
||||||
|
zoomPointer.events.pointerdown(e)
|
||||||
|
},
|
||||||
|
wheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
// When using a macbook trackpad, e.ctrlKey indicates a pinch gesture.
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
const s = Math.exp(-e.deltaY / 100)
|
||||||
|
scale.value = Math.min(Math.max(0.5, scale.value * s), 10)
|
||||||
|
} else {
|
||||||
|
let delta = new Vec2(e.deltaX, e.deltaY)
|
||||||
|
center.value = center.value.addScaled(delta, 1 / scale.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scale,
|
||||||
|
viewBox,
|
||||||
|
transform,
|
||||||
|
sceneMousePos,
|
||||||
|
})
|
||||||
|
}
|
25
app/gui2/src/util/opt.ts
Normal file
25
app/gui2/src/util/opt.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Optional value type. This is a replacement for `T | null | undefined` that is more convenient to
|
||||||
|
* use. We do not select a single value to represent "no value", because we are using libraries that
|
||||||
|
* disagree whether `null` (e.g. Yjs) or `undefined` (e.g. Vue) should be used for that purpose. We
|
||||||
|
* want to be compatible with both without needless conversions. In our own code, we should return
|
||||||
|
* `undefined` for "no value", since that is the default value for empty or no `return` expression.
|
||||||
|
* In order to test whether an `Opt<T>` is defined or not, use `x == null` or `isSome` function.
|
||||||
|
*
|
||||||
|
* Note: For JSON-serialized data, prefer explicit `null` over `undefined`, since `undefined` is
|
||||||
|
* not serializable. Alternatively, use optional field syntax (e.g. `{ x?: number }`).
|
||||||
|
*/
|
||||||
|
export type Opt<T> = T | null | undefined
|
||||||
|
|
||||||
|
export function isSome<T>(value: Opt<T>): value is T {
|
||||||
|
return value != null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNone<T>(value: Opt<T>): value is null | undefined {
|
||||||
|
return value == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type operator that provides the `T` type wrapped in `Opt<T>`.
|
||||||
|
*/
|
||||||
|
export type UnwrapOpt<T> = T extends Opt<infer U> ? U : never
|
59
app/gui2/src/util/reactivity.ts
Normal file
59
app/gui2/src/util/reactivity.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {} from '@vue/reactivity'
|
||||||
|
|
||||||
|
import { computed, isRef, shallowRef, type Ref, type WatchSource, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
/** Cast watch source to an observable ref. */
|
||||||
|
export function watchSourceToRef<T>(src: WatchSource<T>): Ref<T> {
|
||||||
|
return isRef(src) ? src : computed(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the value behind a watch source at the current time. */
|
||||||
|
export function evalWatchSource<T>(src: WatchSource<T>): T {
|
||||||
|
return isRef(src) ? src.value : src()
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
|
||||||
|
type RunFn = <T>(p: T) => Generator<RunToken, UnwrapPromise<T>>
|
||||||
|
|
||||||
|
declare const runBrand: unique symbol
|
||||||
|
type RunToken = { [runBrand]: never }
|
||||||
|
|
||||||
|
export function cancellableAsyncComputed<T>(
|
||||||
|
generator: (run: RunFn) => Generator<RunToken, T, void>,
|
||||||
|
initial = undefined,
|
||||||
|
): Ref<T | typeof initial> {
|
||||||
|
const value = shallowRef<T>()
|
||||||
|
const gen = generator(run)
|
||||||
|
|
||||||
|
function run<T>(p: T): Generator<never, UnwrapPromise<T>, void> {
|
||||||
|
throw 'TODO'
|
||||||
|
// watchEffect((onCleanup) => {
|
||||||
|
// const nextRunnable = gen.next(p)
|
||||||
|
// if (next.done) {
|
||||||
|
// value.value = next.value
|
||||||
|
// }
|
||||||
|
// onCleanup(() => {
|
||||||
|
// if (!next.done) {
|
||||||
|
// gen.return()
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
|
||||||
|
// declare const brand: unique symbol
|
||||||
|
// type Runnable<T> = T & { [brand]: never }
|
||||||
|
|
||||||
|
// function run<T>(p: Promise<T>): Generator<never, Runnable<T>> {}
|
||||||
|
|
||||||
|
// watchEffect(async (onCleanup) => {})
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = cancellableAsyncComputed(function* (run) {
|
||||||
|
const x = yield* run(5)
|
||||||
|
const y = yield* run(Promise.resolve('bla'))
|
||||||
|
const z = yield* someGen()
|
||||||
|
})
|
||||||
|
|
||||||
|
function* someGen() {}
|
54
app/gui2/src/util/vec2.ts
Normal file
54
app/gui2/src/util/vec2.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 2D vector, in no particular reference frame. The exact geometric interpretation of the vector
|
||||||
|
* depends on the context where it is used.
|
||||||
|
*/
|
||||||
|
export class Vec2 {
|
||||||
|
readonly x: number
|
||||||
|
readonly y: number
|
||||||
|
constructor(x: number, y: number) {
|
||||||
|
this.x = x
|
||||||
|
this.y = y
|
||||||
|
}
|
||||||
|
static Zero(): Vec2 {
|
||||||
|
return new Vec2(0, 0)
|
||||||
|
}
|
||||||
|
static FromArr(arr: [number, number]): Vec2 {
|
||||||
|
return new Vec2(arr[0], arr[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: Vec2): boolean {
|
||||||
|
return this.x === other.x && this.y === other.y
|
||||||
|
}
|
||||||
|
isZero(): boolean {
|
||||||
|
return this.x === 0 && this.y === 0
|
||||||
|
}
|
||||||
|
scale(scalar: number): Vec2 {
|
||||||
|
return new Vec2(this.x * scalar, this.y * scalar)
|
||||||
|
}
|
||||||
|
distanceSquare(a: Vec2, b: Vec2): number {
|
||||||
|
const dx = a.x - b.x
|
||||||
|
const dy = a.y - b.y
|
||||||
|
return dx * dx + dy * dy
|
||||||
|
}
|
||||||
|
add(other: Vec2): Vec2 {
|
||||||
|
return new Vec2(this.x + other.x, this.y + other.y)
|
||||||
|
}
|
||||||
|
addScaled(other: Vec2, scale: number): Vec2 {
|
||||||
|
return new Vec2(other.x * scale + this.x, other.y * scale + this.y)
|
||||||
|
}
|
||||||
|
sub(other: Vec2): Vec2 {
|
||||||
|
return new Vec2(this.x - other.x, this.y - other.y)
|
||||||
|
}
|
||||||
|
lengthSquared(): number {
|
||||||
|
return this.x * this.x + this.y * this.y
|
||||||
|
}
|
||||||
|
length(): number {
|
||||||
|
return Math.sqrt(this.lengthSquared())
|
||||||
|
}
|
||||||
|
min(other: Vec2): Vec2 {
|
||||||
|
return new Vec2(Math.min(this.x, other.x), Math.min(this.y, other.y))
|
||||||
|
}
|
||||||
|
max(other: Vec2): Vec2 {
|
||||||
|
return new Vec2(Math.max(this.x, other.x), Math.max(this.y, other.y))
|
||||||
|
}
|
||||||
|
}
|
15
app/gui2/src/views/AboutView.vue
Normal file
15
app/gui2/src/views/AboutView.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div class="about">
|
||||||
|
<h1>This is an about page</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.about {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
9
app/gui2/src/views/HomeView.vue
Normal file
9
app/gui2/src/views/HomeView.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TheWelcome from '../components/TheWelcome.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<TheWelcome />
|
||||||
|
</main>
|
||||||
|
</template>
|
9
app/gui2/src/views/ProjectView.vue
Normal file
9
app/gui2/src/views/ProjectView.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GraphEditor from '../components/GraphEditor.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GraphEditor></GraphEditor>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
12
app/gui2/tsconfig.app.json
Normal file
12
app/gui2/tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "shared/**/*"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
app/gui2/tsconfig.json
Normal file
14
app/gui2/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
10
app/gui2/tsconfig.node.json
Normal file
10
app/gui2/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node18/tsconfig.json",
|
||||||
|
"include": ["vite.config.*", "vitest.config.*", "playwright.config.*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
9
app/gui2/tsconfig.vitest.json
Normal file
9
app/gui2/tsconfig.vitest.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
45
app/gui2/vite.config.ts
Normal file
45
app/gui2/vite.config.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { fileURLToPath, parse } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig, Plugin } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import postcssNesting from 'postcss-nesting'
|
||||||
|
import { WebSocketServer } from 'ws'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), yWebsocketServer()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
postcss: {
|
||||||
|
plugins: [postcssNesting],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomNameRegex = /^[a-z0-9-]+$/i
|
||||||
|
|
||||||
|
function yWebsocketServer(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'y-websocket-server',
|
||||||
|
configureServer(server) {
|
||||||
|
if (server.httpServer == null) return
|
||||||
|
const { setupWSConnection } = require('./node_modules/y-websocket/bin/utils')
|
||||||
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
|
wss.on('connection', setupWSConnection)
|
||||||
|
server.httpServer.on('upgrade', (request, socket, head) => {
|
||||||
|
if (request.url != null && request.url.startsWith('/room/')) {
|
||||||
|
const docName = request.url.slice(6)
|
||||||
|
if (roomNameRegex.test(docName)) {
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, request, { docName })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
14
app/gui2/vitest.config.ts
Normal file
14
app/gui2/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/*'],
|
||||||
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user