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:
Paweł Grabarz 2023-09-05 00:27:33 +02:00 committed by GitHub
parent 8a60bc6dcd
commit 82f634b7f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 9199 additions and 0 deletions

1
.github/CODEOWNERS vendored
View File

@ -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
View 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
View 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/

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

View File

@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": ["./**/*"]
}

8
app/gui2/e2e/vue.spec.ts Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

51
app/gui2/package.json Normal file
View 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"
}
}

View 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

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}

BIN
app/gui2/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

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

View File

@ -0,0 +1,12 @@
@import './base.css';
body {
display: flex;
}
#app {
display: flex;
flex-direction: column;
flex: 1;
font-weight: normal;
}

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

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

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

View 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">@ &nbsp</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>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import GraphEditor from '../components/GraphEditor.vue'
</script>
<template>
<GraphEditor></GraphEditor>
</template>
<style scoped></style>

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

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

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

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