mirror of
https://github.com/enso-org/enso.git
synced 2025-01-05 14:11:36 +03:00
Merge branch 'develop' into wip/akirathan/11274-jvm-cmdlineopt-ni
This commit is contained in:
commit
b89db13046
@ -1 +1 @@
|
||||
20.11.1
|
||||
22.11.0
|
||||
|
@ -22,6 +22,8 @@
|
||||
- [Table Input Widget has now a limit of 256 cells.][11448]
|
||||
- [Added an error message screen displayed when viewing a deleted
|
||||
component.][11452]
|
||||
- [New documentation editor provides improved Markdown editing experience, and
|
||||
paves the way for new documentation features.][11469]
|
||||
|
||||
[11151]: https://github.com/enso-org/enso/pull/11151
|
||||
[11271]: https://github.com/enso-org/enso/pull/11271
|
||||
@ -37,6 +39,7 @@
|
||||
[11447]: https://github.com/enso-org/enso/pull/11447
|
||||
[11448]: https://github.com/enso-org/enso/pull/11448
|
||||
[11452]: https://github.com/enso-org/enso/pull/11452
|
||||
[11469]: https://github.com/enso-org/enso/pull/11469
|
||||
|
||||
#### Enso Standard Library
|
||||
|
||||
@ -46,11 +49,14 @@
|
||||
programmatically.][11255]
|
||||
- [DB_Table may be saved as a Data Link.][11371]
|
||||
- [Support for dates before 1900 in Excel and signed AWS requests.][11373]
|
||||
- [Added `Data.read_many` that allows to read a list of files in a single
|
||||
operation.][11490]
|
||||
|
||||
[11235]: https://github.com/enso-org/enso/pull/11235
|
||||
[11255]: https://github.com/enso-org/enso/pull/11255
|
||||
[11371]: https://github.com/enso-org/enso/pull/11371
|
||||
[11373]: https://github.com/enso-org/enso/pull/11373
|
||||
[11490]: https://github.com/enso-org/enso/pull/11490
|
||||
|
||||
#### Enso Language & Runtime
|
||||
|
||||
|
@ -18,6 +18,8 @@
|
||||
"./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts",
|
||||
"./src/utilities/data/newtype": "./src/utilities/data/newtype.ts",
|
||||
"./src/utilities/data/object": "./src/utilities/data/object.ts",
|
||||
"./src/utilities/data/string": "./src/utilities/data/string.ts",
|
||||
"./src/utilities/data/iter": "./src/utilities/data/iter.ts",
|
||||
"./src/utilities/style/tabBar": "./src/utilities/style/tabBar.ts",
|
||||
"./src/utilities/uniqueString": "./src/utilities/uniqueString.ts",
|
||||
"./src/text": "./src/text/index.ts",
|
||||
@ -37,6 +39,7 @@
|
||||
"@tanstack/query-persist-client-core": "^5.54.0",
|
||||
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lib0": "^0.2.85",
|
||||
"react": "^18.3.1",
|
||||
"vitest": "^1.3.1",
|
||||
"vue": "^3.5.2"
|
||||
|
@ -1,6 +1,6 @@
|
||||
/** @file Functions related to displaying text. */
|
||||
|
||||
import ENGLISH from './english.json' assert { type: 'json' }
|
||||
import ENGLISH from './english.json' with { type: 'json' }
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
|
146
app/common/src/utilities/data/__tests__/iterator.test.ts
Normal file
146
app/common/src/utilities/data/__tests__/iterator.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import * as iter from '../iter'
|
||||
|
||||
interface IteratorCase<T> {
|
||||
iterable: Iterable<T>
|
||||
soleValue: T | undefined
|
||||
first: T | undefined
|
||||
last: T | undefined
|
||||
count: number
|
||||
}
|
||||
|
||||
function makeCases(): IteratorCase<unknown>[] {
|
||||
return [
|
||||
{
|
||||
iterable: iter.empty(),
|
||||
soleValue: undefined,
|
||||
first: undefined,
|
||||
last: undefined,
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
iterable: iter.chain(iter.empty(), iter.empty()),
|
||||
soleValue: undefined,
|
||||
first: undefined,
|
||||
last: undefined,
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
iterable: iter.chain(iter.empty(), ['a'], iter.empty()),
|
||||
soleValue: 'a',
|
||||
first: 'a',
|
||||
last: 'a',
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
iterable: iter.range(10, 11),
|
||||
soleValue: 10,
|
||||
first: 10,
|
||||
last: 10,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
iterable: iter.range(10, 20),
|
||||
soleValue: undefined,
|
||||
first: 10,
|
||||
last: 19,
|
||||
count: 10,
|
||||
},
|
||||
{
|
||||
iterable: iter.range(20, 10),
|
||||
soleValue: undefined,
|
||||
first: 20,
|
||||
last: 11,
|
||||
count: 10,
|
||||
},
|
||||
{
|
||||
iterable: [],
|
||||
soleValue: undefined,
|
||||
first: undefined,
|
||||
last: undefined,
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
iterable: ['a'],
|
||||
soleValue: 'a',
|
||||
first: 'a',
|
||||
last: 'a',
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
iterable: ['a', 'b'],
|
||||
soleValue: undefined,
|
||||
first: 'a',
|
||||
last: 'b',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.filterDefined([undefined, 'a', undefined, 'b', undefined]),
|
||||
soleValue: undefined,
|
||||
first: 'a',
|
||||
last: 'b',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.filter([7, 'a', 8, 'b', 9], el => typeof el === 'string'),
|
||||
soleValue: undefined,
|
||||
first: 'a',
|
||||
last: 'b',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.zip(['a', 'b'], iter.range(1, 2)),
|
||||
soleValue: ['a', 1],
|
||||
first: ['a', 1],
|
||||
last: ['a', 1],
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
iterable: iter.zip(['a', 'b'], iter.range(1, 3)),
|
||||
soleValue: undefined,
|
||||
first: ['a', 1],
|
||||
last: ['b', 2],
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.zip(['a', 'b'], iter.range(1, 4)),
|
||||
soleValue: undefined,
|
||||
first: ['a', 1],
|
||||
last: ['b', 2],
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.zipLongest(['a', 'b'], iter.range(1, 2)),
|
||||
soleValue: undefined,
|
||||
first: ['a', 1],
|
||||
last: ['b', undefined],
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.zipLongest(['a', 'b'], iter.range(1, 3)),
|
||||
soleValue: undefined,
|
||||
first: ['a', 1],
|
||||
last: ['b', 2],
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
iterable: iter.zipLongest(['a', 'b'], iter.range(1, 4)),
|
||||
soleValue: undefined,
|
||||
first: ['a', 1],
|
||||
last: [undefined, 3],
|
||||
count: 3,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
test.each(makeCases())('tryGetSoleValue: case %#', ({ iterable, soleValue }) => {
|
||||
expect(iter.tryGetSoleValue(iterable)).toEqual(soleValue)
|
||||
})
|
||||
|
||||
test.each(makeCases())('last: case %#', ({ iterable, last }) => {
|
||||
expect(iter.last(iterable)).toEqual(last)
|
||||
})
|
||||
|
||||
test.each(makeCases())('count: case %#', ({ iterable, count }) => {
|
||||
expect(iter.count(iterable)).toEqual(count)
|
||||
})
|
@ -1,4 +1,30 @@
|
||||
/** @file Functions for manipulating {@link Iterable}s. */
|
||||
/** @file Utilities for manipulating {@link Iterator}s and {@link Iterable}s. */
|
||||
|
||||
import { iteratorFilter, mapIterator } from 'lib0/iterator'
|
||||
|
||||
/** Similar to {@link Array.prototype.reduce|}, but consumes elements from any iterable. */
|
||||
export function reduce<T, A>(
|
||||
iterable: Iterable<T>,
|
||||
f: (accumulator: A, element: T) => A,
|
||||
initialAccumulator: A,
|
||||
): A {
|
||||
const iterator = iterable[Symbol.iterator]()
|
||||
let accumulator = initialAccumulator
|
||||
let result = iterator.next()
|
||||
while (!result.done) {
|
||||
accumulator = f(accumulator, result.value)
|
||||
result = iterator.next()
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates the provided iterable, returning the number of elements it yielded. Note that if the input is an iterator,
|
||||
* it will be consumed.
|
||||
*/
|
||||
export function count(it: Iterable<unknown>): number {
|
||||
return reduce(it, a => a + 1, 0)
|
||||
}
|
||||
|
||||
/** An iterable with zero elements. */
|
||||
export function* empty(): Generator<never> {}
|
||||
@ -26,22 +52,17 @@ export function* range(start: number, stop: number, step = start <= stop ? 1 : -
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an {@link Iterable} that `yield`s values that are the result of calling the given
|
||||
* function on the next value of the given source iterable.
|
||||
*/
|
||||
export function* map<T, U>(iter: Iterable<T>, map: (value: T) => U): IterableIterator<U> {
|
||||
for (const value of iter) {
|
||||
yield map(value)
|
||||
}
|
||||
/** @returns An iterator that yields the results of applying the given function to each value of the given iterable. */
|
||||
export function map<T, U>(it: Iterable<T>, f: (value: T) => U): IterableIterator<U> {
|
||||
return mapIterator(it[Symbol.iterator](), f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an {@link Iterable} that `yield`s only the values from the given source iterable
|
||||
* that pass the given predicate.
|
||||
*/
|
||||
export function* filter<T>(iter: Iterable<T>, include: (value: T) => boolean): IterableIterator<T> {
|
||||
for (const value of iter) if (include(value)) yield value
|
||||
export function filter<T>(iter: Iterable<T>, include: (value: T) => boolean): IterableIterator<T> {
|
||||
return iteratorFilter(iter[Symbol.iterator](), include)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -141,3 +162,45 @@ export class Resumable<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns an iterator that yields the values of the provided iterator that are not strictly-equal to `undefined`. */
|
||||
export function* filterDefined<T>(iterable: Iterable<T | undefined>): IterableIterator<T> {
|
||||
for (const value of iterable) {
|
||||
if (value !== undefined) yield value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the predicate returned `true` for all values yielded by the provided iterator. Short-circuiting.
|
||||
* Returns `true` if the iterator doesn't yield any values.
|
||||
*/
|
||||
export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
|
||||
for (const value of iter) if (!f(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Return the first element returned by the iterable which meets the condition. */
|
||||
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
|
||||
for (const value of iter) {
|
||||
if (f(value)) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Returns the first element yielded by the iterable. */
|
||||
export function first<T>(iterable: Iterable<T>): T | undefined {
|
||||
const iterator = iterable[Symbol.iterator]()
|
||||
const result = iterator.next()
|
||||
return result.done ? undefined : result.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Return last element returned by the iterable.
|
||||
* NOTE: Linear complexity. This function always visits the whole iterable. Using this with an
|
||||
* infinite generator will cause an infinite loop.
|
||||
*/
|
||||
export function last<T>(iter: Iterable<T>): T | undefined {
|
||||
let last
|
||||
for (const el of iter) last = el
|
||||
return last
|
||||
}
|
@ -162,3 +162,24 @@ export type ExtractKeys<T, U> = {
|
||||
|
||||
/** An instance method of the given type. */
|
||||
export type MethodOf<T> = (this: T, ...args: never) => unknown
|
||||
|
||||
// ===================
|
||||
// === useObjectId ===
|
||||
// ===================
|
||||
|
||||
/** Composable providing support for managing object identities. */
|
||||
export function useObjectId() {
|
||||
let lastId = 0
|
||||
const idNumbers = new WeakMap<object, number>()
|
||||
/** @returns A value that can be used to compare object identity. */
|
||||
function objectId(o: object): number {
|
||||
const id = idNumbers.get(o)
|
||||
if (id == null) {
|
||||
lastId += 1
|
||||
idNumbers.set(o, lastId)
|
||||
return lastId
|
||||
}
|
||||
return id
|
||||
}
|
||||
return { objectId }
|
||||
}
|
||||
|
2
app/common/src/utilities/data/string.ts
Normal file
2
app/common/src/utilities/data/string.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/** See http://www.unicode.org/reports/tr18/#Line_Boundaries */
|
||||
export const LINE_BOUNDARIES = /\r\n|[\n\v\f\r\x85\u2028\u2029]/g
|
@ -84,7 +84,7 @@ export const addNewNodeButton = componentLocator('.PlusButton')
|
||||
export const componentBrowser = componentLocator('.ComponentBrowser')
|
||||
export const nodeOutputPort = componentLocator('.outputPortHoverArea')
|
||||
export const smallPlusButton = componentLocator('.SmallPlusButton')
|
||||
export const lexicalContent = componentLocator('.LexicalContent')
|
||||
export const editorRoot = componentLocator('.EditorRoot')
|
||||
|
||||
/**
|
||||
* A not-selected variant of Component Browser Entry.
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { expect, test } from 'playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { mockMethodCallInfo } from './expressionUpdates'
|
||||
import { mockCollapsedFunctionInfo, mockMethodCallInfo } from './expressionUpdates'
|
||||
import { CONTROL_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
|
||||
@ -13,7 +13,7 @@ test('Main method documentation', async ({ page }) => {
|
||||
await expect(locate.rightDock(page)).toBeVisible()
|
||||
|
||||
// Right-dock displays main method documentation.
|
||||
await expect(locate.lexicalContent(locate.rightDock(page))).toHaveText('The main method')
|
||||
await expect(locate.editorRoot(locate.rightDock(page))).toHaveText('The main method')
|
||||
|
||||
// Documentation hotkey closes right-dock.p
|
||||
await page.keyboard.press(`${CONTROL_KEY}+D`)
|
||||
@ -70,3 +70,20 @@ test('Component help', async ({ page }) => {
|
||||
await locate.graphNodeByBinding(page, 'data').click()
|
||||
await expect(locate.rightDock(page)).toHaveText(/Reads a file into Enso/)
|
||||
})
|
||||
|
||||
test('Documentation reflects entered function', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
|
||||
// Open the panel
|
||||
await expect(locate.rightDock(page)).toBeHidden()
|
||||
await page.keyboard.press(`${CONTROL_KEY}+D`)
|
||||
await expect(locate.rightDock(page)).toBeVisible()
|
||||
|
||||
// Enter the collapsed function
|
||||
await mockCollapsedFunctionInfo(page, 'final', 'func1')
|
||||
await locate.graphNodeByBinding(page, 'final').dblclick()
|
||||
await expect(locate.navBreadcrumb(page)).toHaveText(['Mock Project', 'func1'])
|
||||
|
||||
// Editor should contain collapsed function's docs
|
||||
await expect(locate.editorRoot(locate.rightDock(page))).toHaveText('A collapsed function')
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Server } from '@open-rpc/server-js'
|
||||
import * as random from 'lib0/random'
|
||||
import pmSpec from './pm-openrpc.json' assert { type: 'json' }
|
||||
import pmSpec from './pm-openrpc.json' with { type: 'json' }
|
||||
import {
|
||||
methods as pmMethods,
|
||||
projects,
|
||||
|
@ -83,22 +83,18 @@
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-9ee70a1-20241017",
|
||||
"@codemirror/commands": "^6.6.0",
|
||||
"@codemirror/language": "^6.10.2",
|
||||
"@codemirror/lang-markdown": "^v6.3.0",
|
||||
"@codemirror/lint": "^6.8.1",
|
||||
"@codemirror/search": "^6.5.6",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.28.3",
|
||||
"@fast-check/vitest": "^0.0.8",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@lexical/code": "^0.16.0",
|
||||
"@lexical/link": "^0.16.0",
|
||||
"@lexical/list": "^0.16.0",
|
||||
"@lexical/markdown": "^0.16.0",
|
||||
"@lexical/plain-text": "^0.16.0",
|
||||
"@lexical/rich-text": "^0.16.0",
|
||||
"@lexical/selection": "^0.16.0",
|
||||
"@lexical/table": "^0.16.0",
|
||||
"@lexical/utils": "^0.16.0",
|
||||
"@lezer/common": "^1.1.0",
|
||||
"@lezer/markdown": "^1.3.1",
|
||||
"@lezer/highlight": "^1.1.6",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
@ -135,14 +131,13 @@
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@react-types/shared": "^3.22.1",
|
||||
"@tanstack/react-query-devtools": "5.45.1",
|
||||
"@types/node": "^20.11.21",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/validator": "^13.11.7",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"chalk": "^5.3.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"enso-chat": "git://github.com/enso-org/enso-bot",
|
||||
"fast-check": "^3.15.0",
|
||||
"playwright": "^1.39.0",
|
||||
"postcss": "^8.4.29",
|
||||
@ -189,7 +184,7 @@
|
||||
"sql-formatter": "^13.0.0",
|
||||
"tar": "^6.2.1",
|
||||
"tsx": "^4.7.1",
|
||||
"vite-plugin-vue-devtools": "7.3.7",
|
||||
"vite-plugin-vue-devtools": "7.6.3",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vue-react-wrapper": "^0.3.1",
|
||||
"vue-tsc": "^2.0.24",
|
||||
|
@ -11,7 +11,7 @@ import * as tar from 'tar'
|
||||
import * as yaml from 'yaml'
|
||||
|
||||
import * as common from 'enso-common'
|
||||
import GLOBAL_CONFIG from 'enso-common/src/config.json' assert { type: 'json' }
|
||||
import GLOBAL_CONFIG from 'enso-common/src/config.json' with { type: 'json' }
|
||||
|
||||
import * as projectManagement from './projectManagement'
|
||||
|
||||
|
@ -3,7 +3,7 @@ import * as React from 'react'
|
||||
|
||||
import * as reactDom from 'react-dom'
|
||||
|
||||
import * as chat from 'enso-chat/chat'
|
||||
import * as chat from '#/services/Chat'
|
||||
|
||||
import CloseLargeIcon from '#/assets/close_large.svg'
|
||||
import DefaultUserIcon from '#/assets/default_user.svg'
|
||||
@ -480,6 +480,7 @@ export default function Chat(props: ChatProps) {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight
|
||||
}
|
||||
// Auto-scroll MUST only happen when the message list changes.
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages])
|
||||
|
||||
|
260
app/gui/src/dashboard/services/Chat.ts
Normal file
260
app/gui/src/dashboard/services/Chat.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @file An API definition for the chat WebSocket server.
|
||||
* Types copied from the enso-bot server implementation:
|
||||
* https://github.com/enso-org/enso-bot/blob/aa903b6e639a31930ee4fff55c5639e4471fa48d/chat.ts
|
||||
*/
|
||||
|
||||
import type * as newtype from '#/utilities/newtype'
|
||||
|
||||
// =====================
|
||||
// === Message Types ===
|
||||
// =====================
|
||||
|
||||
/** Identifier for a chat Thread. */
|
||||
export type ThreadId = newtype.Newtype<string, 'ThreadId'>
|
||||
/** Identifier for a chat message. */
|
||||
export type MessageId = newtype.Newtype<string, 'MessageId'>
|
||||
/** Identifier for a chat user. */
|
||||
export type UserId = newtype.Newtype<string, 'UserId'>
|
||||
/** Chat user's email addresss. */
|
||||
export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
|
||||
|
||||
/** Enumeration of all message types exchanged with the chat server. */
|
||||
export enum ChatMessageDataType {
|
||||
// Messages internal to the server.
|
||||
/** Like the `authenticate` message, but with user details. */
|
||||
internalAuthenticate = 'internal-authenticate',
|
||||
/** Like the `authenticateAnonymously` message, but with user details. */
|
||||
internalAuthenticateAnonymously = 'internal-authenticate-anonymously',
|
||||
// Messages from the server to the client.
|
||||
/** Metadata for all threads associated with a user. */
|
||||
serverThreads = 'server-threads',
|
||||
/** Metadata for the currently open thread. */
|
||||
serverThread = 'server-thread',
|
||||
/** A message from the server to the client. */
|
||||
serverMessage = 'server-message',
|
||||
/** An edited message from the server to the client. */
|
||||
serverEditedMessage = 'server-edited-message',
|
||||
/**
|
||||
* A message from the client to the server, sent from the server to the client as part of
|
||||
* the message history.
|
||||
*/
|
||||
serverReplayedMessage = 'server-replayed-message',
|
||||
// Messages from the client to the server.
|
||||
/** The authentication token. */
|
||||
authenticate = 'authenticate',
|
||||
/** Sent by a user that is not logged in. This is currently only used on the website. */
|
||||
authenticateAnonymously = 'authenticate-anonymously',
|
||||
/** Sent when the user is requesting scrollback history. */
|
||||
historyBefore = 'history-before',
|
||||
/** Create a new thread with an initial message. */
|
||||
newThread = 'new-thread',
|
||||
/** Rename an existing thread. */
|
||||
renameThread = 'rename-thread',
|
||||
/** Change the currently active thread. */
|
||||
switchThread = 'switch-thread',
|
||||
/** A message from the client to the server. */
|
||||
message = 'message',
|
||||
/** A reaction from the client. */
|
||||
reaction = 'reaction',
|
||||
/** Removal of a reaction from the client. */
|
||||
removeReaction = 'remove-reaction',
|
||||
/**
|
||||
* Mark a message as read. Used to determine whether to show the notification dot
|
||||
* next to a thread.
|
||||
*/
|
||||
markAsRead = 'mark-as-read',
|
||||
}
|
||||
|
||||
/** Properties common to all WebSocket messages. */
|
||||
interface ChatBaseMessageData<Type extends ChatMessageDataType> {
|
||||
readonly type: Type
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === Internal messages ===
|
||||
// =========================
|
||||
|
||||
/** Sent to the main file with user information. */
|
||||
export interface ChatInternalAuthenticateMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.internalAuthenticate> {
|
||||
readonly userId: UserId
|
||||
readonly userName: string
|
||||
}
|
||||
|
||||
/** Sent to the main file with user IP. */
|
||||
export interface ChatInternalAuthenticateAnonymouslyMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.internalAuthenticateAnonymously> {
|
||||
readonly userId: UserId
|
||||
readonly email: EmailAddress
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// === Messages from server to client ===
|
||||
// ======================================
|
||||
|
||||
/** All possible emojis that can be used as a reaction on a chat message. */
|
||||
export type ReactionSymbol = '❤️' | '🎉' | '👀' | '👍' | '👎' | '😀' | '🙁'
|
||||
|
||||
/** Basic metadata for a single thread. */
|
||||
export interface ThreadData {
|
||||
readonly title: string
|
||||
readonly id: ThreadId
|
||||
readonly hasUnreadMessages: boolean
|
||||
}
|
||||
|
||||
/** Basic metadata for a all of a user's threads. */
|
||||
export interface ChatServerThreadsMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.serverThreads> {
|
||||
readonly threads: ThreadData[]
|
||||
}
|
||||
|
||||
/** All possible message types that may trigger a {@link ChatServerThreadMessageData} response. */
|
||||
export type ChatServerThreadRequestType =
|
||||
| ChatMessageDataType.authenticate
|
||||
| ChatMessageDataType.historyBefore
|
||||
| ChatMessageDataType.newThread
|
||||
| ChatMessageDataType.switchThread
|
||||
|
||||
/**
|
||||
* Thread details and recent messages.
|
||||
* This message is sent every time the user switches threads.
|
||||
*/
|
||||
export interface ChatServerThreadMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.serverThread> {
|
||||
/** The type of the message that triggered this response. */
|
||||
readonly requestType: ChatServerThreadRequestType
|
||||
readonly title: string
|
||||
readonly id: ThreadId
|
||||
/** `true` if there is no more message history before these messages. */
|
||||
readonly isAtBeginning: boolean
|
||||
readonly messages: (ChatServerMessageMessageData | ChatServerReplayedMessageMessageData)[]
|
||||
}
|
||||
|
||||
/** A regular chat message from the server to the client. */
|
||||
export interface ChatServerMessageMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.serverMessage> {
|
||||
readonly id: MessageId
|
||||
// This should not be `null` for staff, as registration is required.
|
||||
// However, it will be `null` for users that have not yet set an avatar.
|
||||
readonly authorAvatar: string | null
|
||||
readonly authorName: string
|
||||
readonly content: string
|
||||
readonly reactions: ReactionSymbol[]
|
||||
/** Milliseconds since the Unix epoch. */
|
||||
readonly timestamp: number
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
* Should only be present when receiving message history, because new messages cannot have been
|
||||
* edited.
|
||||
*/
|
||||
readonly editedTimestamp: number | null
|
||||
}
|
||||
|
||||
/** A regular edited chat message from the server to the client. */
|
||||
export interface ChatServerEditedMessageMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.serverEditedMessage> {
|
||||
readonly id: MessageId
|
||||
readonly content: string
|
||||
/** Milliseconds since the Unix epoch. */
|
||||
readonly timestamp: number
|
||||
}
|
||||
|
||||
/** A replayed message from the client to the server. Includes the timestamp of the message. */
|
||||
export interface ChatServerReplayedMessageMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.serverReplayedMessage> {
|
||||
readonly id: MessageId
|
||||
readonly content: string
|
||||
/** Milliseconds since the Unix epoch. */
|
||||
readonly timestamp: number
|
||||
}
|
||||
|
||||
/** A message from the server to the client. */
|
||||
export type ChatServerMessageData =
|
||||
| ChatServerEditedMessageMessageData
|
||||
| ChatServerMessageMessageData
|
||||
| ChatServerReplayedMessageMessageData
|
||||
| ChatServerThreadMessageData
|
||||
| ChatServerThreadsMessageData
|
||||
|
||||
// ======================================
|
||||
// === Messages from client to server ===
|
||||
// ======================================
|
||||
|
||||
/** Sent whenever the user opens the chat sidebar. */
|
||||
export interface ChatAuthenticateMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.authenticate> {
|
||||
readonly accessToken: string
|
||||
}
|
||||
|
||||
/** Sent whenever the user opens the chat sidebar. */
|
||||
export interface ChatAuthenticateAnonymouslyMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.authenticateAnonymously> {
|
||||
readonly email: EmailAddress
|
||||
}
|
||||
|
||||
/** Sent when the user is requesting scrollback history. */
|
||||
export interface ChatHistoryBeforeMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.historyBefore> {
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
|
||||
/** Sent when the user sends a message in a new thread. */
|
||||
export interface ChatNewThreadMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.newThread> {
|
||||
readonly title: string
|
||||
/** Content of the first message, to reduce the number of round trips. */
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
/** Sent when the user finishes editing the thread name in the chat title bar. */
|
||||
export interface ChatRenameThreadMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.renameThread> {
|
||||
readonly title: string
|
||||
readonly threadId: ThreadId
|
||||
}
|
||||
|
||||
/** Sent when the user picks a thread from the dropdown. */
|
||||
export interface ChatSwitchThreadMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.switchThread> {
|
||||
readonly threadId: ThreadId
|
||||
}
|
||||
|
||||
/** A regular message from the client to the server. */
|
||||
export interface ChatMessageMessageData extends ChatBaseMessageData<ChatMessageDataType.message> {
|
||||
readonly threadId: ThreadId
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
/** A reaction to a message sent by staff. */
|
||||
export interface ChatReactionMessageData extends ChatBaseMessageData<ChatMessageDataType.reaction> {
|
||||
readonly messageId: MessageId
|
||||
readonly reaction: ReactionSymbol
|
||||
}
|
||||
|
||||
/** Removal of a reaction from the client. */
|
||||
export interface ChatRemoveReactionMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.removeReaction> {
|
||||
readonly messageId: MessageId
|
||||
readonly reaction: ReactionSymbol
|
||||
}
|
||||
|
||||
/** Sent when the user scrolls to the bottom of a chat thread. */
|
||||
export interface ChatMarkAsReadMessageData
|
||||
extends ChatBaseMessageData<ChatMessageDataType.markAsRead> {
|
||||
readonly threadId: ThreadId
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
|
||||
/** A message from the client to the server. */
|
||||
export type ChatClientMessageData =
|
||||
| ChatAuthenticateAnonymouslyMessageData
|
||||
| ChatAuthenticateMessageData
|
||||
| ChatHistoryBeforeMessageData
|
||||
| ChatMarkAsReadMessageData
|
||||
| ChatMessageMessageData
|
||||
| ChatNewThreadMessageData
|
||||
| ChatReactionMessageData
|
||||
| ChatRemoveReactionMessageData
|
||||
| ChatRenameThreadMessageData
|
||||
| ChatSwitchThreadMessageData
|
@ -85,7 +85,7 @@
|
||||
/* Resize handle override for the visualization container. */
|
||||
--visualization-resize-handle-inside: 3px;
|
||||
--visualization-resize-handle-outside: 3px;
|
||||
--right-dock-default-width: 40%;
|
||||
--right-dock-default-width: 40vw;
|
||||
--code-editor-default-height: 30%;
|
||||
--scrollbar-scrollable-opacity: 100%;
|
||||
}
|
||||
|
@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChangeSet, Diagnostic, Highlighter } from '@/components/CodeEditor/codemirror'
|
||||
import EditorRoot from '@/components/EditorRoot.vue'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { useAutoBlur } from '@/util/autoBlur'
|
||||
import { chain } from '@/util/data/iterable'
|
||||
import { unwrap } from '@/util/data/result'
|
||||
import { qnJoin, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { createDebouncer } from 'lib0/eventloop'
|
||||
import type { ComponentInstance } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
import { MutableModule } from 'ydoc-shared/ast'
|
||||
import { textChangeToEdits, type SourceRangeEdit } from 'ydoc-shared/util/data/text'
|
||||
@ -40,7 +42,8 @@ const {
|
||||
const projectStore = useProjectStore()
|
||||
const graphStore = useGraphStore()
|
||||
const suggestionDbStore = useSuggestionDbStore()
|
||||
const rootElement = ref<HTMLElement>()
|
||||
const editorRoot = ref<ComponentInstance<typeof EditorRoot>>()
|
||||
const rootElement = computed(() => editorRoot.value?.rootElement)
|
||||
useAutoBlur(rootElement)
|
||||
|
||||
const executionContextDiagnostics = shallowRef<Diagnostic[]>([])
|
||||
@ -63,7 +66,7 @@ const expressionUpdatesDiagnostics = computed(() => {
|
||||
const panics = updates.type.reverseLookup('Panic')
|
||||
const errors = updates.type.reverseLookup('DataflowError')
|
||||
const diagnostics: Diagnostic[] = []
|
||||
for (const externalId of chain(panics, errors)) {
|
||||
for (const externalId of iter.chain(panics, errors)) {
|
||||
const update = updates.get(externalId)
|
||||
if (!update) continue
|
||||
const astId = graphStore.db.idFromExternal(externalId)
|
||||
@ -322,25 +325,11 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootElement"
|
||||
class="CodeEditor"
|
||||
@keydown.arrow-left.stop
|
||||
@keydown.arrow-right.stop
|
||||
@keydown.arrow-up.stop
|
||||
@keydown.arrow-down.stop
|
||||
@keydown.enter.stop
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
@wheel.stop.passive
|
||||
@contextmenu.stop
|
||||
></div>
|
||||
<EditorRoot ref="editorRoot" class="CodeEditor" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.CodeEditor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: var(--font-mono);
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
@ -348,13 +337,13 @@ onMounted(() => {
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
:deep(.ͼ1 .cm-scroller) {
|
||||
:deep(.cm-scroller) {
|
||||
font-family: var(--font-mono);
|
||||
/* Prevent touchpad back gesture, which can be triggered while panning. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.CodeEditor :deep(.cm-editor) {
|
||||
:deep(.cm-editor) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -366,11 +355,11 @@ onMounted(() => {
|
||||
transition: outline 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.CodeEditor :deep(.cm-focused) {
|
||||
:deep(.cm-focused) {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.CodeEditor :deep(.cm-tooltip-hover) {
|
||||
:deep(.cm-tooltip-hover) {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.4);
|
||||
@ -384,7 +373,7 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.CodeEditor :deep(.cm-gutters) {
|
||||
:deep(.cm-gutters) {
|
||||
border-radius: 3px 0 0 3px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
@ -43,8 +43,8 @@ import {
|
||||
} from '@lezer/common'
|
||||
import { styleTags, tags } from '@lezer/highlight'
|
||||
import { EditorView } from 'codemirror'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import type { Diagnostic as LSDiagnostic } from 'ydoc-shared/languageServerTypes'
|
||||
import { tryGetSoleValue } from 'ydoc-shared/util/data/iterable'
|
||||
import type { SourceRangeEdit } from 'ydoc-shared/util/data/text'
|
||||
|
||||
/** TODO: Add docs */
|
||||
@ -124,7 +124,7 @@ function astToCodeMirrorTree(
|
||||
const [start, end] = ast.span()
|
||||
const children = ast.children()
|
||||
|
||||
const childrenToConvert = tryGetSoleValue(children)?.isToken() ? [] : children
|
||||
const childrenToConvert = iter.tryGetSoleValue(children)?.isToken() ? [] : children
|
||||
|
||||
const tree = new Tree(
|
||||
nodeSet.types[ast.inner.type + (ast.isToken() ? RawAst.Tree.typeNames.length : 0)]!,
|
||||
|
@ -3,9 +3,8 @@ import ColorRing from '@/components/ColorRing.vue'
|
||||
import { injectNodeColors } from '@/providers/graphNodeColors'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { filterDefined } from '@/util/data/iterable'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { ref } from 'vue'
|
||||
import { tryGetSoleValue } from 'ydoc-shared/util/data/iterable'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@ -16,9 +15,9 @@ const selection = injectGraphSelection()
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
const displayedColors = new Set<string>(
|
||||
filterDefined(Array.from(selection.selected, (node) => getNodeColor(node))),
|
||||
iter.filterDefined(iter.map(selection.selected, getNodeColor)),
|
||||
)
|
||||
const currentColor = ref<string | undefined>(tryGetSoleValue(displayedColors.values()))
|
||||
const currentColor = ref<string | undefined>(iter.tryGetSoleValue(displayedColors.values()))
|
||||
|
||||
const editedNodeInitialColors = new Map<NodeId, string | undefined>()
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ensoColor, formatCssColor, normalizeHue } from '@/util/colors'
|
||||
import { Resumable } from 'ydoc-shared/util/data/iterable'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
|
||||
export interface FixedRange {
|
||||
start: number
|
||||
@ -98,7 +98,7 @@ export function gradientPoints(
|
||||
): GradientPoint[] {
|
||||
const points = new Array<GradientPoint>()
|
||||
const interpolationPoint = (angle: number) => ({ hue: angle, angle })
|
||||
const fixedRangeIter = new Resumable(inputRanges)
|
||||
const fixedRangeIter = new iter.Resumable(inputRanges)
|
||||
const min = Math.max(3, Math.round(minStops ?? 0))
|
||||
for (let i = 0; i < min; i++) {
|
||||
const angle = i / (min - 1)
|
||||
|
@ -18,7 +18,7 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { type Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import type { VisualizationDataSource } from '@/stores/visualization'
|
||||
import { cancelOnClick, isNodeOutside, targetIsOutside } from '@/util/autoBlur'
|
||||
import { isNodeOutside, targetIsOutside } from '@/util/autoBlur'
|
||||
import { tryGetIndex } from '@/util/data/array'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
@ -77,17 +77,29 @@ const clickOutsideAssociatedElements = (e: PointerEvent) => {
|
||||
false
|
||||
: props.associatedElements.every((element) => targetIsOutside(e, element))
|
||||
}
|
||||
const cbOpen: Interaction = cancelOnClick(clickOutsideAssociatedElements, {
|
||||
cancel: () => emit('canceled'),
|
||||
const cbOpen: Interaction = {
|
||||
pointerdown: (e: PointerEvent) => {
|
||||
if (clickOutsideAssociatedElements(e)) {
|
||||
if (props.usage.type === 'editNode') {
|
||||
acceptInput()
|
||||
} else {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
cancel: () => {
|
||||
emit('canceled')
|
||||
},
|
||||
end: () => {
|
||||
// In AI prompt mode likely the input is not a valid mode.
|
||||
if (input.mode.mode !== 'aiPrompt') {
|
||||
acceptInput()
|
||||
} else {
|
||||
// In AI prompt mode, the input is likely not a valid expression.
|
||||
if (input.mode.mode === 'aiPrompt') {
|
||||
emit('canceled')
|
||||
} else {
|
||||
acceptInput()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function scaleValues<T extends Record<any, number>>(
|
||||
values: T,
|
||||
|
@ -4,7 +4,7 @@ import AutoSizedInput, { type Range } from '@/components/widgets/AutoSizedInput.
|
||||
import type { useNavigator } from '@/composables/navigator'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { computed, ref, watch, type DeepReadonly } from 'vue'
|
||||
import { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
const content = defineModel<DeepReadonly<{ text: string; selection: Range | undefined }>>({
|
||||
required: true,
|
||||
|
@ -7,11 +7,10 @@ import {
|
||||
type Environment,
|
||||
type InputNodeEnvironment,
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import * as iterable from '@/util/data/iterable'
|
||||
import { chain, map, range } from '@/util/data/iterable'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { fc, test as fcTest } from '@fast-check/vitest'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
// Vue playground to visually inspect failing fuzz cases:
|
||||
@ -42,7 +41,7 @@ describe('Non dictated placement', () => {
|
||||
return {
|
||||
screenBounds,
|
||||
nodeRects,
|
||||
selectedNodeRects: iterable.empty(),
|
||||
selectedNodeRects: iter.empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,53 +83,53 @@ describe('Non dictated placement', () => {
|
||||
// === Multiple node tests ===
|
||||
{
|
||||
desc: 'Multiple nodes',
|
||||
nodes: map(range(0, 1001, 20), rectAtX(1050)),
|
||||
nodes: iter.map(iter.range(0, 1001, 20), rectAtX(1050)),
|
||||
pos: new Vec2(1090, 1044),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap',
|
||||
nodes: map(range(1000, -1, -20), rectAtX(1050)),
|
||||
nodes: iter.map(iter.range(1000, -1, -20), rectAtX(1050)),
|
||||
pos: new Vec2(1090, 1044),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap 2',
|
||||
nodes: chain(
|
||||
map(range(500, 901, 20), rectAtX(1050)),
|
||||
map(range(1000, 1501, 20), rectAtX(1050)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(500, 901, 20), rectAtX(1050)),
|
||||
iter.map(iter.range(1000, 1501, 20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1090, 944),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap (just big enough)',
|
||||
nodes: map(range(690, 1500, 88), rectAtX(1050)),
|
||||
nodes: iter.map(iter.range(690, 1500, 88), rectAtX(1050)),
|
||||
pos: new Vec2(1090, 734),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap (slightly too small)',
|
||||
nodes: map(range(500, 849, 87), rectAtX(1050)),
|
||||
nodes: iter.map(iter.range(500, 849, 87), rectAtX(1050)),
|
||||
pos: new Vec2(1090, 892),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with smallest gap',
|
||||
nodes: chain(
|
||||
map(range(500, 901, 20), rectAtX(1050)),
|
||||
map(range(988, 1489, 20), rectAtX(1050)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(500, 901, 20), rectAtX(1050)),
|
||||
iter.map(iter.range(988, 1489, 20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1090, 944),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with smallest gap (reverse)',
|
||||
nodes: chain(
|
||||
map(range(1488, 987, -20), rectAtX(1050)),
|
||||
map(range(900, 499, -20), rectAtX(1050)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(1488, 987, -20), rectAtX(1050)),
|
||||
iter.map(iter.range(900, 499, -20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1090, 944),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap that is too small',
|
||||
nodes: chain(
|
||||
map(range(500, 901, 20), rectAtX(1050)),
|
||||
map(range(987, 1488, 20), rectAtX(1050)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(500, 901, 20), rectAtX(1050)),
|
||||
iter.map(iter.range(987, 1488, 20), rectAtX(1050)),
|
||||
),
|
||||
// This gap is 1px smaller than the previous test - so, 1px too small.
|
||||
// This position is offscreen (y >= 1000), so we pan so that the new node is centered (1531 - 690).
|
||||
@ -139,9 +138,9 @@ describe('Non dictated placement', () => {
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap that is too small (each range reversed)',
|
||||
nodes: chain(
|
||||
map(range(900, 499, -20), rectAtX(1050)),
|
||||
map(range(1487, 986, -20), rectAtX(1050)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(900, 499, -20), rectAtX(1050)),
|
||||
iter.map(iter.range(1487, 986, -20), rectAtX(1050)),
|
||||
),
|
||||
pos: new Vec2(1090, 1531),
|
||||
pan: new Vec2(0, 841),
|
||||
@ -259,64 +258,64 @@ describe('Previous node dictated placement', () => {
|
||||
// === Multiple node tests ===
|
||||
{
|
||||
desc: 'Multiple nodes',
|
||||
nodes: map(range(1000, 2001, 100), rectAtY(734)),
|
||||
nodes: iter.map(iter.range(1000, 2001, 100), rectAtY(734)),
|
||||
pos: new Vec2(2124, 734),
|
||||
pan: new Vec2(1034, 44),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes (reverse)',
|
||||
nodes: map(range(2000, 999, -100), rectAtY(734)),
|
||||
nodes: iter.map(iter.range(2000, 999, -100), rectAtY(734)),
|
||||
pos: new Vec2(2124, 734),
|
||||
pan: new Vec2(1034, 44),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap',
|
||||
nodes: chain(
|
||||
map(range(1000, 1401, 100), rectAtY(734)),
|
||||
map(range(1700, 2001, 100), rectAtY(734)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(1000, 1401, 100), rectAtY(734)),
|
||||
iter.map(iter.range(1700, 2001, 100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(1524, 734),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap (just big enough)',
|
||||
nodes: map(range(1050, 2000, 248), rectAtY(734)),
|
||||
nodes: iter.map(iter.range(1050, 2000, 248), rectAtY(734)),
|
||||
pos: new Vec2(1174, 734),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap (slightly too small)',
|
||||
nodes: map(range(1050, 1792, 247), rectAtY(734)),
|
||||
nodes: iter.map(iter.range(1050, 1792, 247), rectAtY(734)),
|
||||
pos: new Vec2(1915, 734),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with smallest gap',
|
||||
nodes: chain(
|
||||
map(range(1000, 1401, 100), rectAtY(734)),
|
||||
map(range(1648, 1949, 100), rectAtY(734)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(1000, 1401, 100), rectAtY(734)),
|
||||
iter.map(iter.range(1648, 1949, 100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(1524, 734),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with smallest gap (reverse)',
|
||||
nodes: chain(
|
||||
map(range(1948, 1647, -100), rectAtY(734)),
|
||||
map(range(1400, 999, -100), rectAtY(734)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(1948, 1647, -100), rectAtY(734)),
|
||||
iter.map(iter.range(1400, 999, -100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(1524, 734),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap that is too small',
|
||||
nodes: chain(
|
||||
map(range(1000, 1401, 100), rectAtY(734)),
|
||||
map(range(1647, 1948, 100), rectAtY(734)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(1000, 1401, 100), rectAtY(734)),
|
||||
iter.map(iter.range(1647, 1948, 100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(2071, 734),
|
||||
pan: new Vec2(981, 44),
|
||||
},
|
||||
{
|
||||
desc: 'Multiple nodes with gap that is too small (each range reversed)',
|
||||
nodes: chain(
|
||||
map(range(1400, 999, -100), rectAtY(734)),
|
||||
map(range(1947, 1646, -100), rectAtY(734)),
|
||||
nodes: iter.chain(
|
||||
iter.map(iter.range(1400, 999, -100), rectAtY(734)),
|
||||
iter.map(iter.range(1947, 1646, -100), rectAtY(734)),
|
||||
),
|
||||
pos: new Vec2(2071, 734),
|
||||
pan: new Vec2(981, 44),
|
||||
|
@ -10,7 +10,8 @@ import { isSome } from '@/util/data/opt'
|
||||
import { Range } from '@/util/data/range'
|
||||
import { displayedIconOf } from '@/util/getIconName'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { qnLastSegmentIndex, QualifiedName, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
import { qnLastSegmentIndex, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { unwrap } from 'ydoc-shared/util/data/result'
|
||||
|
||||
interface ComponentLabelInfo {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useApproach } from '@/composables/animation'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
export type ScrollTarget =
|
||||
|
@ -29,9 +29,13 @@ defineExpose({ root })
|
||||
const computedSize = useResizeObserver(slideInPanel)
|
||||
const computedBounds = computed(() => new Rect(Vec2.Zero, computedSize.value))
|
||||
|
||||
const style = computed(() => ({
|
||||
'--dock-panel-width': size.value != null ? `${size.value}px` : 'var(--right-dock-default-width)',
|
||||
}))
|
||||
const style = computed(() =>
|
||||
size.value != null ?
|
||||
{
|
||||
'--dock-panel-width': `${size.value}px`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const tabStyle = {
|
||||
clipPath: tabClipPath(TAB_SIZE_PX, TAB_RADIUS_PX, 'right'),
|
||||
@ -52,36 +56,36 @@ const tabStyle = {
|
||||
:class="{ aboveFullscreen: contentFullscreen }"
|
||||
/>
|
||||
<SizeTransition width :duration="100">
|
||||
<div
|
||||
v-if="show"
|
||||
ref="slideInPanel"
|
||||
:style="style"
|
||||
class="DockPanelContent"
|
||||
data-testid="rightDock"
|
||||
>
|
||||
<div class="content">
|
||||
<slot v-if="tab == 'docs'" name="docs" />
|
||||
<slot v-else-if="tab == 'help'" name="help" />
|
||||
</div>
|
||||
<div class="tabBar">
|
||||
<div class="tab" :style="tabStyle">
|
||||
<ToggleIcon
|
||||
:modelValue="tab == 'docs'"
|
||||
title="Documentation Editor"
|
||||
icon="text"
|
||||
@update:modelValue="tab = 'docs'"
|
||||
/>
|
||||
<div v-if="show" ref="slideInPanel" :style="style" class="panelOuter" data-testid="rightDock">
|
||||
<div class="panelInner">
|
||||
<div class="content">
|
||||
<slot v-if="tab == 'docs'" name="docs" />
|
||||
<slot v-else-if="tab == 'help'" name="help" />
|
||||
</div>
|
||||
<div class="tab" :style="tabStyle">
|
||||
<ToggleIcon
|
||||
:modelValue="tab == 'help'"
|
||||
title="Component Help"
|
||||
icon="help"
|
||||
@update:modelValue="tab = 'help'"
|
||||
/>
|
||||
<div class="tabBar">
|
||||
<div class="tab" :style="tabStyle">
|
||||
<ToggleIcon
|
||||
:modelValue="tab == 'docs'"
|
||||
title="Documentation Editor"
|
||||
icon="text"
|
||||
@update:modelValue="tab = 'docs'"
|
||||
/>
|
||||
</div>
|
||||
<div class="tab" :style="tabStyle">
|
||||
<ToggleIcon
|
||||
:modelValue="tab == 'help'"
|
||||
title="Component Help"
|
||||
icon="help"
|
||||
@update:modelValue="tab = 'help'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeHandles
|
||||
left
|
||||
:modelValue="computedBounds"
|
||||
@update:modelValue="size = $event.width"
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandles left :modelValue="computedBounds" @update:modelValue="size = $event.width" />
|
||||
</div>
|
||||
</SizeTransition>
|
||||
</div>
|
||||
@ -89,12 +93,25 @@ const tabStyle = {
|
||||
|
||||
<style scoped>
|
||||
.DockPanel {
|
||||
display: contents;
|
||||
display: block;
|
||||
--dock-panel-min-width: 258px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.DockPanelContent {
|
||||
min-width: 258px;
|
||||
width: var(--dock-panel-width);
|
||||
/* Outer panel container; this element's visible width will be overwritten by the size transition, but the inner panel's
|
||||
* will not, preventing content reflow. Content reflow is disruptive to the appearance of the transition, and can affect
|
||||
* the framerate drastically.
|
||||
*/
|
||||
.panelOuter {
|
||||
min-width: var(--dock-panel-min-width);
|
||||
width: var(--dock-panel-width, var(--right-dock-default-width));
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panelInner {
|
||||
min-width: var(--dock-panel-min-width);
|
||||
width: var(--dock-panel-width, var(--right-dock-default-width));
|
||||
height: 100%;
|
||||
position: relative;
|
||||
--icon-margin: 16px; /* `--icon-margin` in `.toggleDock` must match this value. */
|
||||
--icon-size: 16px;
|
||||
@ -105,7 +122,6 @@ const tabStyle = {
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
min-width: 0;
|
||||
}
|
||||
|
@ -9,8 +9,11 @@ import type { ToValue } from '@/util/reactivity'
|
||||
import { ref, toRef, toValue, watch } from 'vue'
|
||||
import type { Path } from 'ydoc-shared/languageServerTypes'
|
||||
import { Err, Ok, mapOk, withContext, type Result } from 'ydoc-shared/util/data/result'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const documentation = defineModel<string>({ required: true })
|
||||
const { yText } = defineProps<{
|
||||
yText: Y.Text
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:fullscreen': [boolean]
|
||||
}>()
|
||||
@ -88,7 +91,7 @@ watch(
|
||||
</div>
|
||||
<div class="scrollArea">
|
||||
<MarkdownEditor
|
||||
v-model="documentation"
|
||||
:yText="yText"
|
||||
:transformImageUrl="transformImageUrl"
|
||||
:toolbarContainer="toolbarElement"
|
||||
/>
|
||||
|
@ -3,6 +3,7 @@ import type { SuggestionEntry, SuggestionId } from '@/stores/suggestionDatabase/
|
||||
import { SuggestionKind, entryQn } from '@/stores/suggestionDatabase/entry'
|
||||
import type { Doc } from '@/util/docParser'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import type { SuggestionEntryArgument } from 'ydoc-shared/languageServerTypes/suggestions'
|
||||
|
||||
// === Types ===
|
||||
@ -108,15 +109,15 @@ export function lookupDocumentation(db: SuggestionDb, id: SuggestionId): Docs {
|
||||
|
||||
function getChildren(db: SuggestionDb, id: SuggestionId, kind: SuggestionKind): Docs[] {
|
||||
if (!id) return []
|
||||
const children = Array.from(db.childIdToParentId.reverseLookup(id))
|
||||
return children.reduce((acc: Docs[], id: SuggestionId) => {
|
||||
const entry = db.get(id)
|
||||
if (entry?.kind === kind && !entry?.isPrivate) {
|
||||
const docs = lookupDocumentation(db, id)
|
||||
acc.push(docs)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
const children = db.childIdToParentId.reverseLookup(id)
|
||||
return [
|
||||
...iter.filterDefined(
|
||||
iter.map(children, (id: SuggestionId) => {
|
||||
const entry = db.get(id)
|
||||
return entry?.kind === kind && !entry?.isPrivate ? lookupDocumentation(db, id) : undefined
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function asFunctionDocs(docs: Docs[]): FunctionDocs[] {
|
||||
|
29
app/gui/src/project-view/components/EditorRoot.vue
Normal file
29
app/gui/src/project-view/components/EditorRoot.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const rootElement = ref<HTMLElement>()
|
||||
defineExpose({ rootElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootElement"
|
||||
class="EditorRoot"
|
||||
@keydown.arrow-left.stop
|
||||
@keydown.arrow-right.stop
|
||||
@keydown.arrow-up.stop
|
||||
@keydown.arrow-down.stop
|
||||
@keydown.enter.stop
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
@wheel.stop.passive
|
||||
@contextmenu.stop
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.EditorRoot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
@ -9,7 +9,7 @@ import {
|
||||
import BottomPanel from '@/components/BottomPanel.vue'
|
||||
import CodeEditor from '@/components/CodeEditor.vue'
|
||||
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||
import { type Usage } from '@/components/ComponentBrowser/input'
|
||||
import type { Usage } from '@/components/ComponentBrowser/input'
|
||||
import { usePlacement } from '@/components/ComponentBrowser/placement'
|
||||
import ComponentDocumentation from '@/components/ComponentDocumentation.vue'
|
||||
import DockPanel from '@/components/DockPanel.vue'
|
||||
@ -20,21 +20,21 @@ import { useGraphEditorClipboard } from '@/components/GraphEditor/clipboard'
|
||||
import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import { useGraphEditorToasts } from '@/components/GraphEditor/toasts'
|
||||
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
|
||||
import { uploadedExpression, Uploader } from '@/components/GraphEditor/upload'
|
||||
import GraphMissingView from '@/components/GraphMissingView.vue'
|
||||
import GraphMouse from '@/components/GraphMouse.vue'
|
||||
import PlusButton from '@/components/PlusButton.vue'
|
||||
import SceneScroller from '@/components/SceneScroller.vue'
|
||||
import TopBar from '@/components/TopBar.vue'
|
||||
import { builtinWidgets } from '@/components/widgets'
|
||||
import { useAstDocumentation } from '@/composables/astDocumentation'
|
||||
import { useDoubleClick } from '@/composables/doubleClick'
|
||||
import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events'
|
||||
import { groupColorVar } from '@/composables/nodeColors'
|
||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
|
||||
import { provideFullscreenContext } from '@/providers/fullscreenContext'
|
||||
import { provideGraphNavigator, type GraphNavigator } from '@/providers/graphNavigator'
|
||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideNodeColors } from '@/providers/graphNodeColors'
|
||||
import { provideNodeCreation } from '@/providers/graphNodeCreation'
|
||||
import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
@ -43,25 +43,25 @@ import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { provideKeyboard } from '@/providers/keyboard'
|
||||
import { injectVisibility } from '@/providers/visibility'
|
||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||
import { provideGraphStore, type NodeId } from '@/stores/graph'
|
||||
import type { NodeId } from '@/stores/graph'
|
||||
import { provideGraphStore } from '@/stores/graph'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
|
||||
import { suggestionDocumentationUrl, type Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
|
||||
import { provideVisualizationStore } from '@/stores/visualization'
|
||||
import { bail } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { AstId } from '@/util/ast/abstract'
|
||||
import { colorFromString } from '@/util/colors'
|
||||
import { partition } from '@/util/data/array'
|
||||
import { every, filterDefined } from '@/util/data/iterable'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Err, Ok, unwrapOr } from '@/util/data/result'
|
||||
import { Err, Ok } from '@/util/data/result'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { computedFallback, useSelectRef } from '@/util/reactivity'
|
||||
import { until } from '@vueuse/core'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { encoding, set } from 'lib0'
|
||||
import {
|
||||
computed,
|
||||
@ -75,7 +75,6 @@ import {
|
||||
type ComponentInstance,
|
||||
} from 'vue'
|
||||
import { encodeMethodPointer } from 'ydoc-shared/languageServerTypes'
|
||||
import * as iterable from 'ydoc-shared/util/data/iterable'
|
||||
import { isDevMode } from 'ydoc-shared/util/detect'
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
@ -330,7 +329,7 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
},
|
||||
toggleVisualization() {
|
||||
const selected = nodeSelection.selected
|
||||
const allVisible = every(
|
||||
const allVisible = iter.every(
|
||||
selected,
|
||||
(id) => graphStore.db.nodeIdToNode.get(id)?.vis?.visible === true,
|
||||
)
|
||||
@ -416,7 +415,7 @@ const documentationEditorArea = computed(() => unrefElement(docEditor))
|
||||
const showRightDock = computedFallback(
|
||||
storedShowRightDock,
|
||||
// Show documentation editor when documentation exists on first graph visit.
|
||||
() => !!documentation.state.value,
|
||||
() => (markdownDocs.value?.length ?? 0) > 0,
|
||||
)
|
||||
const rightDockTab = computedFallback(storedRightDockTab, () => 'docs')
|
||||
|
||||
@ -430,9 +429,11 @@ const documentationEditorHandler = documentationEditorBindings.handler({
|
||||
},
|
||||
})
|
||||
|
||||
const { documentation } = useAstDocumentation(graphStore, () =>
|
||||
unwrapOr(graphStore.methodAst, undefined),
|
||||
)
|
||||
const markdownDocs = computed(() => {
|
||||
const currentMethod = graphStore.methodAst
|
||||
if (!currentMethod.ok) return
|
||||
return currentMethod.value.mutableDocumentationMarkdown()
|
||||
})
|
||||
|
||||
// === Component Browser ===
|
||||
|
||||
@ -550,7 +551,7 @@ const componentBrowserElements = computed(() => [
|
||||
|
||||
interface NewNodeOptions {
|
||||
placement: PlacementStrategy
|
||||
sourcePort?: AstId | undefined
|
||||
sourcePort?: Ast.AstId | undefined
|
||||
}
|
||||
|
||||
function addNodeDisconnected() {
|
||||
@ -592,7 +593,7 @@ function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[
|
||||
createWithComponentBrowser({ placement: { type: 'source', node: sourceNode }, sourcePort })
|
||||
}
|
||||
|
||||
function handleNodeOutputPortDoubleClick(id: AstId) {
|
||||
function handleNodeOutputPortDoubleClick(id: Ast.AstId) {
|
||||
const srcNode = graphStore.db.getPatternExpressionNodeId(id)
|
||||
if (srcNode == null) {
|
||||
console.error('Impossible happened: Double click on port not belonging to any node: ', id)
|
||||
@ -601,7 +602,7 @@ function handleNodeOutputPortDoubleClick(id: AstId) {
|
||||
createWithComponentBrowser({ placement: { type: 'source', node: srcNode }, sourcePort: id })
|
||||
}
|
||||
|
||||
function handleEdgeDrop(source: AstId, position: Vec2) {
|
||||
function handleEdgeDrop(source: Ast.AstId, position: Vec2) {
|
||||
createWithComponentBrowser({ placement: { type: 'fixed', position }, sourcePort: source })
|
||||
}
|
||||
|
||||
@ -609,7 +610,7 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
|
||||
|
||||
function collapseNodes() {
|
||||
const selected = new Set(
|
||||
iterable.filter(
|
||||
iter.filter(
|
||||
nodeSelection.selected,
|
||||
(id) => graphStore.db.nodeIdToNode.get(id)?.type === 'component',
|
||||
),
|
||||
@ -630,7 +631,7 @@ function collapseNodes() {
|
||||
if (!topLevel) {
|
||||
bail('BUG: no top level, collapsing not possible.')
|
||||
}
|
||||
const selectedNodeRects = filterDefined(Array.from(selected, graphStore.visibleArea))
|
||||
const selectedNodeRects = iter.filterDefined(iter.map(selected, graphStore.visibleArea))
|
||||
graphStore.edit((edit) => {
|
||||
const { collapsedCallRoot, collapsedNodeIds, outputAstId } = performCollapse(
|
||||
info.value,
|
||||
@ -641,8 +642,8 @@ function collapseNodes() {
|
||||
const position = collapsedNodePlacement(selectedNodeRects)
|
||||
edit.get(collapsedCallRoot).mutableNodeMetadata().set('position', position.xy())
|
||||
if (outputAstId != null) {
|
||||
const collapsedNodeRects = filterDefined(
|
||||
Array.from(collapsedNodeIds, graphStore.visibleArea),
|
||||
const collapsedNodeRects = iter.filterDefined(
|
||||
iter.map(collapsedNodeIds, graphStore.visibleArea),
|
||||
)
|
||||
const { place } = usePlacement(collapsedNodeRects, graphNavigator.viewport)
|
||||
const position = place(collapsedNodeRects)
|
||||
@ -785,9 +786,9 @@ const documentationEditorFullscreen = ref(false)
|
||||
>
|
||||
<template #docs>
|
||||
<DocumentationEditor
|
||||
v-if="markdownDocs"
|
||||
ref="docEditor"
|
||||
:modelValue="documentation.state.value"
|
||||
@update:modelValue="documentation.set"
|
||||
:yText="markdownDocs"
|
||||
@update:fullscreen="documentationEditorFullscreen = $event"
|
||||
/>
|
||||
</template>
|
||||
@ -812,7 +813,7 @@ const documentationEditorFullscreen = ref(false)
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
& :deep(.DockPanel) {
|
||||
& .DockPanel {
|
||||
flex: none;
|
||||
}
|
||||
& .vertical {
|
||||
@ -824,7 +825,7 @@ const documentationEditorFullscreen = ref(false)
|
||||
.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
& :deep(.BottomPanel) {
|
||||
& .BottomPanel {
|
||||
flex: none;
|
||||
}
|
||||
& .viewport {
|
||||
|
@ -405,6 +405,10 @@ watchEffect(() => {
|
||||
emit('update:rect', nodeOuterRect.value)
|
||||
}
|
||||
})
|
||||
|
||||
const dataSource = computed(
|
||||
() => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -489,7 +493,7 @@ watchEffect(() => {
|
||||
:nodePosition="nodePosition"
|
||||
:isCircularMenuVisible="menuVisible"
|
||||
:currentType="props.node.vis?.identifier"
|
||||
:dataSource="{ type: 'node', nodeId: props.node.rootExpr.externalId }"
|
||||
:dataSource="dataSource"
|
||||
:typename="expressionInfo?.typename"
|
||||
:width="visualizationWidth"
|
||||
:height="visualizationHeight"
|
||||
|
@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { visualizationBindings } from '@/bindings'
|
||||
import {
|
||||
RawDataSource,
|
||||
useVisualizationData,
|
||||
} from '@/components/GraphEditor/GraphVisualization/visualizationData'
|
||||
import type { RawDataSource } from '@/components/GraphEditor/GraphVisualization/visualizationData'
|
||||
import { useVisualizationData } from '@/components/GraphEditor/GraphVisualization/visualizationData'
|
||||
import VisualizationToolbar from '@/components/GraphEditor/GraphVisualization/VisualizationToolbar.vue'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import ResizeHandles from '@/components/ResizeHandles.vue'
|
||||
import WithFullscreenMode from '@/components/WithFullscreenMode.vue'
|
||||
import { focusIsIn, useEvent, useResizeObserver } from '@/composables/events'
|
||||
import { VisualizationDataSource } from '@/stores/visualization'
|
||||
import type { VisualizationDataSource } from '@/stores/visualization'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import { type BoundsSet, Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
@ -185,6 +183,15 @@ watch(
|
||||
() => isFullscreen,
|
||||
(f) => f && nextTick(() => panelElement.value?.focus()),
|
||||
)
|
||||
|
||||
const visParams = computed(() => {
|
||||
return {
|
||||
visualization: effectiveVisualization.value,
|
||||
data: effectiveVisualizationData.value,
|
||||
size: contentElementSize.value,
|
||||
nodeType: props.typename,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@ -241,10 +248,7 @@ customElements.define(ensoVisualizationHost, defineCustomElement(VisualizationHo
|
||||
>
|
||||
<component
|
||||
:is="ensoVisualizationHost"
|
||||
:visualization="effectiveVisualization"
|
||||
:data="effectiveVisualizationData"
|
||||
:size="contentElementSize"
|
||||
:nodeType="typename"
|
||||
:params="visParams"
|
||||
@updatePreprocessor="
|
||||
updatePreprocessor($event.detail[0], $event.detail[1], ...$event.detail.slice(2))
|
||||
"
|
||||
|
@ -1,20 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import FullscreenButton from '@/components/FullscreenButton.vue'
|
||||
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||
import SelectionDropdownText from '@/components/SelectionDropdownText.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import type { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import {
|
||||
isActionButton,
|
||||
isSelectionMenu,
|
||||
isTextSelectionMenu,
|
||||
isToggleButton,
|
||||
ToolbarItem,
|
||||
} from '@/components/visualizations/toolbar'
|
||||
import VisualizationSelector from '@/components/VisualizationSelector.vue'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { isQualifiedName, qnLastSegment } from '@/util/qualifiedName'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
|
||||
|
||||
const isFullscreen = defineModel<boolean>('isFullscreen', { required: true })
|
||||
const currentVis = defineModel<VisualizationIdentifier>('currentVis', { required: true })
|
||||
@ -88,6 +90,14 @@ useEvent(window, 'pointerup', (e) => interaction.handlePointerEvent(e, 'pointeru
|
||||
:title="item.title"
|
||||
alwaysShowArrow
|
||||
/>
|
||||
<SelectionDropdownText
|
||||
v-else-if="isTextSelectionMenu(item)"
|
||||
v-model="item.selectedTextOption.value"
|
||||
:options="item.options"
|
||||
:title="item.title"
|
||||
:heading="item.heading"
|
||||
alwaysShowArrow
|
||||
/>
|
||||
<div v-else>?</div>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -1,25 +1,25 @@
|
||||
import LoadingErrorVisualization from '@/components/visualizations/LoadingErrorVisualization.vue'
|
||||
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import type { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import type { NodeVisualizationConfiguration } from '@/stores/project/executionContext'
|
||||
import {
|
||||
DEFAULT_VISUALIZATION_CONFIGURATION,
|
||||
DEFAULT_VISUALIZATION_IDENTIFIER,
|
||||
useVisualizationStore,
|
||||
VisualizationDataSource,
|
||||
type VisualizationDataSource,
|
||||
} from '@/stores/visualization'
|
||||
import type { Visualization } from '@/stores/visualization/runtimeTypes'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { toError } from '@/util/data/error'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
onErrorCaptured,
|
||||
ref,
|
||||
shallowRef,
|
||||
ShallowRef,
|
||||
type ShallowRef,
|
||||
toValue,
|
||||
watch,
|
||||
watchEffect,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import testCases from '@/components/GraphEditor/__tests__/clipboardTestCases.json' assert { type: 'json' }
|
||||
import testCases from '@/components/GraphEditor/__tests__/clipboardTestCases.json' with { type: 'json' }
|
||||
import {
|
||||
isSpreadsheetTsv,
|
||||
nodesFromClipboardContent,
|
||||
|
@ -15,10 +15,10 @@ import { nodeIdFromOuterAst } from '../../../stores/graph/graphDatabase'
|
||||
// ===============================
|
||||
|
||||
function setupGraphDb(code: string, graphDb: GraphDb) {
|
||||
const { root, toRaw, getSpan } = Ast.parseExtended(code)
|
||||
const { root, toRaw, getSpan } = Ast.parseUpdatingIdMap(code)
|
||||
const expressions = Array.from(root.statements())
|
||||
const func = expressions[0]
|
||||
assert(func instanceof Ast.Function)
|
||||
assert(func instanceof Ast.FunctionDef)
|
||||
const rawFunc = toRaw.get(func.id)
|
||||
assert(rawFunc?.type === RawAst.Tree.Type.Function)
|
||||
graphDb.updateExternalIds(root)
|
||||
|
@ -2,9 +2,9 @@ import type { NodeCreationOptions } from '@/composables/nodeCreation'
|
||||
import type { GraphStore, Node, NodeId } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { filterDefined } from '@/util/data/iterable'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { NodeMetadataFields } from 'ydoc-shared/ast'
|
||||
|
||||
@ -131,7 +131,7 @@ async function decodeClipboard<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
return filterDefined(await Promise.all(clipboardItems.map(decodeItem)))
|
||||
return iter.filterDefined(await Promise.all(clipboardItems.map(decodeItem)))
|
||||
}
|
||||
|
||||
// === Spreadsheet clipboard decoder ===
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { GraphDb, NodeId, nodeIdFromOuterAst } from '@/stores/graph/graphDatabase'
|
||||
import type { GraphDb, NodeId } from '@/stores/graph/graphDatabase'
|
||||
import { nodeIdFromOuterAst } from '@/stores/graph/graphDatabase'
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Identifier, isIdentifier, moduleMethodNames } from '@/util/ast/abstract'
|
||||
import { Err, Ok, Result, unwrap } from '@/util/data/result'
|
||||
import type { Identifier } from '@/util/ast/abstract'
|
||||
import { isIdentifier, moduleMethodNames } from '@/util/ast/abstract'
|
||||
import { Err, Ok, unwrap, type Result } from '@/util/data/result'
|
||||
import { tryIdentifier } from '@/util/qualifiedName'
|
||||
import * as set from 'lib0/set'
|
||||
|
||||
@ -222,7 +224,7 @@ export function performCollapseImpl(
|
||||
const collapsedBody = Ast.BodyBlock.new(extractedLines, edit)
|
||||
const outputAst = Ast.Ident.new(edit, outputIdentifier)
|
||||
collapsedBody.push(outputAst)
|
||||
const collapsedFunction = Ast.Function.new(collapsedName, info.args, collapsedBody, {
|
||||
const collapsedFunction = Ast.FunctionDef.new(collapsedName, info.args, collapsedBody, {
|
||||
edit,
|
||||
documentation: 'ICON group',
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import type { Opt } from '@/util/data/opt'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import theme from '@/util/theme.json'
|
||||
import { iteratorFilter } from 'lib0/iterator'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { computed, markRaw, ref, watchEffect, type ComputedRef, type WatchStopHandle } from 'vue'
|
||||
|
||||
const DRAG_SNAP_THRESHOLD = 16
|
||||
@ -21,7 +21,7 @@ interface PartialVec2 {
|
||||
* Snap Grid for dragged nodes.
|
||||
*
|
||||
* Created from existing nodes' rects, it allows "snapping" dragged nodes to another nodes on
|
||||
* the scene, so the user could easily and nicely ailgn their nodes.
|
||||
* the scene, so the user could easily and nicely align their nodes.
|
||||
*
|
||||
* The nodes will be snapped to align with every edge of any other node, and also at place above
|
||||
* and below node leaving default vertical gap (same as when adding new node).
|
||||
@ -199,7 +199,7 @@ export function useDragging() {
|
||||
|
||||
private createSnapGrid() {
|
||||
const nonDraggedRects = computed(() => {
|
||||
const nonDraggedNodes = iteratorFilter(
|
||||
const nonDraggedNodes = iter.filter(
|
||||
graphStore.db.nodeIds(),
|
||||
(id) => !this.draggedNodes.has(id),
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import { provideTooltipRegistry, TooltipRegistry } from '@/providers/tooltipState'
|
||||
import type { TooltipRegistry } from '@/providers/tooltipState'
|
||||
import { provideTooltipRegistry } from '@/providers/tooltipState'
|
||||
import type { IHeaderParams } from 'ag-grid-community'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { commonContextMenuActions, type MenuItem } from '@/components/shared/AgGridTableView.vue'
|
||||
import type { WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
|
||||
import { requiredImportsByFQN, type RequiredImport } from '@/stores/graph/imports'
|
||||
import { type RequiredImport, requiredImportsByFQN } from '@/stores/graph/imports'
|
||||
import type { SuggestionDb } from '@/stores/suggestionDatabase'
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { tryEnsoToNumber, tryNumberToEnso } from '@/util/ast/abstract'
|
||||
import { findIndexOpt } from '@/util/data/array'
|
||||
import * as iterable from '@/util/data/iterable'
|
||||
import { Err, Ok, transposeResult, unwrapOrWithLog, type Result } from '@/util/data/result'
|
||||
import { Err, Ok, type Result, transposeResult, unwrapOrWithLog } from '@/util/data/result'
|
||||
import { qnLastSegment, type QualifiedName } from '@/util/qualifiedName'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import type { ColDef } from 'ag-grid-enterprise'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { ColumnSpecificHeaderParams } from './TableHeader.vue'
|
||||
import type { ColumnSpecificHeaderParams } from './TableHeader.vue'
|
||||
|
||||
/** Id of a fake column with "Add new column" option. */
|
||||
export const NEW_COLUMN_ID = 'NewColumn'
|
||||
@ -437,7 +437,7 @@ export function useTableNewArgument(
|
||||
}
|
||||
const edit = graph.startEdit()
|
||||
const columns = edit.getVersion(columnsAst.value.value)
|
||||
const fromIndex = iterable.find(columns.enumerate(), ([, ast]) => ast?.id === colId)?.[0]
|
||||
const fromIndex = iter.find(columns.enumerate(), ([, ast]) => ast?.id === colId)?.[0]
|
||||
if (fromIndex != null) {
|
||||
columns.move(fromIndex, toIndex - 1)
|
||||
onUpdate({ edit })
|
||||
|
@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { UrlTransformer } from '@/components/MarkdownEditor/imageUrlTransformer'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const text = defineModel<string>({ required: true })
|
||||
const props = defineProps<{
|
||||
yText: Y.Text
|
||||
transformImageUrl?: UrlTransformer
|
||||
toolbarContainer: HTMLElement | undefined
|
||||
}>()
|
||||
@ -15,6 +16,6 @@ const LazyMarkdownEditor = defineAsyncComponent(
|
||||
|
||||
<template>
|
||||
<Suspense>
|
||||
<LazyMarkdownEditor v-model="text" v-bind="props" />
|
||||
<LazyMarkdownEditor v-bind="props" />
|
||||
</Suspense>
|
||||
</template>
|
||||
|
@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { blockTypeToBlockName, type BlockType } from '@/components/MarkdownEditor/formatting'
|
||||
import SelectionDropdown from '@/components/SelectionDropdown.vue'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
|
||||
const blockType = defineModel<BlockType>({ required: true })
|
||||
|
||||
const blockTypeIcon: Record<keyof typeof blockTypeToBlockName, Icon> = {
|
||||
paragraph: 'text',
|
||||
bullet: 'bullet-list',
|
||||
code: 'code',
|
||||
h1: 'header1',
|
||||
h2: 'header2',
|
||||
h3: 'header3',
|
||||
number: 'numbered-list',
|
||||
quote: 'quote',
|
||||
}
|
||||
const blockTypesOrdered: BlockType[] = [
|
||||
'paragraph',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'code',
|
||||
'bullet',
|
||||
'number',
|
||||
'quote',
|
||||
]
|
||||
|
||||
const blockTypeOptions = Object.fromEntries(
|
||||
blockTypesOrdered.map((key) => [
|
||||
key,
|
||||
{ icon: blockTypeIcon[key], label: blockTypeToBlockName[key] },
|
||||
]),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectionDropdown v-model="blockType" :options="blockTypeOptions" labelButton />
|
||||
</template>
|
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
injectLexicalImageUrlTransformer,
|
||||
injectDocumentationImageUrlTransformer,
|
||||
type TransformUrlResult,
|
||||
} from '@/components/MarkdownEditor/imageUrlTransformer'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
@ -14,10 +14,10 @@ const props = defineProps<{
|
||||
alt: string
|
||||
}>()
|
||||
|
||||
const urlTransformer = injectLexicalImageUrlTransformer(true)
|
||||
const urlTransformer = injectDocumentationImageUrlTransformer(true)
|
||||
|
||||
// NOTE: Garbage-collecting image data when the `src` changes is not implemented. Current users of `LexicalImage` don't
|
||||
// change the `src` after creating an image.
|
||||
// NOTE: Garbage-collecting image data when the `src` changes is not implemented. Current users of `DocumentationImage`
|
||||
// don't change the `src` after creating an image.
|
||||
const data: Ref<TransformUrlResult | undefined> =
|
||||
urlTransformer ?
|
||||
computedAsync(() => urlTransformer.transformUrl(props.src), undefined, {
|
@ -1,87 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import { type UseFormatting } from '@/components/MarkdownEditor/formatting'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ formatting: UseFormatting }>()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const { bold, italic, strikethrough, subscript, superscript, blockType, clearFormatting } =
|
||||
props.formatting
|
||||
|
||||
function useValueEqualsConstant<T>(value: Ref<T>, constant: T, valueWhenSetToFalse: T) {
|
||||
return {
|
||||
state: computed(() => value.value === constant),
|
||||
set: (newValue: boolean) => {
|
||||
if (newValue && value.value !== constant) {
|
||||
value.value = constant
|
||||
} else if (!newValue && value.value === constant) {
|
||||
value.value = valueWhenSetToFalse
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const code = useValueEqualsConstant(blockType.state, 'code', 'paragraph')
|
||||
|
||||
const TODO: Icon = 'text'
|
||||
|
||||
const close = () => (menuOpen.value = false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleIcon
|
||||
:modelValue="bold.state.value"
|
||||
icon="bold"
|
||||
title="Bold"
|
||||
@update:modelValue="bold.set"
|
||||
/>
|
||||
<ToggleIcon
|
||||
:modelValue="italic.state.value"
|
||||
icon="italic"
|
||||
title="Italic"
|
||||
@update:modelValue="italic.set"
|
||||
/>
|
||||
<ToggleIcon
|
||||
:modelValue="code.state.value"
|
||||
icon="code"
|
||||
title="Insert Code Block"
|
||||
@update:modelValue="code.set"
|
||||
/>
|
||||
<!-- TODO: Insert link -->
|
||||
<DropdownMenu v-model:open="menuOpen">
|
||||
<template #button>Aa</template>
|
||||
<template #entries>
|
||||
<ToggleIcon
|
||||
:modelValue="strikethrough.state.value"
|
||||
icon="strike-through"
|
||||
label="Strikethrough"
|
||||
@update:modelValue="strikethrough.set"
|
||||
@click="close"
|
||||
/>
|
||||
<ToggleIcon
|
||||
:modelValue="subscript.state.value"
|
||||
:icon="TODO"
|
||||
label="Subscript"
|
||||
@update:modelValue="subscript.set"
|
||||
@click="close"
|
||||
/>
|
||||
<ToggleIcon
|
||||
:modelValue="superscript.state.value"
|
||||
:icon="TODO"
|
||||
label="Superscript"
|
||||
@update:modelValue="superscript.set"
|
||||
@click="close"
|
||||
/>
|
||||
<SvgButton
|
||||
name="remove-textstyle"
|
||||
label="Clear Formatting"
|
||||
@click.stop="clearFormatting"
|
||||
@click="close"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
@ -1,45 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import BlockTypeMenu from '@/components/MarkdownEditor/BlockTypeMenu.vue'
|
||||
import FormatPropertiesBar from '@/components/MarkdownEditor/FormatPropertiesBar.vue'
|
||||
import type { UseFormatting } from '@/components/MarkdownEditor/formatting'
|
||||
|
||||
const props = defineProps<{ formatting: UseFormatting }>()
|
||||
|
||||
const { blockType } = props.formatting
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="FormattingToolbar">
|
||||
<div class="section">
|
||||
<BlockTypeMenu :modelValue="blockType.state.value" @update:modelValue="blockType.set" />
|
||||
</div>
|
||||
<div class="section">
|
||||
<FormatPropertiesBar :formatting="formatting" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.FormattingToolbar {
|
||||
--color-frame-bg: white;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
background-color: white;
|
||||
border-radius: var(--radius-full);
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.FormattingToolbar :deep(.iconLabel) {
|
||||
margin-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
</style>
|
@ -1,164 +0,0 @@
|
||||
import LexicalImage from '@/components/MarkdownEditor/ImagePlugin/LexicalImage.vue'
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
} from 'lexical'
|
||||
import { $applyNodeReplacement, DecoratorNode } from 'lexical'
|
||||
import { h, type Component } from 'vue'
|
||||
|
||||
export interface ImagePayload {
|
||||
altText: string
|
||||
key?: NodeKey | undefined
|
||||
src: string
|
||||
}
|
||||
|
||||
export interface UpdateImagePayload {
|
||||
altText?: string
|
||||
}
|
||||
|
||||
function $convertImageElement(domNode: Node): null | DOMConversionOutput {
|
||||
if (domNode instanceof HTMLImageElement) {
|
||||
const { alt: altText, src } = domNode
|
||||
const node = $createImageNode({ altText, src })
|
||||
return { node }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type SerializedImageNode = Spread<
|
||||
{
|
||||
altText: string
|
||||
src: string
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>
|
||||
|
||||
/** TODO: Add docs */
|
||||
export class ImageNode extends DecoratorNode<Component> {
|
||||
__src: string
|
||||
__altText: string
|
||||
|
||||
/** TODO: Add docs */
|
||||
static override getType(): string {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
static override clone(node: ImageNode): ImageNode {
|
||||
return new ImageNode(node.__src, node.__altText, node.__key)
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
static override importJSON(serializedNode: SerializedImageNode): ImageNode {
|
||||
const { altText, src } = serializedNode
|
||||
return $createImageNode({
|
||||
altText,
|
||||
src,
|
||||
})
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
static override importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
img: (_node: Node) => ({
|
||||
conversion: $convertImageElement,
|
||||
priority: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
constructor(src: string, altText: string, key?: NodeKey) {
|
||||
super(key)
|
||||
this.__src = src
|
||||
this.__altText = altText
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
override exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('img')
|
||||
element.setAttribute('src', this.__src)
|
||||
element.setAttribute('alt', this.__altText)
|
||||
return { element }
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
override exportJSON(): SerializedImageNode {
|
||||
return {
|
||||
altText: this.getAltText(),
|
||||
src: this.getSrc(),
|
||||
type: 'image',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
getSrc(): string {
|
||||
return this.__src
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
getAltText(): string {
|
||||
return this.__altText
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
setAltText(altText: string): void {
|
||||
const writable = this.getWritable()
|
||||
writable.__altText = altText
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
update(payload: UpdateImagePayload): void {
|
||||
const writable = this.getWritable()
|
||||
const { altText } = payload
|
||||
if (altText !== undefined) {
|
||||
writable.__altText = altText
|
||||
}
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
/** TODO: Add docs */
|
||||
override createDOM(config: EditorConfig): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
const className = config.theme.image
|
||||
if (className !== undefined) {
|
||||
span.className = className
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
override updateDOM(_prevNode: ImageNode, dom: HTMLElement, config: EditorConfig): false {
|
||||
const className = config.theme.image
|
||||
if (className !== undefined) {
|
||||
dom.className = className
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
override decorate(): Component {
|
||||
return h(LexicalImage, {
|
||||
src: this.__src,
|
||||
alt: this.__altText,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function $createImageNode({ altText, src, key }: ImagePayload): ImageNode {
|
||||
return $applyNodeReplacement(new ImageNode(src, altText, key))
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode {
|
||||
return node instanceof ImageNode
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import {
|
||||
$createImageNode,
|
||||
$isImageNode,
|
||||
ImageNode,
|
||||
} from '@/components/MarkdownEditor/ImagePlugin/imageNode'
|
||||
import type { LexicalMarkdownPlugin } from '@/components/MarkdownEditor/markdown'
|
||||
import type { TextMatchTransformer } from '@lexical/markdown'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { assertDefined } from 'ydoc-shared/util/assert'
|
||||
|
||||
export const IMAGE: TextMatchTransformer = {
|
||||
dependencies: [ImageNode],
|
||||
export: (node) => {
|
||||
if (!$isImageNode(node)) {
|
||||
return null
|
||||
}
|
||||
return `![${node.getAltText()}](${node.getSrc()})`
|
||||
},
|
||||
importRegExp: /!\[([^\]]*)]\(([^()\n]+)\)/,
|
||||
regExp: /!\[([^\]]*)]\(([^()\n]+)\)$/,
|
||||
replace: (textNode, match) => {
|
||||
const [, altText, src] = match
|
||||
assertDefined(altText)
|
||||
assertDefined(src)
|
||||
const imageNode = $createImageNode({ altText, src })
|
||||
textNode.replace(imageNode)
|
||||
},
|
||||
trigger: ')',
|
||||
type: 'text-match',
|
||||
}
|
||||
|
||||
export const imagePlugin: LexicalMarkdownPlugin = {
|
||||
nodes: [ImageNode],
|
||||
transformers: [IMAGE],
|
||||
register(_editor: LexicalEditor): void {},
|
||||
}
|
@ -1,80 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import FloatingSelectionMenu from '@/components/FloatingSelectionMenu.vue'
|
||||
import FormattingToolbar from '@/components/MarkdownEditor/FormattingToolbar.vue'
|
||||
import { imagePlugin } from '@/components/MarkdownEditor/ImagePlugin'
|
||||
import SelectionFormattingToolbar from '@/components/MarkdownEditor/SelectionFormattingToolbar.vue'
|
||||
import { lexicalRichTextTheme, useFormatting } from '@/components/MarkdownEditor/formatting'
|
||||
import EditorRoot from '@/components/EditorRoot.vue'
|
||||
import { highlightStyle } from '@/components/MarkdownEditor/highlight'
|
||||
import {
|
||||
provideLexicalImageUrlTransformer,
|
||||
provideDocumentationImageUrlTransformer,
|
||||
type UrlTransformer,
|
||||
} from '@/components/MarkdownEditor/imageUrlTransformer'
|
||||
import { listPlugin } from '@/components/MarkdownEditor/listPlugin'
|
||||
import { markdownPlugin } from '@/components/MarkdownEditor/markdown'
|
||||
import { useLexical } from '@/components/lexical'
|
||||
import LexicalContent from '@/components/lexical/LexicalContent.vue'
|
||||
import LexicalDecorators from '@/components/lexical/LexicalDecorators.vue'
|
||||
import { autoLinkPlugin, linkPlugin, useLinkNode } from '@/components/lexical/LinkPlugin'
|
||||
import LinkToolbar from '@/components/lexical/LinkToolbar.vue'
|
||||
import { shallowRef, toRef, useCssModule, type ComponentInstance } from 'vue'
|
||||
import { ensoMarkdown } from '@/components/MarkdownEditor/markdown'
|
||||
import VueComponentHost from '@/components/VueComponentHost.vue'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { minimalSetup } from 'codemirror'
|
||||
import { type ComponentInstance, onMounted, ref, toRef, useCssModule, watch } from 'vue'
|
||||
import { yCollab } from 'y-codemirror.next'
|
||||
import * as awarenessProtocol from 'y-protocols/awareness.js'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const editorRoot = ref<ComponentInstance<typeof EditorRoot>>()
|
||||
|
||||
const markdown = defineModel<string>({ required: true })
|
||||
const props = defineProps<{
|
||||
yText: Y.Text
|
||||
transformImageUrl?: UrlTransformer | undefined
|
||||
toolbarContainer: HTMLElement | undefined
|
||||
}>()
|
||||
|
||||
const contentElement = shallowRef<ComponentInstance<typeof LexicalContent>>()
|
||||
const vueHost = ref<ComponentInstance<typeof VueComponentHost>>()
|
||||
|
||||
provideLexicalImageUrlTransformer(toRef(props, 'transformImageUrl'))
|
||||
provideDocumentationImageUrlTransformer(toRef(props, 'transformImageUrl'))
|
||||
|
||||
const theme = lexicalRichTextTheme(useCssModule('lexicalTheme'))
|
||||
const { editor } = useLexical(contentElement, 'MarkdownEditor', theme, [
|
||||
...markdownPlugin(markdown, [listPlugin, imagePlugin, linkPlugin]),
|
||||
autoLinkPlugin,
|
||||
])
|
||||
const formatting = useFormatting(editor)
|
||||
const awareness = new awarenessProtocol.Awareness(new Y.Doc())
|
||||
const editorView = new EditorView()
|
||||
const constantExtensions = [minimalSetup, highlightStyle(useCssModule()), EditorView.lineWrapping]
|
||||
|
||||
const { urlUnderCursor } = useLinkNode(editor)
|
||||
watch([vueHost, toRef(props, 'yText')], ([vueHost, yText]) => {
|
||||
if (!vueHost) return
|
||||
editorView.setState(
|
||||
EditorState.create({
|
||||
doc: yText.toString(),
|
||||
extensions: [...constantExtensions, ensoMarkdown({ vueHost }), yCollab(yText, awareness)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const content = editorView.dom.getElementsByClassName('cm-content')[0]!
|
||||
content.addEventListener('focusin', () => (editing.value = true))
|
||||
editorRoot.value?.rootElement?.prepend(editorView.dom)
|
||||
})
|
||||
|
||||
const editing = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="MarkdownEditor fullHeight">
|
||||
<Teleport :to="toolbarContainer">
|
||||
<FormattingToolbar :formatting="formatting" @pointerdown.prevent />
|
||||
</Teleport>
|
||||
<LexicalContent ref="contentElement" @wheel.stop.passive @contextmenu.stop @pointerdown.stop />
|
||||
<FloatingSelectionMenu :selectionElement="contentElement">
|
||||
<template #default="{ collapsed }">
|
||||
<SelectionFormattingToolbar v-if="!collapsed" :formatting="formatting" />
|
||||
<LinkToolbar v-else-if="urlUnderCursor" :url="urlUnderCursor" />
|
||||
</template>
|
||||
</FloatingSelectionMenu>
|
||||
<LexicalDecorators :editor="editor" />
|
||||
</div>
|
||||
<EditorRoot
|
||||
ref="editorRoot"
|
||||
class="MarkdownEditor"
|
||||
:class="{ editing }"
|
||||
@focusout="editing = false"
|
||||
/>
|
||||
<VueComponentHost ref="vueHost" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fullHeight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
:deep(.cm-content) {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
:deep(.toggledOn) {
|
||||
:deep(.cm-scroller) {
|
||||
/* Prevent touchpad back gesture, which can be triggered while panning. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.EditorRoot :deep(.cm-editor) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
color: black;
|
||||
opacity: 0.6;
|
||||
}
|
||||
:deep(.toggledOff) {
|
||||
color: black;
|
||||
opacity: 0.3;
|
||||
}
|
||||
:deep(.DropdownMenuButton) {
|
||||
color: inherit;
|
||||
opacity: inherit;
|
||||
}
|
||||
:deep(.DropdownMenuContent .MenuButton) {
|
||||
justify-content: unset;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module="lexicalTheme" src="@/components/MarkdownEditor/theme.css" />
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style module>
|
||||
/* === Syntax styles === */
|
||||
|
||||
.heading1 {
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.heading2 {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.heading3,
|
||||
.heading4,
|
||||
.heading5,
|
||||
.heading6 {
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
.processingInstruction {
|
||||
opacity: 20%;
|
||||
}
|
||||
.emphasis:not(.processingInstruction) {
|
||||
font-style: italic;
|
||||
}
|
||||
.strong:not(.processingInstruction) {
|
||||
font-weight: bold;
|
||||
}
|
||||
.strikethrough:not(.processingInstruction) {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.monospace {
|
||||
/*noinspection CssNoGenericFontName*/
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* === Editing-mode === */
|
||||
|
||||
/* There are currently no style overrides for editing mode, so this is commented out to appease the Vue linter. */
|
||||
/* :global(.MarkdownEditor):global(.editing) :global(.cm-line):global(.cm-has-cursor) {} */
|
||||
|
||||
/* === View-mode === */
|
||||
|
||||
:global(.MarkdownEditor):not(:global(.editing)) :global(.cm-line),
|
||||
:global(.cm-line):not(:global(.cm-has-cursor)) {
|
||||
:global(.cm-image-markup) {
|
||||
display: none;
|
||||
}
|
||||
.processingInstruction {
|
||||
display: none;
|
||||
}
|
||||
.url {
|
||||
display: none;
|
||||
}
|
||||
a > .link {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&:has(.list.processingInstruction) {
|
||||
display: list-item;
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type UseFormatting } from '@/components/MarkdownEditor/formatting'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
|
||||
const props = defineProps<{ formatting: UseFormatting }>()
|
||||
|
||||
const { bold, italic, strikethrough } = props.formatting
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="SelectionFormattingToolbar">
|
||||
<ToggleIcon
|
||||
:modelValue="bold.state.value"
|
||||
icon="bold"
|
||||
title="Bold"
|
||||
@update:modelValue="bold.set"
|
||||
/>
|
||||
<ToggleIcon
|
||||
:modelValue="italic.state.value"
|
||||
icon="italic"
|
||||
title="Italic"
|
||||
@update:modelValue="italic.set"
|
||||
/>
|
||||
<ToggleIcon
|
||||
:modelValue="strikethrough.state.value"
|
||||
icon="strike-through"
|
||||
title="Strikethrough"
|
||||
@update:modelValue="strikethrough.set"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.SelectionFormattingToolbar {
|
||||
display: flex;
|
||||
background-color: white;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,137 @@
|
||||
import { ensoMarkdown } from '@/components/MarkdownEditor/markdown'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { Decoration, EditorView } from '@codemirror/view'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
function decorations<T>(
|
||||
source: string,
|
||||
recognize: (from: number, to: number, decoration: Decoration) => T | undefined,
|
||||
) {
|
||||
const vueHost = {
|
||||
register: () => ({ unregister: () => {} }),
|
||||
}
|
||||
const state = EditorState.create({
|
||||
doc: source,
|
||||
extensions: [ensoMarkdown({ vueHost })],
|
||||
})
|
||||
const view = new EditorView({ state })
|
||||
const decorationSets = state.facet(EditorView.decorations)
|
||||
const results = []
|
||||
for (const decorationSet of decorationSets) {
|
||||
const resolvedDecorations =
|
||||
decorationSet instanceof Function ? decorationSet(view) : decorationSet
|
||||
const cursor = resolvedDecorations.iter()
|
||||
while (cursor.value != null) {
|
||||
const recognized = recognize(cursor.from, cursor.to, cursor.value)
|
||||
if (recognized) results.push(recognized)
|
||||
cursor.next()
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function links(source: string) {
|
||||
return decorations(source, (from, to, deco) => {
|
||||
if (deco.spec.tagName === 'a') {
|
||||
return {
|
||||
text: source.substring(from, to),
|
||||
href: deco.spec.attributes.href,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function images(source: string) {
|
||||
return decorations(source, (from, to, deco) => {
|
||||
if ('widget' in deco.spec && 'props' in deco.spec.widget && 'src' in deco.spec.widget.props) {
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
src: deco.spec.widget.props.src,
|
||||
alt: deco.spec.widget.props.alt,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
markdown: '[Link text](https://www.example.com/index.html)',
|
||||
expectedLinks: [
|
||||
{
|
||||
text: 'Link text',
|
||||
href: 'https://www.example.com/index.html',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
markdown: '[Unclosed url](https://www.example.com/index.html',
|
||||
expectedLinks: [],
|
||||
},
|
||||
{
|
||||
markdown: '[](https://www.example.com/index.html)',
|
||||
expectedLinks: [],
|
||||
},
|
||||
{
|
||||
markdown: '[With empty URL]()',
|
||||
expectedLinks: [],
|
||||
},
|
||||
{
|
||||
markdown: '[With no URL]',
|
||||
expectedLinks: [],
|
||||
},
|
||||
{
|
||||
markdown: '[Unclosed',
|
||||
expectedLinks: [],
|
||||
},
|
||||
])('Link decoration: $markdown', ({ markdown, expectedLinks }) => {
|
||||
expect(links(markdown)).toEqual(expectedLinks)
|
||||
expect(images(markdown)).toEqual([])
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
markdown: '![Image](https://www.example.com/image.avif)',
|
||||
image: {
|
||||
src: 'https://www.example.com/image.avif',
|
||||
alt: 'Image',
|
||||
},
|
||||
},
|
||||
{
|
||||
markdown: '![](https://www.example.com/image.avif)',
|
||||
image: {
|
||||
src: 'https://www.example.com/image.avif',
|
||||
alt: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
markdown: '![Image](https://www.example.com/image.avif',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
markdown: '![Image]()',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
markdown: '![Image]',
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
markdown: '![Image',
|
||||
image: null,
|
||||
},
|
||||
])('Image decoration: $markdown', ({ markdown, image }) => {
|
||||
expect(links(markdown)).toEqual([])
|
||||
expect(images(markdown)).toEqual(
|
||||
image == null ?
|
||||
[]
|
||||
: [
|
||||
{
|
||||
from: markdown.length,
|
||||
to: markdown.length,
|
||||
src: image.src,
|
||||
alt: image.alt,
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
@ -1,253 +0,0 @@
|
||||
import { lexicalTheme } from '@/components/lexical'
|
||||
import { useBufferedWritable } from '@/util/reactivity'
|
||||
import { $createCodeNode } from '@lexical/code'
|
||||
import {
|
||||
$isListNode,
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
ListNode,
|
||||
} from '@lexical/list'
|
||||
import {
|
||||
$createHeadingNode,
|
||||
$createQuoteNode,
|
||||
$isHeadingNode,
|
||||
$isQuoteNode,
|
||||
type HeadingTagType,
|
||||
} from '@lexical/rich-text'
|
||||
import { $setBlocksType } from '@lexical/selection'
|
||||
import { $isTableSelection } from '@lexical/table'
|
||||
import {
|
||||
$findMatchingParent,
|
||||
$getNearestBlockElementAncestorOrThrow,
|
||||
$getNearestNodeOfType,
|
||||
} from '@lexical/utils'
|
||||
import type { EditorThemeClasses, LexicalEditor, RangeSelection, TextFormatType } from 'lexical'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function useFormatting(editor: LexicalEditor) {
|
||||
const selectionReaders = new Array<(selection: RangeSelection) => void>()
|
||||
function onReadSelection(reader: (selection: RangeSelection) => void) {
|
||||
selectionReaders.push(reader)
|
||||
}
|
||||
function $readState() {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
for (const reader of selectionReaders) {
|
||||
reader(selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read($readState)
|
||||
})
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, _newEditor) => {
|
||||
$readState()
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
return {
|
||||
bold: useFormatProperty(editor, 'bold', onReadSelection),
|
||||
italic: useFormatProperty(editor, 'italic', onReadSelection),
|
||||
strikethrough: useFormatProperty(editor, 'strikethrough', onReadSelection),
|
||||
subscript: useFormatProperty(editor, 'subscript', onReadSelection),
|
||||
superscript: useFormatProperty(editor, 'superscript', onReadSelection),
|
||||
blockType: useBlockType(editor, onReadSelection),
|
||||
clearFormatting: () => editor.update($clearFormatting),
|
||||
}
|
||||
}
|
||||
export type UseFormatting = ReturnType<typeof useFormatting>
|
||||
|
||||
function useFormatProperty(
|
||||
editor: LexicalEditor,
|
||||
property: TextFormatType,
|
||||
onReadSelection: ($readSelection: (selection: RangeSelection) => void) => void,
|
||||
) {
|
||||
const state = ref(false)
|
||||
|
||||
onReadSelection((selection) => (state.value = selection.hasFormat(property)))
|
||||
|
||||
// The editor only exposes a toggle interface, so we need to model its state to ensure the setter is only called when
|
||||
// the desired value is different from its current value.
|
||||
const writable = useBufferedWritable({
|
||||
get: state,
|
||||
set: (_value: boolean) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, property),
|
||||
})
|
||||
|
||||
return { state, set: (value: boolean) => (writable.value = value) }
|
||||
}
|
||||
|
||||
function $clearFormatting() {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
|
||||
const anchor = selection.anchor
|
||||
const focus = selection.focus
|
||||
const nodes = selection.getNodes()
|
||||
const extractedNodes = selection.extract()
|
||||
|
||||
if (anchor.key === focus.key && anchor.offset === focus.offset) {
|
||||
return
|
||||
}
|
||||
|
||||
nodes.forEach((node, idx) => {
|
||||
// We split the first and last node by the selection
|
||||
// So that we don't format unselected text inside those nodes
|
||||
if ($isTextNode(node)) {
|
||||
// Use a separate variable to ensure TS does not lose the refinement
|
||||
let textNode = node
|
||||
if (idx === 0 && anchor.offset !== 0) {
|
||||
textNode = textNode.splitText(anchor.offset)[1] || textNode
|
||||
}
|
||||
if (idx === nodes.length - 1) {
|
||||
textNode = textNode.splitText(focus.offset)[0] || textNode
|
||||
}
|
||||
/**
|
||||
* If the selected text has one format applied
|
||||
* selecting a portion of the text, could
|
||||
* clear the format to the wrong portion of the text.
|
||||
*
|
||||
* The cleared text is based on the length of the selected text.
|
||||
*/
|
||||
// We need this in case the selected text only has one format
|
||||
const extractedTextNode = extractedNodes[0]
|
||||
if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
|
||||
textNode = extractedTextNode
|
||||
}
|
||||
|
||||
if (textNode.__style !== '') {
|
||||
textNode.setStyle('')
|
||||
}
|
||||
if (textNode.__format !== 0) {
|
||||
textNode.setFormat(0)
|
||||
$getNearestBlockElementAncestorOrThrow(textNode).setFormat('')
|
||||
}
|
||||
node = textNode
|
||||
} else if ($isHeadingNode(node) || $isQuoteNode(node)) {
|
||||
node.replace($createParagraphNode(), true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const blockTypeToBlockName = {
|
||||
bullet: 'Bulleted List',
|
||||
code: 'Code Block',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
number: 'Numbered List',
|
||||
paragraph: 'Normal',
|
||||
quote: 'Quote',
|
||||
}
|
||||
export type BlockType = keyof typeof blockTypeToBlockName
|
||||
export const blockTypes = Object.keys(blockTypeToBlockName) as BlockType[]
|
||||
|
||||
const smallestEnabledHeading = ['h6', 'h5', 'h4', 'h3', 'h2', 'h1'].find(
|
||||
isBlockType,
|
||||
) as HeadingTagType & BlockType
|
||||
|
||||
function isBlockType(value: string): value is BlockType {
|
||||
return value in blockTypeToBlockName
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function normalizeHeadingLevel(heading: HeadingTagType): HeadingTagType & BlockType {
|
||||
return isBlockType(heading) ? heading : smallestEnabledHeading
|
||||
}
|
||||
|
||||
function useBlockType(
|
||||
editor: LexicalEditor,
|
||||
onReadSelection: ($readSelection: (selection: RangeSelection) => void) => void,
|
||||
) {
|
||||
const state = ref<BlockType>('paragraph')
|
||||
|
||||
onReadSelection((selection) => (state.value = $getBlockType(selection) ?? 'paragraph'))
|
||||
|
||||
function $getBlockType(selection: RangeSelection): BlockType | undefined {
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const element =
|
||||
anchorNode.getKey() === 'root' ?
|
||||
anchorNode
|
||||
: $findMatchingParent(anchorNode, (e) => {
|
||||
const parent = e.getParent()
|
||||
return parent !== null && $isRootOrShadowRoot(parent)
|
||||
}) ?? anchorNode.getTopLevelElementOrThrow()
|
||||
|
||||
if ($isListNode(element)) {
|
||||
const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
|
||||
const type = parentList ? parentList.getListType() : element.getListType()
|
||||
if (type in blockTypeToBlockName) {
|
||||
return type as keyof typeof blockTypeToBlockName
|
||||
}
|
||||
} else if ($isHeadingNode(element)) {
|
||||
return normalizeHeadingLevel(element.getTag())
|
||||
} else {
|
||||
const type = element.getType()
|
||||
if (type in blockTypeToBlockName) {
|
||||
return type as keyof typeof blockTypeToBlockName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $setBlockType: Record<BlockType, () => void> = {
|
||||
bullet: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
||||
number: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
||||
paragraph: () => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
}
|
||||
},
|
||||
quote: () => $setBlocksType($getSelection(), () => $createQuoteNode()),
|
||||
code: () => {
|
||||
let selection = $getSelection()
|
||||
if (selection !== null) {
|
||||
if (selection.isCollapsed()) {
|
||||
$setBlocksType(selection, () => $createCodeNode())
|
||||
} else {
|
||||
const textContent = selection.getTextContent()
|
||||
const codeNode = $createCodeNode()
|
||||
selection.insertNodes([codeNode])
|
||||
selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertRawText(textContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
h1: () => $setBlocksType($getSelection(), () => $createHeadingNode('h1')),
|
||||
h2: () => $setBlocksType($getSelection(), () => $createHeadingNode('h2')),
|
||||
h3: () => $setBlocksType($getSelection(), () => $createHeadingNode('h3')),
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
set: (value: BlockType) => editor.update($setBlockType[value]),
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function lexicalRichTextTheme(themeCss: Record<string, string>): EditorThemeClasses {
|
||||
const theme = lexicalTheme(themeCss)
|
||||
if (theme.heading) {
|
||||
for (const level of Object.keys(theme.heading)) {
|
||||
const levelTag = level as keyof typeof theme.heading
|
||||
const normalized = normalizeHeadingLevel(levelTag)
|
||||
theme.heading[levelTag] = theme.heading[normalized] ?? 'lexical-unsupported-heading-level'
|
||||
}
|
||||
}
|
||||
return theme
|
||||
}
|
108
app/gui/src/project-view/components/MarkdownEditor/highlight.ts
Normal file
108
app/gui/src/project-view/components/MarkdownEditor/highlight.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { syntaxHighlighting } from '@codemirror/language'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { type Tag, tagHighlighter, tags } from '@lezer/highlight'
|
||||
|
||||
const tagNames: (keyof typeof tags)[] = [
|
||||
'comment',
|
||||
'lineComment',
|
||||
'blockComment',
|
||||
'docComment',
|
||||
'name',
|
||||
'variableName',
|
||||
'typeName',
|
||||
'tagName',
|
||||
'propertyName',
|
||||
'attributeName',
|
||||
'className',
|
||||
'labelName',
|
||||
'namespace',
|
||||
'macroName',
|
||||
'literal',
|
||||
'string',
|
||||
'docString',
|
||||
'character',
|
||||
'attributeValue',
|
||||
'number',
|
||||
'integer',
|
||||
'float',
|
||||
'bool',
|
||||
'regexp',
|
||||
'escape',
|
||||
'color',
|
||||
'url',
|
||||
'keyword',
|
||||
'self',
|
||||
'null',
|
||||
'atom',
|
||||
'unit',
|
||||
'modifier',
|
||||
'operatorKeyword',
|
||||
'controlKeyword',
|
||||
'definitionKeyword',
|
||||
'moduleKeyword',
|
||||
'operator',
|
||||
'derefOperator',
|
||||
'arithmeticOperator',
|
||||
'logicOperator',
|
||||
'bitwiseOperator',
|
||||
'compareOperator',
|
||||
'updateOperator',
|
||||
'definitionOperator',
|
||||
'typeOperator',
|
||||
'controlOperator',
|
||||
'punctuation',
|
||||
'separator',
|
||||
'bracket',
|
||||
'angleBracket',
|
||||
'squareBracket',
|
||||
'paren',
|
||||
'brace',
|
||||
'content',
|
||||
'heading',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'heading5',
|
||||
'heading6',
|
||||
'contentSeparator',
|
||||
'list',
|
||||
'quote',
|
||||
'emphasis',
|
||||
'strong',
|
||||
'link',
|
||||
'monospace',
|
||||
'strikethrough',
|
||||
'inserted',
|
||||
'deleted',
|
||||
'changed',
|
||||
'invalid',
|
||||
'meta',
|
||||
'documentMeta',
|
||||
'annotation',
|
||||
'processingInstruction',
|
||||
]
|
||||
|
||||
/**
|
||||
* Defines an {@link Extension} that applies a highlighting CSS class for any {@link Tag} with a provided class mapping.
|
||||
* @param css A mapping from {@link Tag} names to CSS class names.
|
||||
*/
|
||||
export function highlightStyle(css: Record<string, string>): Extension {
|
||||
const modTagClasses = (mod: keyof typeof tags) =>
|
||||
tagNames.map((tag) => ({
|
||||
tag: (tags[mod] as any)(tags[tag]) as Tag,
|
||||
class: `${tags[mod]}-${tag}`,
|
||||
}))
|
||||
const tagClasses = tagNames.map((tag) => ({ tag: tags[tag] as Tag, class: css[tag] ?? tag }))
|
||||
return syntaxHighlighting(
|
||||
tagHighlighter([
|
||||
...tagClasses,
|
||||
...modTagClasses('definition'),
|
||||
...modTagClasses('constant'),
|
||||
...modTagClasses('function'),
|
||||
...modTagClasses('standard'),
|
||||
...modTagClasses('local'),
|
||||
...modTagClasses('special'),
|
||||
]),
|
||||
)
|
||||
}
|
@ -7,11 +7,11 @@ export type TransformUrlResult = Result<{ url: string; dispose?: () => void }>
|
||||
export type UrlTransformer = (url: string) => Promise<TransformUrlResult>
|
||||
|
||||
export {
|
||||
injectFn as injectLexicalImageUrlTransformer,
|
||||
provideFn as provideLexicalImageUrlTransformer,
|
||||
injectFn as injectDocumentationImageUrlTransformer,
|
||||
provideFn as provideDocumentationImageUrlTransformer,
|
||||
}
|
||||
const { provideFn, injectFn } = createContextStore(
|
||||
'Lexical image URL transformer',
|
||||
'Documentation image URL transformer',
|
||||
(transformUrl: ToValue<UrlTransformer | undefined>) => ({
|
||||
transformUrl: (url: string) => toValue(transformUrl)?.(url),
|
||||
}),
|
||||
@ -59,7 +59,10 @@ export function fetcherUrlTransformer<ResourceLocation>(
|
||||
return Ok({
|
||||
url: result.value.value,
|
||||
dispose: () => {
|
||||
if (!(result.value.refs -= 1)) URL.revokeObjectURL(result.value.value)
|
||||
if (!(result.value.refs -= 1)) {
|
||||
URL.revokeObjectURL(result.value.value)
|
||||
allocatedUrls.delete(uniqueId)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
import type { LexicalPlugin } from '@/components/lexical'
|
||||
import {
|
||||
$handleListInsertParagraph,
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
insertList,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
REMOVE_LIST_COMMAND,
|
||||
removeList,
|
||||
} from '@lexical/list'
|
||||
import { COMMAND_PRIORITY_LOW, INSERT_PARAGRAPH_COMMAND } from 'lexical'
|
||||
|
||||
export const listPlugin: LexicalPlugin = {
|
||||
nodes: [ListItemNode, ListNode],
|
||||
register: (editor) => {
|
||||
editor.registerCommand(
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
() => {
|
||||
insertList(editor, 'number')
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
editor.registerCommand(
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
() => {
|
||||
insertList(editor, 'bullet')
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
editor.registerCommand(
|
||||
REMOVE_LIST_COMMAND,
|
||||
() => {
|
||||
removeList(editor)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
editor.registerCommand(
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
$handleListInsertParagraph,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
},
|
||||
}
|
@ -1,70 +1,9 @@
|
||||
import type { LexicalPlugin } from '@/components/lexical'
|
||||
import { useLexicalStringSync } from '@/components/lexical/sync'
|
||||
import { CodeHighlightNode, CodeNode } from '@lexical/code'
|
||||
import { AutoLinkNode, LinkNode } from '@lexical/link'
|
||||
import { ListItemNode, ListNode } from '@lexical/list'
|
||||
import {
|
||||
$convertFromMarkdownString,
|
||||
$convertToMarkdownString,
|
||||
TRANSFORMERS,
|
||||
registerMarkdownShortcuts,
|
||||
type Transformer,
|
||||
} from '@lexical/markdown'
|
||||
import { HeadingNode, QuoteNode, registerRichText } from '@lexical/rich-text'
|
||||
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
|
||||
import { $setSelection } from 'lexical'
|
||||
import { watch, type Ref } from 'vue'
|
||||
import { markdownDecorators } from '@/components/MarkdownEditor/markdown/decoration'
|
||||
import { markdown } from '@/components/MarkdownEditor/markdown/parse'
|
||||
import type { VueHost } from '@/components/VueComponentHost.vue'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
|
||||
export interface LexicalMarkdownPlugin extends LexicalPlugin {
|
||||
transformers?: Transformer[]
|
||||
/** Markdown extension, with customizations for Enso. */
|
||||
export function ensoMarkdown({ vueHost }: { vueHost: VueHost }): Extension {
|
||||
return [markdown(), markdownDecorators({ vueHost })]
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function markdownPlugin(
|
||||
model: Ref<string>,
|
||||
extensions: LexicalMarkdownPlugin[],
|
||||
): LexicalPlugin[] {
|
||||
const transformers = new Array<Transformer>()
|
||||
for (const extension of extensions) {
|
||||
if (extension?.transformers) transformers.push(...extension.transformers)
|
||||
}
|
||||
transformers.push(...TRANSFORMERS)
|
||||
return [...extensions, baseMarkdownPlugin(transformers), markdownSyncPlugin(model, transformers)]
|
||||
}
|
||||
|
||||
function baseMarkdownPlugin(transformers: Transformer[]): LexicalPlugin {
|
||||
return {
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
QuoteNode,
|
||||
ListItemNode,
|
||||
ListNode,
|
||||
AutoLinkNode,
|
||||
LinkNode,
|
||||
CodeHighlightNode,
|
||||
CodeNode,
|
||||
TableCellNode,
|
||||
TableNode,
|
||||
TableRowNode,
|
||||
],
|
||||
register: (editor) => {
|
||||
registerRichText(editor)
|
||||
registerMarkdownShortcuts(editor, transformers)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const markdownSyncPlugin = (model: Ref<string>, transformers: Transformer[]): LexicalPlugin => ({
|
||||
register: (editor) => {
|
||||
const { content } = useLexicalStringSync(
|
||||
editor,
|
||||
() => $convertToMarkdownString(transformers),
|
||||
(value) => {
|
||||
$convertFromMarkdownString(value, transformers)
|
||||
$setSelection(null)
|
||||
},
|
||||
)
|
||||
watch(model, (newContent) => content.set(newContent), { immediate: true })
|
||||
watch(content.editedContent, (newContent) => (model.value = newContent))
|
||||
},
|
||||
})
|
||||
|
@ -0,0 +1,270 @@
|
||||
import DocumentationImage from '@/components/MarkdownEditor/DocumentationImage.vue'
|
||||
import type { VueHost } from '@/components/VueComponentHost.vue'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { type EditorSelection, type Extension, RangeSetBuilder, type Text } from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
type PluginValue,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
WidgetType,
|
||||
} from '@codemirror/view'
|
||||
import type { SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
|
||||
import { h, markRaw } from 'vue'
|
||||
|
||||
/** Extension applying decorators for Markdown. */
|
||||
export function markdownDecorators({ vueHost }: { vueHost: VueHost }): Extension {
|
||||
const stateDecorator = new TreeStateDecorator(vueHost, [
|
||||
decorateImageWithClass,
|
||||
decorateImageWithRendered,
|
||||
])
|
||||
const stateDecoratorExt = EditorView.decorations.compute(['doc'], (state) =>
|
||||
stateDecorator.decorate(syntaxTree(state), state.doc),
|
||||
)
|
||||
const viewDecoratorExt = ViewPlugin.define(
|
||||
(view) => new TreeViewDecorator(view, vueHost, [decorateLink]),
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
)
|
||||
const cursorDecoratorExt = EditorView.decorations.compute(['selection', 'doc'], (state) =>
|
||||
cursorDecorations(state.selection, state.doc),
|
||||
)
|
||||
return [stateDecoratorExt, viewDecoratorExt, cursorDecoratorExt]
|
||||
}
|
||||
|
||||
interface NodeDecorator {
|
||||
(
|
||||
nodeRef: SyntaxNodeRef,
|
||||
doc: Text,
|
||||
emitDecoration: (from: number, to: number, deco: Decoration) => void,
|
||||
vueHost: VueHost,
|
||||
): void
|
||||
}
|
||||
|
||||
// === Tree state decorator ===
|
||||
|
||||
/** Maintains a set of decorations based on the tree. */
|
||||
class TreeStateDecorator {
|
||||
constructor(
|
||||
private readonly vueHost: VueHost,
|
||||
private readonly nodeDecorators: NodeDecorator[],
|
||||
) {}
|
||||
|
||||
decorate(tree: Tree, doc: Text): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
const emit = (from: number, to: number, value: Decoration) => {
|
||||
builder.add(from, to, value)
|
||||
}
|
||||
tree.iterate({
|
||||
enter: (nodeRef) => {
|
||||
for (const decorator of this.nodeDecorators) decorator(nodeRef, doc, emit, this.vueHost)
|
||||
},
|
||||
})
|
||||
return builder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// === Cursor decorator ===
|
||||
|
||||
function cursorDecorations(selection: EditorSelection, doc: Text): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
for (const range of selection.ranges) {
|
||||
const line = doc.lineAt(range.from)
|
||||
builder.add(
|
||||
line.from,
|
||||
line.from,
|
||||
Decoration.line({
|
||||
class: 'cm-has-cursor',
|
||||
}),
|
||||
)
|
||||
if (range.to != range.from) {
|
||||
// TODO: Add decorations to each line
|
||||
}
|
||||
}
|
||||
return builder.finish()
|
||||
}
|
||||
|
||||
// === Tree view decorator ===
|
||||
|
||||
/** Maintains a set of decorations based on the tree, lazily-constructed for the visible range of the document. */
|
||||
class TreeViewDecorator implements PluginValue {
|
||||
decorations: DecorationSet
|
||||
|
||||
constructor(
|
||||
view: EditorView,
|
||||
private readonly vueHost: VueHost,
|
||||
/**
|
||||
* Functions that construct decorations based on tree. The decorations must not have significant impact on the
|
||||
* height of the document, or scrolling issues would result, because decorations are lazily computed based on the
|
||||
* current viewport.
|
||||
*/
|
||||
private readonly nodeDecorators: NodeDecorator[],
|
||||
) {
|
||||
this.decorations = this.buildDeco(syntaxTree(view.state), view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// TODO
|
||||
// Attaching widgets can change the geometry, so don't re-attach widgets in response to geometry changes.
|
||||
// Reusing unchanged widgets would be a better solution, but this works correctly as long as rendering widgets
|
||||
// within the `visibleRanges` doesn't bring any new content into the `visibleRanges`; in practice this should hold.
|
||||
//if (!update.docChanged && !update.viewportChanged) return
|
||||
if (!update.docChanged) return
|
||||
this.decorations = this.buildDeco(syntaxTree(update.state), update.view)
|
||||
}
|
||||
|
||||
private buildDeco(tree: Tree, view: EditorView) {
|
||||
if (!tree.length) return Decoration.none
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
const doc = view.state.doc
|
||||
const emit = (from: number, to: number, value: Decoration) => {
|
||||
builder.add(from, to, value)
|
||||
}
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (nodeRef) => {
|
||||
for (const decorator of this.nodeDecorators) decorator(nodeRef, doc, emit, this.vueHost)
|
||||
},
|
||||
})
|
||||
}
|
||||
return builder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// === Links ===
|
||||
|
||||
/** Parse a link or image */
|
||||
function parseLinkLike(node: SyntaxNode, doc: Text) {
|
||||
const textOpen = node.firstChild // [ or ![
|
||||
if (!textOpen) return
|
||||
const textClose = textOpen.nextSibling // ]
|
||||
if (!textClose) return
|
||||
const urlOpen = textClose.nextSibling // (
|
||||
// The parser accepts partial links such as `[Missing url]`.
|
||||
if (!urlOpen) return
|
||||
const urlNode = urlOpen.nextSibling
|
||||
// If the URL is empty, this will be the closing 'LinkMark'.
|
||||
if (urlNode?.name !== 'URL') return
|
||||
return {
|
||||
textFrom: textOpen.to,
|
||||
textTo: textClose.from,
|
||||
url: doc.sliceString(urlNode.from, urlNode.to),
|
||||
}
|
||||
}
|
||||
|
||||
function decorateLink(
|
||||
nodeRef: SyntaxNodeRef,
|
||||
doc: Text,
|
||||
emitDecoration: (from: number, to: number, deco: Decoration) => void,
|
||||
) {
|
||||
if (nodeRef.name === 'Link') {
|
||||
const parsed = parseLinkLike(nodeRef.node, doc)
|
||||
if (!parsed) return
|
||||
const { textFrom, textTo, url } = parsed
|
||||
if (textFrom === textTo) return
|
||||
emitDecoration(
|
||||
textFrom,
|
||||
textTo,
|
||||
Decoration.mark({
|
||||
tagName: 'a',
|
||||
attributes: { href: url },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// === Images ===
|
||||
|
||||
function decorateImageWithClass(
|
||||
nodeRef: SyntaxNodeRef,
|
||||
_doc: Text,
|
||||
emitDecoration: (from: number, to: number, deco: Decoration) => void,
|
||||
) {
|
||||
if (nodeRef.name === 'Image') {
|
||||
emitDecoration(
|
||||
nodeRef.from,
|
||||
nodeRef.to,
|
||||
Decoration.mark({
|
||||
class: 'cm-image-markup',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function decorateImageWithRendered(
|
||||
nodeRef: SyntaxNodeRef,
|
||||
doc: Text,
|
||||
emitDecoration: (from: number, to: number, deco: Decoration) => void,
|
||||
vueHost: VueHost,
|
||||
) {
|
||||
if (nodeRef.name === 'Image') {
|
||||
const parsed = parseLinkLike(nodeRef.node, doc)
|
||||
if (!parsed) return
|
||||
const { textFrom, textTo, url } = parsed
|
||||
const text = doc.sliceString(textFrom, textTo)
|
||||
const widget = new ImageWidget({ alt: text, src: url }, vueHost)
|
||||
emitDecoration(
|
||||
nodeRef.to,
|
||||
nodeRef.to,
|
||||
Decoration.widget({
|
||||
widget,
|
||||
// Ensure the cursor is drawn relative to the content before the widget.
|
||||
// If it is drawn relative to the widget, it will be hidden when the widget is hidden (i.e. during editing).
|
||||
side: 1,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ImageWidget extends WidgetType {
|
||||
private container: HTMLElement | undefined
|
||||
private vueHostRegistration: { unregister: () => void } | undefined
|
||||
|
||||
constructor(
|
||||
private readonly props: {
|
||||
readonly alt: string
|
||||
readonly src: string
|
||||
},
|
||||
private readonly vueHost: VueHost,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override get estimatedHeight() {
|
||||
return -1
|
||||
}
|
||||
|
||||
override eq(other: WidgetType) {
|
||||
return (
|
||||
other instanceof ImageWidget &&
|
||||
other.props.src == this.props.src &&
|
||||
other.props.alt == this.props.alt
|
||||
)
|
||||
}
|
||||
|
||||
override toDOM(): HTMLElement {
|
||||
if (!this.container) {
|
||||
const container = markRaw(document.createElement('span'))
|
||||
container.className = 'cm-image-rendered'
|
||||
this.vueHostRegistration = this.vueHost.register(
|
||||
h(DocumentationImage, {
|
||||
src: this.props.src,
|
||||
alt: this.props.alt,
|
||||
}),
|
||||
container,
|
||||
)
|
||||
this.container = container
|
||||
}
|
||||
return this.container
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.vueHostRegistration?.unregister()
|
||||
this.container = undefined
|
||||
}
|
||||
}
|
35
app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts
vendored
Normal file
35
app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
/** @file Private lezer-markdown symbols used by lezer-markdown parsers we have customized versions of. */
|
||||
|
||||
import { Tree, TreeBuffer } from '@lezer/common'
|
||||
import { Element } from '@lezer/markdown'
|
||||
|
||||
declare module '@lezer/markdown' {
|
||||
export interface BlockContext {
|
||||
block: CompositeBlock
|
||||
stack: CompositeBlock[]
|
||||
readonly buffer: Buffer
|
||||
|
||||
addNode: (block: number | Tree, from: number, to?: number) => void
|
||||
startContext: (type: number, start: number, value?: number) => void
|
||||
}
|
||||
|
||||
export interface CompositeBlock {
|
||||
readonly type: number
|
||||
// Used for indentation in list items, markup character in lists
|
||||
readonly value: number
|
||||
readonly from: number
|
||||
readonly hash: number
|
||||
end: number
|
||||
readonly children: (Tree | TreeBuffer)[]
|
||||
readonly positions: number[]
|
||||
}
|
||||
|
||||
export interface Buffer {
|
||||
content: number[]
|
||||
nodes: Tree[]
|
||||
|
||||
write: (type: number, from: number, to: number, children?: number) => Buffer
|
||||
writeElements: (elts: readonly Element[], offset?: number) => Buffer
|
||||
finish: (type: number, length: number) => Tree
|
||||
}
|
||||
}
|
@ -0,0 +1,248 @@
|
||||
import { markdown as baseMarkdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import type { Tree } from '@lezer/common'
|
||||
import type { BlockContext, BlockParser, Line, MarkdownParser, NodeSpec } from '@lezer/markdown'
|
||||
import { Element } from '@lezer/markdown'
|
||||
import { assertDefined } from 'ydoc-shared/util/assert'
|
||||
|
||||
/**
|
||||
* Enso Markdown extension. Differences from CodeMirror's base Markdown extension:
|
||||
* - It defines the flavor of Markdown supported in Enso documentation. Currently, this is mostly CommonMark except we
|
||||
* don't support setext headings. Planned features include support for some GFM extensions.
|
||||
* - Many of the parsers differ from the `@lezer/markdown` parsers in their treatment of whitespace, in order to support
|
||||
* a rendering mode where markup (and some associated spacing) is hidden.
|
||||
*/
|
||||
export function markdown(): Extension {
|
||||
return baseMarkdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [
|
||||
{
|
||||
parseBlock: [headerParser, bulletList, orderedList, blockquoteParser, disableSetextHeading],
|
||||
defineNodes: [blockquoteNode],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function getType({ parser }: { parser: MarkdownParser }, name: string) {
|
||||
const ty = parser.nodeSet.types.find((ty) => ty.name === name)
|
||||
assertDefined(ty)
|
||||
return ty.id
|
||||
}
|
||||
|
||||
/** Parser override to include the space in the delimiter. */
|
||||
const headerParser: BlockParser = {
|
||||
name: 'ATXHeading',
|
||||
parse: (cx, line) => {
|
||||
let size = isAtxHeading(line)
|
||||
if (size < 0) return false
|
||||
const level = size
|
||||
// If the character after the hashes is a space, treat it as part of the `HeaderMark`.
|
||||
if (isSpace(line.text.charCodeAt(size))) size += 1
|
||||
const off = line.pos
|
||||
const from = cx.lineStart + off
|
||||
// Trailing spaces at EOL
|
||||
const endOfSpace = skipSpaceBack(line.text, line.text.length, off)
|
||||
let after = endOfSpace
|
||||
// Trailing sequence of # (before EOL spaces)
|
||||
while (after > off && line.text.charCodeAt(after - 1) == line.next) after--
|
||||
if (after == endOfSpace || after == off || !isSpace(line.text.charCodeAt(after - 1)))
|
||||
after = line.text.length
|
||||
const headerMark = getType(cx, 'HeaderMark')
|
||||
const buf = cx.buffer
|
||||
.write(headerMark, 0, size)
|
||||
.writeElements(cx.parser.parseInline(line.text.slice(off + size, after), from + size), -from)
|
||||
if (after < line.text.length) buf.write(headerMark, after - off, endOfSpace - off)
|
||||
const node = buf.finish(getType(cx, `ATXHeading${level}`), line.text.length - off)
|
||||
cx.nextLine()
|
||||
cx.addNode(node, from)
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
/** Parser override to include the space in the delimiter. */
|
||||
const bulletList: BlockParser = {
|
||||
name: 'BulletList',
|
||||
parse: (cx, line) => {
|
||||
const size = isBulletList(line, cx, false)
|
||||
if (size < 0) return false
|
||||
const length = size + (isSpace(line.text.charCodeAt(line.pos + 1)) ? 1 : 0)
|
||||
const bulletList = getType(cx, 'BulletList')
|
||||
if (cx.block.type != bulletList) cx.startContext(bulletList, line.basePos, line.next)
|
||||
const newBase = getListIndent(line, line.pos + 1)
|
||||
cx.startContext(getType(cx, 'ListItem'), line.basePos, newBase - line.baseIndent)
|
||||
cx.addNode(getType(cx, 'ListMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + length)
|
||||
line.moveBaseColumn(newBase)
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
/** Parser override to include the space in the delimiter. */
|
||||
const orderedList: BlockParser = {
|
||||
name: 'OrderedList',
|
||||
parse: (cx, line) => {
|
||||
const size = isOrderedList(line, cx, false)
|
||||
if (size < 0) return false
|
||||
const orderedList = getType(cx, 'OrderedList')
|
||||
if (cx.block.type != orderedList)
|
||||
cx.startContext(orderedList, line.basePos, line.text.charCodeAt(line.pos + size - 1))
|
||||
const newBase = getListIndent(line, line.pos + size)
|
||||
cx.startContext(getType(cx, 'ListItem'), line.basePos, newBase - line.baseIndent)
|
||||
cx.addNode(getType(cx, 'ListMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + size)
|
||||
line.moveBaseColumn(newBase)
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const ENSO_BLOCKQUOTE_TYPE = 'EnsoBlockquote'
|
||||
|
||||
/** Parser override to include the space in the delimiter. */
|
||||
const blockquoteParser: BlockParser = {
|
||||
name: ENSO_BLOCKQUOTE_TYPE,
|
||||
parse: (cx, line) => {
|
||||
const size = isBlockquote(line)
|
||||
if (size < 0) return false
|
||||
const type = getType(cx, ENSO_BLOCKQUOTE_TYPE)
|
||||
cx.startContext(type, line.pos)
|
||||
cx.addNode(getType(cx, 'QuoteMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + size)
|
||||
line.moveBase(line.pos + size)
|
||||
return null
|
||||
},
|
||||
before: 'Blockquote',
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces setext heading parser with a parser that never matches.
|
||||
*
|
||||
* When starting a bulleted list, the `SetextHeading` parser can match when a `-` has been typed and a following space
|
||||
* hasn't been entered yet; the resulting style changes are distracting. To prevent this, we don't support setext
|
||||
* headings; ATX headings seem to be much more popular anyway.
|
||||
*/
|
||||
const disableSetextHeading: BlockParser = {
|
||||
name: 'SetextHeading',
|
||||
parse: () => false,
|
||||
}
|
||||
|
||||
const blockquoteNode: NodeSpec = {
|
||||
name: ENSO_BLOCKQUOTE_TYPE,
|
||||
block: true,
|
||||
composite: (cx, line) => {
|
||||
if (line.next != 62 /* '>' */) return false
|
||||
const size = isSpace(line.text.charCodeAt(line.pos + 1)) ? 2 : 1
|
||||
line.addMarker(
|
||||
elt(getType(cx, 'QuoteMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + size),
|
||||
)
|
||||
line.moveBase(line.pos + size)
|
||||
//bl.end = cx.lineStart + line.text.length
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
function elt(type: number, from: number, to: number): Element {
|
||||
return new (Element as any)(type, from, to)
|
||||
}
|
||||
|
||||
function isBlockquote(line: Line) {
|
||||
return (
|
||||
line.next != 62 /* '>' */ ? -1
|
||||
: line.text.charCodeAt(line.pos + 1) == 32 ? 2
|
||||
: 1
|
||||
)
|
||||
}
|
||||
|
||||
function isBulletList(line: Line, cx: BlockContext, breaking: boolean) {
|
||||
return (
|
||||
(line.next == 45 || line.next == 43 || line.next == 42) /* '-+*' */ &&
|
||||
(line.pos == line.text.length - 1 || isSpace(line.text.charCodeAt(line.pos + 1))) &&
|
||||
(!breaking || inList(cx, 'BulletList') || line.skipSpace(line.pos + 2) < line.text.length)
|
||||
) ?
|
||||
1
|
||||
: -1
|
||||
}
|
||||
|
||||
function isOrderedList(line: Line, cx: BlockContext, breaking: boolean) {
|
||||
let pos = line.pos
|
||||
let next = line.next
|
||||
for (;;) {
|
||||
if (next >= 48 && next <= 57 /* '0-9' */) pos++
|
||||
else break
|
||||
if (pos == line.text.length) return -1
|
||||
next = line.text.charCodeAt(pos)
|
||||
}
|
||||
if (
|
||||
pos == line.pos ||
|
||||
pos > line.pos + 9 ||
|
||||
(next != 46 && next != 41) /* '.)' */ ||
|
||||
(pos < line.text.length - 1 && !isSpace(line.text.charCodeAt(pos + 1))) ||
|
||||
(breaking &&
|
||||
!inList(cx, 'OrderedList') &&
|
||||
(line.skipSpace(pos + 1) == line.text.length ||
|
||||
pos > line.pos + 1 ||
|
||||
line.next != 49)) /* '1' */
|
||||
)
|
||||
return -1
|
||||
return pos + 1 - line.pos
|
||||
}
|
||||
|
||||
function inList(cx: BlockContext, typeName: string) {
|
||||
const type = getType(cx, typeName)
|
||||
for (let i = cx.stack.length - 1; i >= 0; i--) if (cx.stack[i]!.type == type) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function getListIndent(line: Line, pos: number) {
|
||||
const indentAfter = line.countIndent(pos, line.pos, line.indent)
|
||||
const indented = line.countIndent(line.skipSpace(pos), pos, indentAfter)
|
||||
return indented >= indentAfter + 5 ? indentAfter + 1 : indented
|
||||
}
|
||||
|
||||
// === Debugging ===
|
||||
|
||||
/** Represents the structure of a @{link Tree} in a JSON-compatible format. */
|
||||
export interface DebugTree {
|
||||
/** The name of the {@link NodeType} */
|
||||
name: string
|
||||
children: DebugTree[]
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/** @returns A debug representation of the provided {@link Tree} */
|
||||
export function debugTree(tree: Tree): DebugTree {
|
||||
const cursor = tree.cursor()
|
||||
let current: DebugTree[] = []
|
||||
const stack: DebugTree[][] = []
|
||||
cursor.iterate(
|
||||
(node) => {
|
||||
const children: DebugTree[] = []
|
||||
current.push({
|
||||
name: node.name,
|
||||
children,
|
||||
})
|
||||
stack.push(current)
|
||||
current = children
|
||||
},
|
||||
() => (current = stack.pop()!),
|
||||
)
|
||||
return current[0]!
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
function skipSpaceBack(line: string, i: number, to: number) {
|
||||
while (i > to && isSpace(line.charCodeAt(i - 1))) i--
|
||||
return i
|
||||
}
|
||||
|
||||
/** Returns the number of hash marks at the beginning of the line, or -1 if it is not in the range [1, 6] */
|
||||
function isAtxHeading(line: Line) {
|
||||
if (line.next != 35 /* '#' */) return -1
|
||||
let pos = line.pos + 1
|
||||
while (pos < line.text.length && line.text.charCodeAt(pos) == 35) pos++
|
||||
if (pos < line.text.length && line.text.charCodeAt(pos) != 32) return -1
|
||||
const size = pos - line.pos
|
||||
return size > 6 ? -1 : size
|
||||
}
|
||||
|
||||
function isSpace(ch: number) {
|
||||
return ch == 32 || ch == 9 || ch == 10 || ch == 13
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
/*
|
||||
Lexical theme. Class names are derived from the `LexicalThemeClasses` type from `lexical`, with the hierarchy flattened
|
||||
using `_` to separate levels. See the `lexicalTheme` function in `lexical/formatting.ts`.
|
||||
*/
|
||||
|
||||
.heading_h1 {
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.heading_h2 {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.heading_h3,
|
||||
.heading_h4,
|
||||
.heading_h5,
|
||||
.heading_h6 {
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.text_strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.text_italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.text_bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.quote {
|
||||
margin-left: 0.2em;
|
||||
border-left: 0.3em solid #ccc;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.list_ol {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
.list_ul {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.image > img {
|
||||
display: inline;
|
||||
margin: 0 0.1em;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #555;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||
import type { SelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||
import { ref } from 'vue'
|
||||
|
||||
type Key = number | string | symbol
|
||||
|
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
/** @file A dropdown menu supporting the pattern of selecting a single entry from a list. */
|
||||
import DropdownMenu from '@/components/DropdownMenu.vue'
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import { TextSelectionMenuOption } from '@/components/visualizations/toolbar'
|
||||
import { ref } from 'vue'
|
||||
|
||||
type Key = number | string | symbol
|
||||
const selected = defineModel<Key>({ required: true })
|
||||
const _props = defineProps<{
|
||||
options: Record<Key, TextSelectionMenuOption>
|
||||
title?: string | undefined
|
||||
alwaysShowArrow?: boolean
|
||||
heading: string
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-model:open="open" :title="title" :alwaysShowArrow="alwaysShowArrow">
|
||||
<template #button>
|
||||
<template v-if="options[selected]">
|
||||
<div v-if="heading" v-text="heading" />
|
||||
<div v-if="options[selected]?.label" class="iconLabel" v-text="options[selected]?.label" />
|
||||
</template>
|
||||
</template>
|
||||
<template #entries>
|
||||
<MenuButton
|
||||
v-for="[key, option] in Object.entries(options)"
|
||||
:key="key"
|
||||
:title="option.title"
|
||||
:modelValue="selected === key"
|
||||
@update:modelValue="$event && (selected = key)"
|
||||
@click="open = false"
|
||||
>
|
||||
<div v-if="option.label" class="iconLabel" v-text="option.label" />
|
||||
</MenuButton>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.MenuButton {
|
||||
margin: -4px;
|
||||
justify-content: unset;
|
||||
}
|
||||
|
||||
.iconLabel {
|
||||
margin-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
</style>
|
@ -8,7 +8,7 @@
|
||||
|
||||
import MenuButton from '@/components/MenuButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { URLString } from '@/util/data/urlString'
|
||||
import type { URLString } from '@/util/data/urlString'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
|
||||
const toggledOn = defineModel<boolean>({ default: false })
|
||||
|
39
app/gui/src/project-view/components/VueComponentHost.vue
Normal file
39
app/gui/src/project-view/components/VueComponentHost.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useObjectId } from 'enso-common/src/utilities/data/object'
|
||||
import { type Component, reactive } from 'vue'
|
||||
|
||||
const teleportations = reactive(new Map<Component, HTMLElement>())
|
||||
|
||||
defineExpose({
|
||||
register: (component: Component, element: HTMLElement) => {
|
||||
teleportations.set(component, element)
|
||||
return { unregister: () => teleportations.delete(component) }
|
||||
},
|
||||
} satisfies VueHost)
|
||||
|
||||
const { objectId } = useObjectId()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Supports creation of Vue Components within a particular Vue context.
|
||||
*
|
||||
* This enables creating Vue Components from code run outside any Vue context by APIs that render custom HTML content
|
||||
* but aren't Vue-aware.
|
||||
*/
|
||||
export interface VueHost {
|
||||
/**
|
||||
* Request the given component to begin being rendered as a child of the specified HTML element. The returned
|
||||
* `unregister` function should be called when the component should no longer be rendered.
|
||||
*/
|
||||
register: (component: Component, element: HTMLElement) => { unregister: () => void }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="[component, slot] in teleportations.entries()" :key="objectId(component)">
|
||||
<Teleport :to="slot">
|
||||
<component :is="component" />
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
@ -1,15 +1,9 @@
|
||||
import { documentationEditorBindings } from '@/bindings'
|
||||
import { IMAGE } from '@/components/MarkdownEditor/ImagePlugin'
|
||||
import { $isImageNode } from '@/components/MarkdownEditor/ImagePlugin/imageNode'
|
||||
import type { LexicalMarkdownPlugin } from '@/components/MarkdownEditor/markdown'
|
||||
import type { LexicalPlugin } from '@/components/lexical'
|
||||
import { $createLinkNode, $isLinkNode, AutoLinkNode, LinkNode } from '@lexical/link'
|
||||
import type { Transformer } from '@lexical/markdown'
|
||||
import { AutoLinkNode, LinkNode } from '@lexical/link'
|
||||
import { $getNearestNodeOfType } from '@lexical/utils'
|
||||
import {
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isTextNode,
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
@ -27,51 +21,6 @@ const EMAIL_REGEX =
|
||||
|
||||
export const __TEST = { URL_REGEX, EMAIL_REGEX }
|
||||
|
||||
const LINK: Transformer = {
|
||||
dependencies: [LinkNode],
|
||||
export: (node, exportChildren, exportFormat) => {
|
||||
if (!$isLinkNode(node)) {
|
||||
return null
|
||||
}
|
||||
const title = node.getTitle()
|
||||
const linkContent =
|
||||
title ?
|
||||
`[${node.getTextContent()}](${node.getURL()} "${title}")`
|
||||
: `[${node.getTextContent()}](${node.getURL()})`
|
||||
const firstChild = node.getFirstChild()
|
||||
// Add text styles only if link has single text node inside. If it's more
|
||||
// then one we ignore it as markdown does not support nested styles for links
|
||||
if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) {
|
||||
return exportFormat(firstChild, linkContent)
|
||||
} else if (node.getChildrenSize() === 1 && $isImageNode(firstChild)) {
|
||||
// Images sometimes happen to be inside links (when importing nodes from HTML).
|
||||
// The link is not important for us (this type of layout is not supported in markdown),
|
||||
// but we want to display the image.
|
||||
return IMAGE.export(firstChild, exportChildren, exportFormat)
|
||||
} else {
|
||||
return linkContent
|
||||
}
|
||||
},
|
||||
importRegExp: /(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/,
|
||||
regExp: /(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/,
|
||||
replace: (textNode, match) => {
|
||||
const [, linkText, linkUrl, linkTitle] = match
|
||||
if (linkText && linkUrl) {
|
||||
const linkNode = $createLinkNode(linkUrl, {
|
||||
title: linkTitle ?? null,
|
||||
rel: 'nofollow',
|
||||
target: '_blank',
|
||||
})
|
||||
const linkTextNode = $createTextNode(linkText)
|
||||
linkTextNode.setFormat(textNode.getFormat())
|
||||
linkNode.append(linkTextNode)
|
||||
textNode.replace(linkNode)
|
||||
}
|
||||
},
|
||||
trigger: ')',
|
||||
type: 'text-match',
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function $getSelectedLinkNode() {
|
||||
const selection = $getSelection()
|
||||
@ -87,17 +36,6 @@ export function $getSelectedLinkNode() {
|
||||
}
|
||||
}
|
||||
|
||||
const linkClickHandler = documentationEditorBindings.handler({
|
||||
openLink() {
|
||||
const link = $getSelectedLinkNode()
|
||||
if (link instanceof LinkNode) {
|
||||
window.open(link.getURL(), '_blank')?.focus()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
const autoLinkClickHandler = documentationEditorBindings.handler({
|
||||
openLink() {
|
||||
const link = $getSelectedLinkNode()
|
||||
@ -109,18 +47,6 @@ const autoLinkClickHandler = documentationEditorBindings.handler({
|
||||
},
|
||||
})
|
||||
|
||||
export const linkPlugin: LexicalMarkdownPlugin = {
|
||||
nodes: [LinkNode],
|
||||
transformers: [LINK],
|
||||
register(editor: LexicalEditor): void {
|
||||
editor.registerCommand(
|
||||
CLICK_COMMAND,
|
||||
(event) => linkClickHandler(event),
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const autoLinkPlugin: LexicalPlugin = {
|
||||
nodes: [AutoLinkNode],
|
||||
register(editor: LexicalEditor): void {
|
||||
|
@ -47,7 +47,7 @@ import {
|
||||
tsvTableToEnsoExpression,
|
||||
writeClipboard,
|
||||
} from '@/components/GraphEditor/clipboard'
|
||||
import { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||
import type { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||
import { useAutoBlur } from '@/util/autoBlur'
|
||||
import type {
|
||||
CellEditingStartedEvent,
|
||||
@ -66,6 +66,8 @@ import type {
|
||||
RowHeightParams,
|
||||
SortChangedEvent,
|
||||
} from 'ag-grid-enterprise'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { LINE_BOUNDARIES } from 'enso-common/src/utilities/data/string'
|
||||
import { type ComponentInstance, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = 22
|
||||
@ -82,6 +84,8 @@ const _props = defineProps<{
|
||||
suppressMoveWhenColumnDragging?: boolean
|
||||
textFormatOption?: TextFormatOptions
|
||||
processDataFromClipboard?: (params: ProcessDataFromClipboardParams<TData>) => string[][] | null
|
||||
pinnedTopRowData?: TData[]
|
||||
pinnedRowHeightMultiplier?: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
cellEditingStarted: [event: CellEditingStartedEvent]
|
||||
@ -104,6 +108,10 @@ function onGridReady(event: GridReadyEvent<TData>) {
|
||||
}
|
||||
|
||||
function getRowHeight(params: RowHeightParams): number {
|
||||
if (params.node.rowPinned === 'top') {
|
||||
return DEFAULT_ROW_HEIGHT * (_props.pinnedRowHeightMultiplier ?? 2)
|
||||
}
|
||||
|
||||
if (_props.textFormatOption === 'off') {
|
||||
return DEFAULT_ROW_HEIGHT
|
||||
}
|
||||
@ -114,14 +122,11 @@ function getRowHeight(params: RowHeightParams): number {
|
||||
return DEFAULT_ROW_HEIGHT
|
||||
}
|
||||
|
||||
const returnCharsCount = textValues.map((text: string) => {
|
||||
const crlfCount = (text.match(/\r\n/g) || []).length
|
||||
const crCount = (text.match(/\r/g) || []).length
|
||||
const lfCount = (text.match(/\n/g) || []).length
|
||||
return crCount + lfCount - crlfCount
|
||||
})
|
||||
const returnCharsCount = iter.map(textValues, (text) =>
|
||||
iter.count(text.matchAll(LINE_BOUNDARIES)),
|
||||
)
|
||||
|
||||
const maxReturnCharsCount = Math.max(...returnCharsCount)
|
||||
const maxReturnCharsCount = iter.reduce(returnCharsCount, Math.max, 0)
|
||||
return (maxReturnCharsCount + 1) * DEFAULT_ROW_HEIGHT
|
||||
}
|
||||
|
||||
@ -269,6 +274,7 @@ const { AgGridVue } = await import('ag-grid-vue3')
|
||||
:suppressDragLeaveHidesColumns="suppressDragLeaveHidesColumns"
|
||||
:suppressMoveWhenColumnDragging="suppressMoveWhenColumnDragging"
|
||||
:processDataFromClipboard="processDataFromClipboard"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
@gridReady="onGridReady"
|
||||
@firstDataRendered="updateColumnWidths"
|
||||
@rowDataUpdated="updateColumnWidths($event), emit('rowDataUpdated', $event)"
|
||||
|
@ -4,11 +4,7 @@ export const inputType = 'Any'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
const props = defineProps<{ data: { name: string; error: Error } }>()
|
||||
|
||||
watchEffect(() => console.error(props.data.error))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -304,7 +304,6 @@ const yLabelLeft = computed(
|
||||
getTextWidthBySizeAndFamily(data.value.axis.y.label, LABEL_FONT_STYLE) / 2,
|
||||
)
|
||||
const yLabelTop = computed(() => -margin.value.left + 15)
|
||||
const showYLabelText = computed(() => !data.value.is_multi_series)
|
||||
const xTickFormat = computed(() => {
|
||||
switch (data.value.x_value_type) {
|
||||
case 'Time':
|
||||
@ -315,6 +314,22 @@ const xTickFormat = computed(() => {
|
||||
return '%d/%m/%Y %H:%M:%S'
|
||||
}
|
||||
})
|
||||
const seriesLabels = computed(() =>
|
||||
Object.keys(data.value.axis)
|
||||
.filter((s) => s != 'x')
|
||||
.map((s) => {
|
||||
return data.value.axis[s as keyof AxesConfiguration].label
|
||||
}),
|
||||
)
|
||||
|
||||
const yLabelText = computed(() => {
|
||||
if (!data.value.is_multi_series) {
|
||||
return data.value.axis.y.label
|
||||
}
|
||||
if (yAxisSelected.value) {
|
||||
return yAxisSelected.value === 'none' ? null : yAxisSelected.value
|
||||
} else return null
|
||||
})
|
||||
const isUsingIndexForX = computed(() => data.value.axis.x.label === 'index')
|
||||
|
||||
watchEffect(() => {
|
||||
@ -732,26 +747,21 @@ watchPostEffect(() => {
|
||||
|
||||
watchPostEffect(() => {
|
||||
if (data.value.is_multi_series) {
|
||||
const seriesLabels = Object.keys(data.value.axis)
|
||||
.filter((s) => s != 'x')
|
||||
.map((s) => {
|
||||
return data.value.axis[s as keyof AxesConfiguration].label
|
||||
})
|
||||
const formatLabel = (string: string) =>
|
||||
string.length > 10 ? `${string.substr(0, 10)}...` : string
|
||||
|
||||
const color = d3
|
||||
.scaleOrdinal<string>()
|
||||
.domain(seriesLabels)
|
||||
.domain(seriesLabels.value)
|
||||
.range(d3.schemeCategory10)
|
||||
.domain(seriesLabels)
|
||||
.domain(seriesLabels.value)
|
||||
|
||||
d3Legend.value.selectAll('circle').remove()
|
||||
d3Legend.value.selectAll('text').remove()
|
||||
|
||||
d3Legend.value
|
||||
.selectAll('dots')
|
||||
.data(seriesLabels)
|
||||
.data(seriesLabels.value)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('cx', function (d, i) {
|
||||
@ -763,7 +773,7 @@ watchPostEffect(() => {
|
||||
|
||||
d3Legend.value
|
||||
.selectAll('labels')
|
||||
.data(seriesLabels)
|
||||
.data(seriesLabels.value)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('x', function (d, i) {
|
||||
@ -844,6 +854,17 @@ function zoomToSelected(override?: boolean) {
|
||||
|
||||
useEvent(document, 'keydown', bindings.handler({ zoomToSelected: () => zoomToSelected() }))
|
||||
|
||||
const yAxisSelected = ref('none')
|
||||
const makeSeriesLabelOptions = () => {
|
||||
const seriesOptions: { [key: string]: { label: string } } = {}
|
||||
seriesLabels.value.forEach((label, index) => {
|
||||
seriesOptions[label] = {
|
||||
label: label,
|
||||
}
|
||||
})
|
||||
return seriesOptions
|
||||
}
|
||||
|
||||
config.setToolbar([
|
||||
{
|
||||
icon: 'select',
|
||||
@ -867,6 +888,18 @@ config.setToolbar([
|
||||
disabled: () => !createNewFilterNodeEnabled.value,
|
||||
onClick: createNewFilterNode,
|
||||
},
|
||||
{
|
||||
type: 'textSelectionMenu',
|
||||
selectedTextOption: yAxisSelected,
|
||||
title: 'Choose Y Axis Label',
|
||||
heading: 'Y Axis Label: ',
|
||||
options: {
|
||||
none: {
|
||||
label: 'No Label',
|
||||
},
|
||||
...makeSeriesLabelOptions(),
|
||||
},
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
@ -894,12 +927,11 @@ config.setToolbar([
|
||||
v-text="data.axis.x.label"
|
||||
></text>
|
||||
<text
|
||||
v-if="showYLabelText"
|
||||
class="label label-y"
|
||||
text-anchor="end"
|
||||
:x="yLabelLeft"
|
||||
:y="yLabelTop"
|
||||
v-text="data.axis.y.label"
|
||||
v-text="yLabelText"
|
||||
></text>
|
||||
<g ref="pointsNode" clip-path="url(#clip)"></g>
|
||||
<g ref="zoomNode" class="zoom" :width="boxWidth" :height="boxHeight" fill="none">
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import icons from '@/assets/icons.svg'
|
||||
import AgGridTableView, { commonContextMenuActions } from '@/components/shared/AgGridTableView.vue'
|
||||
import { SortModel, useTableVizToolbar } from '@/components/visualizations/tableVizToolbar'
|
||||
import { useTableVizToolbar, type SortModel } from '@/components/visualizations/tableVizToolbar'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
CellClickedEvent,
|
||||
ColDef,
|
||||
ICellRendererParams,
|
||||
ITooltipParams,
|
||||
SortChangedEvent,
|
||||
} from 'ag-grid-enterprise'
|
||||
import { computed, onMounted, ref, shallowRef, watchEffect, type Ref } from 'vue'
|
||||
@ -79,6 +80,12 @@ interface UnknownTable {
|
||||
get_child_node_action: string
|
||||
get_child_node_link_name: string
|
||||
link_value_type: string
|
||||
data_quality_pairs?: DataQualityPairs
|
||||
}
|
||||
|
||||
interface DataQualityPairs {
|
||||
number_of_nothing: number[]
|
||||
number_of_whitespace: number[]
|
||||
}
|
||||
|
||||
export type TextFormatOptions = 'full' | 'partial' | 'off'
|
||||
@ -118,7 +125,9 @@ const defaultColDef: Ref<ColDef> = ref({
|
||||
filter: true,
|
||||
resizable: true,
|
||||
minWidth: 25,
|
||||
cellRenderer: cellRenderer,
|
||||
cellRenderer: (params: ICellRendererParams) => {
|
||||
return params.node.rowPinned === 'top' ? customCellRenderer(params) : cellRenderer(params)
|
||||
},
|
||||
cellClass: cellClass,
|
||||
contextMenuItems: [commonContextMenuActions.copy, 'copyWithHeaders', 'separator', 'export'],
|
||||
} satisfies ColDef)
|
||||
@ -142,6 +151,47 @@ const selectableRowLimits = computed(() => {
|
||||
return defaults
|
||||
})
|
||||
|
||||
const pinnedTopRowData = computed(() => {
|
||||
if (typeof props.data === 'object' && 'data_quality_pairs' in props.data) {
|
||||
const data_ = props.data
|
||||
const headers = data_.header
|
||||
const numberOfNothing = data_.data_quality_pairs!.number_of_nothing
|
||||
const numberOfWhitespace = data_.data_quality_pairs!.number_of_whitespace
|
||||
const total = data_.all_rows_count as number
|
||||
if (headers?.length) {
|
||||
const pairs: Record<string, string> = headers.reduce(
|
||||
(obj: any, key: string, index: number) => {
|
||||
obj[key] = {
|
||||
numberOfNothing: numberOfNothing[index],
|
||||
numberOfWhitespace: numberOfWhitespace[index],
|
||||
total,
|
||||
}
|
||||
return obj
|
||||
},
|
||||
{},
|
||||
)
|
||||
return [{ [INDEX_FIELD_NAME]: 'Data Quality', ...pairs }]
|
||||
}
|
||||
return [
|
||||
{
|
||||
[INDEX_FIELD_NAME]: 'Data Quality',
|
||||
Value: {
|
||||
numberOfNothing: numberOfNothing[0] ?? null,
|
||||
numberOfWhitespace: numberOfWhitespace[0] ?? null,
|
||||
total,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const pinnedRowHeight = computed(() => {
|
||||
const valueTypes =
|
||||
typeof props.data === 'object' && 'value_type' in props.data ? props.data.value_type : []
|
||||
return valueTypes.some((t) => t.constructor === 'Char' || t.constructor === 'Mixed') ? 2 : 1
|
||||
})
|
||||
|
||||
const newNodeSelectorValues = computed(() => {
|
||||
let tooltipValue
|
||||
let headerName
|
||||
@ -284,6 +334,39 @@ function cellClass(params: CellClassParams) {
|
||||
return null
|
||||
}
|
||||
|
||||
const createVisual = (value: number) => {
|
||||
let color
|
||||
if (value < 33) {
|
||||
color = 'green'
|
||||
} else if (value < 66) {
|
||||
color = 'orange'
|
||||
} else {
|
||||
color = 'red'
|
||||
}
|
||||
return `
|
||||
<div style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: ${color}; margin-left: 5px;"></div>
|
||||
`
|
||||
}
|
||||
|
||||
const customCellRenderer = (params: ICellRendererParams) => {
|
||||
if (params.node.rowPinned === 'top') {
|
||||
const nothingPerecent = (params.value.numberOfNothing / params.value.total) * 100
|
||||
const wsPerecent = (params.value.numberOfWhitespace / params.value.total) * 100
|
||||
const nothingVisibility = params.value.numberOfNothing === null ? 'hidden' : 'visible'
|
||||
const whitespaceVisibility = params.value.numberOfWhitespace === null ? 'hidden' : 'visible'
|
||||
|
||||
return `<div>
|
||||
<div style="visibility:${nothingVisibility};">
|
||||
Nulls/Nothing: ${nothingPerecent.toFixed(2)}% ${createVisual(nothingPerecent)}
|
||||
</div>
|
||||
<div style="visibility:${whitespaceVisibility};">
|
||||
Trailing/Leading Whitespace: ${wsPerecent.toFixed(2)}% ${createVisual(wsPerecent)}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function cellRenderer(params: ICellRendererParams) {
|
||||
// Convert's the value into a display string.
|
||||
if (params.value === null) return '<span style="color:grey; font-style: italic;">Nothing</span>'
|
||||
@ -404,10 +487,14 @@ function toLinkField(fieldName: string, getChildAction?: string, castValueTypes?
|
||||
newNodeSelectorValues.value.headerName ? newNodeSelectorValues.value.headerName : fieldName,
|
||||
field: fieldName,
|
||||
onCellDoubleClicked: (params) => createNode(params, fieldName, getChildAction, castValueTypes),
|
||||
tooltipValueGetter: () => {
|
||||
return `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate component`
|
||||
},
|
||||
cellRenderer: (params: any) => `<div class='link'> ${params.value} </div>`,
|
||||
tooltipValueGetter: (params: ITooltipParams) =>
|
||||
params.node?.rowPinned === 'top' ?
|
||||
null
|
||||
: `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate component`,
|
||||
cellRenderer: (params: ICellRendererParams) =>
|
||||
params.node.rowPinned === 'top' ?
|
||||
`<div> ${params.value}</div>`
|
||||
: `<div class='link'> ${params.value} </div>`,
|
||||
}
|
||||
}
|
||||
|
||||
@ -639,6 +726,8 @@ config.setToolbar(
|
||||
:rowData="rowData"
|
||||
:defaultColDef="defaultColDef"
|
||||
:textFormatOption="textFormatterSelected"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:pinnedRowHeightMultiplier="pinnedRowHeight"
|
||||
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
|
||||
/>
|
||||
</Suspense>
|
||||
|
@ -1,18 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
|
||||
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import type { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import { provideVisualizationConfig } from '@/providers/visualizationConfig'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import type { Vec2 } from '@/util/data/vec2'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
|
||||
const { visualization, data, size, nodeType } = defineProps<{
|
||||
visualization?: string | object
|
||||
data?: any
|
||||
size: Vec2
|
||||
nodeType?: string | undefined
|
||||
overflow?: boolean
|
||||
toolbarOverflow?: boolean
|
||||
// A single prop `params` is important to mitigate a bug in Vue that causes
|
||||
// inconsistent state when multiple props are present on the custom elements component.
|
||||
// TODO[ib]: Add a link to the issue.
|
||||
const props = defineProps<{
|
||||
params: {
|
||||
visualization?: string | object
|
||||
data?: any
|
||||
size: Vec2
|
||||
nodeType?: string | undefined
|
||||
overflow?: boolean
|
||||
toolbarOverflow?: boolean
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -32,10 +37,10 @@ const emit = defineEmits<{
|
||||
|
||||
provideVisualizationConfig({
|
||||
get size() {
|
||||
return size
|
||||
return props.params.size
|
||||
},
|
||||
get nodeType() {
|
||||
return nodeType
|
||||
return props.params.nodeType
|
||||
},
|
||||
setPreprocessor: (
|
||||
visualizationModule: string,
|
||||
@ -52,7 +57,11 @@ provideVisualizationConfig({
|
||||
<template>
|
||||
<Suspense>
|
||||
<template #fallback><LoadingVisualization /></template>
|
||||
<component :is="visualization" v-if="visualization && data" :data="data" />
|
||||
<component
|
||||
:is="props.params.visualization"
|
||||
v-if="props.params.visualization && props.params.data"
|
||||
:data="props.params.data"
|
||||
/>
|
||||
<LoadingVisualization v-else />
|
||||
</Suspense>
|
||||
</template>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import type { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue'
|
||||
import type { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import { computed, ComputedRef, Ref, toValue } from 'vue'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import { computed, type ComputedRef, type Ref, toValue } from 'vue'
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
export type SortModel = {
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { URLString } from '@/util/data/urlString'
|
||||
import { Icon } from '@/util/iconName'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import { Ref } from 'vue'
|
||||
import type { URLString } from '@/util/data/urlString'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface Button {
|
||||
icon: Icon | URLString
|
||||
iconStyle?: Record<string, string>
|
||||
title?: string
|
||||
dataTestid?: string
|
||||
icon: Icon | URLString
|
||||
}
|
||||
|
||||
export interface ActionButton extends Button {
|
||||
@ -30,7 +30,21 @@ export interface SelectionMenu {
|
||||
options: Record<string, SelectionMenuOption>
|
||||
}
|
||||
|
||||
export type ToolbarItem = ActionButton | ToggleButton | SelectionMenu
|
||||
export interface TextSelectionMenuOption {
|
||||
title?: string
|
||||
dataTestid?: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TextSelectionMenu {
|
||||
type: 'textSelectionMenu'
|
||||
selectedTextOption: Ref<string>
|
||||
title?: string
|
||||
options: Record<string, TextSelectionMenuOption>
|
||||
heading: string
|
||||
}
|
||||
|
||||
export type ToolbarItem = ActionButton | ToggleButton | SelectionMenu | TextSelectionMenu
|
||||
|
||||
/** {@link ActionButton} discriminant */
|
||||
export function isActionButton(item: Readonly<ToolbarItem>): item is ActionButton {
|
||||
@ -46,3 +60,8 @@ export function isToggleButton(item: Readonly<ToolbarItem>): item is ToggleButto
|
||||
export function isSelectionMenu(item: Readonly<ToolbarItem>): item is SelectionMenu {
|
||||
return 'selected' in item
|
||||
}
|
||||
|
||||
/** {@link SelectionTextMenu} discriminant */
|
||||
export function isTextSelectionMenu(item: Readonly<ToolbarItem>): item is TextSelectionMenu {
|
||||
return 'selectedTextOption' in item
|
||||
}
|
||||
|
@ -3,15 +3,14 @@ import LoadingSpinner from '@/components/shared/LoadingSpinner.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useBackend } from '@/composables/backend'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import Backend, {
|
||||
assetIsDirectory,
|
||||
assetIsFile,
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import type {
|
||||
DirectoryAsset,
|
||||
DirectoryId,
|
||||
FileAsset,
|
||||
FileId,
|
||||
} from 'enso-common/src/services/Backend'
|
||||
import Backend, { assetIsDirectory, assetIsFile } from 'enso-common/src/services/Backend'
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -26,7 +26,7 @@ test.each([
|
||||
// This is a special case because when a block is empty, adding a line requires adding *two* linebreaks.
|
||||
test('Adding node to empty block', () => {
|
||||
const module = Ast.MutableModule.Transient()
|
||||
const func = Ast.Function.new(identifier('f')!, [], Ast.BodyBlock.new([], module), {
|
||||
const func = Ast.FunctionDef.new(identifier('f')!, [], Ast.BodyBlock.new([], module), {
|
||||
edit: module,
|
||||
})
|
||||
const rootBlock = Ast.BodyBlock.new([], module)
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { injectBackend } from '@/providers/backend'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import { useQuery, useQueryClient, UseQueryOptions, UseQueryReturnType } from '@tanstack/vue-query'
|
||||
import type { UseQueryOptions, UseQueryReturnType } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { BackendMethods } from 'enso-common/src/backendQuery'
|
||||
import { backendBaseOptions, backendQueryKey } from 'enso-common/src/backendQuery'
|
||||
import Backend from 'enso-common/src/services/Backend'
|
||||
|
@ -11,7 +11,7 @@ import type { KeyboardComposable } from '@/composables/keyboard'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { Handler, useGesture } from '@vueuse/gesture'
|
||||
import { useGesture, type Handler } from '@vueuse/gesture'
|
||||
import {
|
||||
computed,
|
||||
onScopeDispose,
|
||||
|
@ -12,11 +12,11 @@ import type { Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { isIdentifier, substituteIdentifier, type Identifier } from '@/util/ast/abstract'
|
||||
import { partition } from '@/util/data/array'
|
||||
import { filterDefined } from '@/util/data/iterable'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { nextTick, toValue } from 'vue'
|
||||
import { assert, assertNever } from 'ydoc-shared/util/assert'
|
||||
import { mustExtend } from 'ydoc-shared/util/types'
|
||||
@ -76,7 +76,8 @@ export function useNodeCreation(
|
||||
: placement.type === 'mouse' ? tryMouse() ?? place()
|
||||
: placement.type === 'mouseRelative' ? tryMouseRelative(placement.posOffset) ?? place()
|
||||
: placement.type === 'mouseEvent' ? mouseDictatedPlacement(placement.position)
|
||||
: placement.type === 'source' ? place(filterDefined([graphStore.visibleArea(placement.node)]))
|
||||
: placement.type === 'source' ?
|
||||
place(iter.filterDefined([graphStore.visibleArea(placement.node)]))
|
||||
: placement.type === 'fixed' ? placement.position
|
||||
: assertNever(placement)
|
||||
)
|
||||
@ -278,7 +279,10 @@ export function insertNodeStatements(
|
||||
const lines = bodyBlock.lines
|
||||
const lastStatement = lines[lines.length - 1]?.statement?.node
|
||||
const index =
|
||||
lastStatement instanceof Ast.MutableAssignment || lastStatement instanceof Ast.MutableFunction ?
|
||||
(
|
||||
lastStatement instanceof Ast.MutableAssignment ||
|
||||
lastStatement instanceof Ast.MutableFunctionDef
|
||||
) ?
|
||||
lines.length
|
||||
: lines.length - 1
|
||||
bodyBlock.insert(index, ...statements)
|
||||
|
@ -3,15 +3,15 @@ import { selectionMouseBindings } from '@/bindings'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import type { PortId } from '@/providers/portInfo.ts'
|
||||
import { type NodeId } from '@/stores/graph'
|
||||
import { filter, filterDefined, map } from '@/util/data/iterable'
|
||||
import type { Rect } from '@/util/data/rect'
|
||||
import { intersectionSize } from '@/util/data/set'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { dataAttribute, elementHierarchy } from '@/util/dom'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import * as set from 'lib0/set'
|
||||
import { computed, ref, shallowReactive, shallowRef } from 'vue'
|
||||
import { Err, Ok, type Result } from 'ydoc-shared/util/data/result'
|
||||
import { NavigatorComposable } from './navigator'
|
||||
import type { NavigatorComposable } from './navigator'
|
||||
|
||||
interface BaseSelectionOptions<T> {
|
||||
margin?: number
|
||||
@ -85,11 +85,13 @@ function useSelectionImpl<T, PackedT>(
|
||||
// Selection, including elements that do not (currently) pass `isValid`.
|
||||
const rawSelected = shallowReactive(new Set<PackedT>())
|
||||
|
||||
const unpackedRawSelected = computed(() => set.from(filterDefined(map(rawSelected, unpack))))
|
||||
const selected = computed(() => set.from(filter(unpackedRawSelected.value, isValid)))
|
||||
const unpackedRawSelected = computed(() =>
|
||||
set.from(iter.filterDefined(iter.map(rawSelected, unpack))),
|
||||
)
|
||||
const selected = computed(() => set.from(iter.filter(unpackedRawSelected.value, isValid)))
|
||||
const isChanging = computed(() => anchor.value != null)
|
||||
const committedSelection = computed(() =>
|
||||
isChanging.value ? set.from(filter(initiallySelected, isValid)) : selected.value,
|
||||
isChanging.value ? set.from(iter.filter(initiallySelected, isValid)) : selected.value,
|
||||
)
|
||||
|
||||
function readInitiallySelected() {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import type { MockYdocProviderImpl } from '@/util/crdt'
|
||||
import type { WebSocketHandler } from '@/util/net'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
import * as random from 'lib0/random'
|
||||
import * as Ast from 'ydoc-shared/ast'
|
||||
import {
|
||||
Builder,
|
||||
EnsoUUID,
|
||||
@ -28,7 +28,7 @@ import { uuidToBits } from 'ydoc-shared/uuid'
|
||||
import * as Y from 'yjs'
|
||||
import { mockFsDirectoryHandle, type FileTree } from '../util/convert/fsAccess'
|
||||
import { mockDataWSHandler as originalMockDataWSHandler } from './dataServer'
|
||||
import mockDb from './mockSuggestions.json' assert { type: 'json' }
|
||||
import mockDb from './mockSuggestions.json' with { type: 'json' }
|
||||
|
||||
const mockProjectId = random.uuidv4() as Uuid
|
||||
const standardBase = 'Standard.Base' as QualifiedName
|
||||
@ -49,6 +49,7 @@ const mainFile = `\
|
||||
## Module documentation
|
||||
from Standard.Base import all
|
||||
|
||||
## A collapsed function
|
||||
func1 arg =
|
||||
f2 = Main.func2 arg
|
||||
result = f2 - 5
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createContextStore } from '@/providers'
|
||||
import { Ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export { provideFn as provideFullscreenContext, injectFn as useFullscreenContext }
|
||||
const { provideFn, injectFn } = createContextStore(
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createContextStore } from '@/providers'
|
||||
import { last } from '@/util/data/iterable'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import {
|
||||
computed,
|
||||
onUnmounted,
|
||||
@ -21,14 +21,14 @@ const { provideFn, injectFn } = createContextStore('tooltip registry', () => {
|
||||
const hoveredElements = shallowReactive<Map<HTMLElement, EntriesSet>>(new Map())
|
||||
|
||||
const lastHoveredElement = computed(() => {
|
||||
return last(hoveredElements.keys())
|
||||
return iter.last(hoveredElements.keys())
|
||||
})
|
||||
|
||||
return {
|
||||
lastHoveredElement,
|
||||
getElementEntry(el: HTMLElement | undefined): TooltipEntry | undefined {
|
||||
const set = el && hoveredElements.get(el)
|
||||
return set ? last(set) : undefined
|
||||
return set ? iter.last(set) : undefined
|
||||
},
|
||||
registerTooltip(slot: Ref<Slot | undefined>) {
|
||||
const entry: TooltipEntry = {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import type { ToolbarItem } from '@/components/visualizations/toolbar'
|
||||
import { createContextStore } from '@/providers'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { ToValue } from '@/util/reactivity'
|
||||
import type { Vec2 } from '@/util/data/vec2'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface VisualizationConfig {
|
||||
|
@ -4,8 +4,7 @@ import type { WidgetConfiguration } from '@/providers/widgetRegistry/configurati
|
||||
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import type { Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MutableModule } from '@/util/ast/abstract'
|
||||
import { ViteHotContext } from 'vite/types/hot.js'
|
||||
import type { ViteHotContext } from 'vite/types/hot.js'
|
||||
import { computed, shallowReactive, type Component, type PropType } from 'vue'
|
||||
import type { WidgetEditHandlerParent } from './widgetRegistry/editHandler'
|
||||
|
||||
@ -169,7 +168,7 @@ export interface WidgetProps<T> {
|
||||
* is committed in {@link NodeWidgetTree}.
|
||||
*/
|
||||
export interface WidgetUpdate {
|
||||
edit?: MutableModule | undefined
|
||||
edit?: Ast.MutableModule | undefined
|
||||
portUpdate?: { origin: PortId } & (
|
||||
| { value: Ast.Owned<Ast.MutableExpression> | string | undefined }
|
||||
| { metadataKey: string; metadata: unknown }
|
||||
|
@ -23,7 +23,7 @@ export function parseWithSpans<T extends Record<string, SourceRange>>(code: stri
|
||||
idMap.insertKnownId(span, eid)
|
||||
}
|
||||
|
||||
const { root: ast, toRaw, getSpan } = Ast.parseExtended(code, idMap)
|
||||
const { root: ast, toRaw, getSpan } = Ast.parseUpdatingIdMap(code, idMap)
|
||||
const idFromExternal = new Map<ExternalId, AstId>()
|
||||
ast.visitRecursive((ast) => {
|
||||
idFromExternal.set(ast.externalId, ast.id)
|
||||
@ -58,7 +58,7 @@ test('Reading graph from definition', () => {
|
||||
const db = GraphDb.Mock()
|
||||
const expressions = Array.from(ast.statements())
|
||||
const func = expressions[0]
|
||||
assert(func instanceof Ast.Function)
|
||||
assert(func instanceof Ast.FunctionDef)
|
||||
const rawFunc = toRaw.get(func.id)
|
||||
assert(rawFunc?.type === RawAst.Tree.Type.Function)
|
||||
db.updateExternalIds(ast)
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
} from '@/util/reactivity'
|
||||
import * as objects from 'enso-common/src/utilities/data/object'
|
||||
import * as set from 'lib0/set'
|
||||
import { reactive, ref, shallowReactive, WatchStopHandle, type Ref } from 'vue'
|
||||
import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue'
|
||||
import type { MethodCall, StackItem } from 'ydoc-shared/languageServerTypes'
|
||||
import type { Opt } from 'ydoc-shared/util/data/opt'
|
||||
import type { ExternalId, SourceRange, VisualizationMetadata } from 'ydoc-shared/yjsModel'
|
||||
@ -47,7 +47,7 @@ export class BindingsDb {
|
||||
|
||||
/** TODO: Add docs */
|
||||
readFunctionAst(
|
||||
func: Ast.Function,
|
||||
func: Ast.FunctionDef,
|
||||
rawFunc: RawAst.Tree.Function | undefined,
|
||||
moduleCode: string,
|
||||
getSpan: (id: AstId) => SourceRange | undefined,
|
||||
@ -346,7 +346,7 @@ export class GraphDb {
|
||||
* expression changes.
|
||||
*/
|
||||
updateNodes(
|
||||
functionAst_: Ast.Function,
|
||||
functionAst_: Ast.FunctionDef,
|
||||
{ watchEffect }: { watchEffect: (f: () => void) => WatchStopHandle },
|
||||
) {
|
||||
const currentNodeIds = new Set<NodeId>()
|
||||
@ -468,7 +468,7 @@ export class GraphDb {
|
||||
|
||||
/** Deeply scan the function to perform alias-analysis. */
|
||||
updateBindings(
|
||||
functionAst_: Ast.Function,
|
||||
functionAst_: Ast.FunctionDef,
|
||||
rawFunction: RawAst.Tree.Function | undefined,
|
||||
moduleCode: string,
|
||||
getSpan: (id: AstId) => SourceRange | undefined,
|
||||
|
@ -22,15 +22,15 @@ import { isAstId, isIdentifier } from '@/util/ast/abstract'
|
||||
import { RawAst, visitRecursive } from '@/util/ast/raw'
|
||||
import { reactiveModule } from '@/util/ast/reactive'
|
||||
import { partition } from '@/util/data/array'
|
||||
import { Events, stringUnionToArray } from '@/util/data/observable'
|
||||
import { stringUnionToArray, type Events } from '@/util/data/observable'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Err, mapOk, Ok, unwrap, type Result } from '@/util/data/result'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { normalizeQualifiedName, qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { useWatchContext } from '@/util/reactivity'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { map, set } from 'lib0'
|
||||
import { iteratorFilter } from 'lib0/iterator'
|
||||
import {
|
||||
computed,
|
||||
markRaw,
|
||||
@ -98,7 +98,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
||||
const vizRects = reactive(new Map<NodeId, Rect>())
|
||||
// The currently visible nodes' areas (including visualization).
|
||||
const visibleNodeAreas = computed(() => {
|
||||
const existing = iteratorFilter(nodeRects.entries(), ([id]) => db.isNodeId(id))
|
||||
const existing = iter.filter(nodeRects.entries(), ([id]) => db.isNodeId(id))
|
||||
return Array.from(existing, ([id, rect]) => vizRects.get(id) ?? rect)
|
||||
})
|
||||
function visibleArea(nodeId: NodeId): Rect | undefined {
|
||||
@ -146,7 +146,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
||||
},
|
||||
)
|
||||
|
||||
const methodAst = computed<Result<Ast.Function>>(() =>
|
||||
const methodAst = computed<Result<Ast.FunctionDef>>(() =>
|
||||
syncModule.value ? getExecutedMethodAst(syncModule.value) : Err('AST not yet initialized'),
|
||||
)
|
||||
|
||||
@ -188,7 +188,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
||||
db.updateBindings(method, rawFunc, moduleSource.text, getSpan)
|
||||
})
|
||||
|
||||
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.Function> {
|
||||
function getExecutedMethodAst(module?: Ast.Module): Result<Ast.FunctionDef> {
|
||||
const executionStackTop = proj.executionContext.getStackTop()
|
||||
switch (executionStackTop.type) {
|
||||
case 'ExplicitCall': {
|
||||
@ -204,7 +204,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
|
||||
}
|
||||
}
|
||||
|
||||
function getMethodAst(ptr: MethodPointer, edit?: Ast.Module): Result<Ast.Function> {
|
||||
function getMethodAst(ptr: MethodPointer, edit?: Ast.Module): Result<Ast.FunctionDef> {
|
||||
const topLevel = (edit ?? syncModule.value)?.root()
|
||||
if (!topLevel) return Err('Module unavailable')
|
||||
assert(topLevel instanceof Ast.BodyBlock)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { PortId } from '@/providers/portInfo'
|
||||
import type { ConnectedEdge } from '@/stores/graph/index'
|
||||
import { filterDefined } from '@/util/data/iterable'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { computed, ref, watch, type WatchSource } from 'vue'
|
||||
import type { AstId } from 'ydoc-shared/ast'
|
||||
|
||||
@ -99,7 +99,7 @@ export function useUnconnectedEdges() {
|
||||
const unconnectedEdges = computed<Set<UnconnectedEdge>>(
|
||||
() =>
|
||||
new Set(
|
||||
filterDefined([mouseEditedEdge.value, cbEditedEdge.value, outputSuggestedEdge.value]),
|
||||
iter.filterDefined([mouseEditedEdge.value, cbEditedEdge.value, outputSuggestedEdge.value]),
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -15,6 +15,7 @@ import * as random from 'lib0/random'
|
||||
import { reactive } from 'vue'
|
||||
import type { LanguageServer } from 'ydoc-shared/languageServer'
|
||||
import {
|
||||
methodPointerEquals,
|
||||
stackItemsEqual,
|
||||
type ContextId,
|
||||
type Diagnostic,
|
||||
@ -45,16 +46,26 @@ function visualizationConfigEqual(
|
||||
b: NodeVisualizationConfiguration,
|
||||
): boolean {
|
||||
return (
|
||||
a === b ||
|
||||
visualizationConfigPreprocessorEqual(a, b) &&
|
||||
(a.positionalArgumentsExpressions === b.positionalArgumentsExpressions ||
|
||||
(Array.isArray(a.positionalArgumentsExpressions) &&
|
||||
Array.isArray(b.positionalArgumentsExpressions) &&
|
||||
array.equalFlat(a.positionalArgumentsExpressions, b.positionalArgumentsExpressions)))
|
||||
)
|
||||
}
|
||||
|
||||
/** Same as {@link visualizationConfigEqual}, but ignores differences in {@link NodeVisualizationConfiguration.positionalArgumentsExpressions}. */
|
||||
export function visualizationConfigPreprocessorEqual(
|
||||
a: NodeVisualizationConfiguration,
|
||||
b: NodeVisualizationConfiguration,
|
||||
): boolean {
|
||||
return (
|
||||
a == b ||
|
||||
(a.visualizationModule === b.visualizationModule &&
|
||||
(a.positionalArgumentsExpressions === b.positionalArgumentsExpressions ||
|
||||
(Array.isArray(a.positionalArgumentsExpressions) &&
|
||||
Array.isArray(b.positionalArgumentsExpressions) &&
|
||||
array.equalFlat(a.positionalArgumentsExpressions, b.positionalArgumentsExpressions))) &&
|
||||
(a.expression === b.expression ||
|
||||
(typeof a.expression === 'object' &&
|
||||
typeof b.expression === 'object' &&
|
||||
object.equalFlat(a.expression, b.expression))))
|
||||
methodPointerEquals(a.expression, b.expression))))
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { Awareness } from '@/stores/awareness'
|
||||
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
|
||||
import {
|
||||
ExecutionContext,
|
||||
visualizationConfigPreprocessorEqual,
|
||||
type NodeVisualizationConfiguration,
|
||||
} from '@/stores/project/executionContext'
|
||||
import { VisualizationDataRegistry } from '@/stores/project/visualizationDataRegistry'
|
||||
@ -237,11 +238,17 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
|
||||
})
|
||||
|
||||
function useVisualizationData(configuration: WatchSource<Opt<NodeVisualizationConfiguration>>) {
|
||||
const id = random.uuidv4() as Uuid
|
||||
const newId = () => random.uuidv4() as Uuid
|
||||
const visId = ref(newId())
|
||||
// Regenerate the visualization ID when the preprocessor changes.
|
||||
watch(configuration, (a, b) => {
|
||||
if (a != null && b != null && !visualizationConfigPreprocessorEqual(a, b))
|
||||
visId.value = newId()
|
||||
})
|
||||
|
||||
watch(
|
||||
configuration,
|
||||
(config, _, onCleanup) => {
|
||||
[configuration, visId],
|
||||
([config, id], _, onCleanup) => {
|
||||
executionContext.setVisualization(id, config)
|
||||
onCleanup(() => executionContext.setVisualization(id, null))
|
||||
},
|
||||
@ -250,7 +257,9 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
return computed(() => parseVisualizationData(visualizationDataRegistry.getRawData(id)))
|
||||
return computed(() =>
|
||||
parseVisualizationData(visualizationDataRegistry.getRawData(visId.value)),
|
||||
)
|
||||
}
|
||||
|
||||
const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => {
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { assert, assertDefined } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import {
|
||||
MutableModule,
|
||||
TextLiteral,
|
||||
escapeTextLiteral,
|
||||
findModuleMethod,
|
||||
substituteIdentifier,
|
||||
@ -21,7 +19,7 @@ import { findExpressions, testCase, tryFindExpressions } from './testCase'
|
||||
test('Raw block abstracts to Ast.BodyBlock', () => {
|
||||
const code = 'value = 2 + 2'
|
||||
const rawBlock = Ast.rawParseModule(code)
|
||||
const edit = MutableModule.Transient()
|
||||
const edit = Ast.MutableModule.Transient()
|
||||
const abstracted = Ast.abstract(edit, rawBlock, code)
|
||||
expect(abstracted.root).toBeInstanceOf(Ast.BodyBlock)
|
||||
})
|
||||
@ -395,7 +393,7 @@ test.each(cases)('parse/print round-trip: %s', (testCase) => {
|
||||
const root = Ast.parseModule(code)
|
||||
root.module.setRoot(root)
|
||||
// Print AST back to source.
|
||||
const printed = Ast.print(root)
|
||||
const printed = Ast.printWithSpans(root)
|
||||
expect(printed.code).toEqual(expectedCode)
|
||||
// Loading token IDs from IdMaps is not implemented yet, fix during sync.
|
||||
printed.info.tokens.clear()
|
||||
@ -409,7 +407,7 @@ test.each(cases)('parse/print round-trip: %s', (testCase) => {
|
||||
const { root: root1, spans: spans1 } = Ast.parseModuleWithSpans(printed.code)
|
||||
Ast.setExternalIds(root1.module, spans1, idMap)
|
||||
// Check that Identities match original AST.
|
||||
const printed1 = Ast.print(root1)
|
||||
const printed1 = Ast.printWithSpans(root1)
|
||||
printed1.info.tokens.clear()
|
||||
const idMap1 = Ast.spanMapToIdMap(printed1.info)
|
||||
const mapsEqual = idMap1.isEqual(idMap)
|
||||
@ -448,7 +446,7 @@ test('Insert new expression', () => {
|
||||
|
||||
type SimpleModule = {
|
||||
root: Ast.BodyBlock
|
||||
main: Ast.Function
|
||||
main: Ast.FunctionDef
|
||||
mainBlock: Ast.BodyBlock
|
||||
assignment: Ast.Assignment
|
||||
}
|
||||
@ -515,7 +513,7 @@ test('Modify subexpression - setting a vector', () => {
|
||||
expect(assignment).toBeInstanceOf(Ast.Assignment)
|
||||
|
||||
const edit = root.module.edit()
|
||||
const transientModule = MutableModule.Transient()
|
||||
const transientModule = Ast.MutableModule.Transient()
|
||||
const barExpression = Ast.parseExpression('bar')
|
||||
assertDefined(barExpression)
|
||||
const newValue = Ast.Vector.new(transientModule, [barExpression])
|
||||
@ -556,7 +554,7 @@ test('Block lines interface', () => {
|
||||
})
|
||||
|
||||
test('Splice', () => {
|
||||
const module = MutableModule.Transient()
|
||||
const module = Ast.MutableModule.Transient()
|
||||
const edit = module.edit()
|
||||
const ident = Ast.Ident.new(edit, 'foo' as Identifier)
|
||||
expect(ident.code()).toBe('foo')
|
||||
@ -566,7 +564,7 @@ test('Splice', () => {
|
||||
})
|
||||
|
||||
test('Construct app', () => {
|
||||
const edit = MutableModule.Transient()
|
||||
const edit = Ast.MutableModule.Transient()
|
||||
const app = Ast.App.new(
|
||||
edit,
|
||||
Ast.Ident.new(edit, 'func' as Identifier),
|
||||
@ -607,9 +605,9 @@ test('Automatic parenthesis', () => {
|
||||
|
||||
test('Tree repair: Non-canonical block line attribution', () => {
|
||||
const beforeCase = testCase({
|
||||
'func a b =': Ast.Function,
|
||||
'func a b =': Ast.FunctionDef,
|
||||
' c = a + b': Ast.Assignment,
|
||||
'main =': Ast.Function,
|
||||
'main =': Ast.FunctionDef,
|
||||
' func arg1 arg2': Ast.ExpressionStatement,
|
||||
})
|
||||
const before = beforeCase.statements
|
||||
@ -624,10 +622,12 @@ test('Tree repair: Non-canonical block line attribution', () => {
|
||||
|
||||
const repair = edit.edit()
|
||||
Ast.repair(editedRoot, repair)
|
||||
const afterRepair = findExpressions(repair.root()!, {
|
||||
'func a b =': Ast.Function,
|
||||
const repairedRoot = repair.root()
|
||||
assertDefined(repairedRoot)
|
||||
const afterRepair = findExpressions(repairedRoot, {
|
||||
'func a b =': Ast.FunctionDef,
|
||||
'c = a + b': Ast.Assignment,
|
||||
'main =': Ast.Function,
|
||||
'main =': Ast.FunctionDef,
|
||||
'func arg1 arg2': Ast.ExpressionStatement,
|
||||
})
|
||||
const repairedFunc = afterRepair['func a b =']
|
||||
@ -737,7 +737,7 @@ describe('Code edit', () => {
|
||||
|
||||
test('Rearrange block', () => {
|
||||
const beforeCase = testCase({
|
||||
'main =': Ast.Function,
|
||||
'main =': Ast.FunctionDef,
|
||||
' call_result = func sum 12': Ast.Assignment,
|
||||
' sum = value + 23': Ast.Assignment,
|
||||
' value = 42': Ast.Assignment,
|
||||
@ -756,7 +756,7 @@ describe('Code edit', () => {
|
||||
expect(edit.root()?.code()).toBe(newCode)
|
||||
// Ensure the identities of all the original nodes were maintained.
|
||||
const after = tryFindExpressions(edit.root()!, {
|
||||
'main =': Ast.Function,
|
||||
'main =': Ast.FunctionDef,
|
||||
'call_result = func sum 12': Ast.Assignment,
|
||||
'sum = value + 23': Ast.Assignment,
|
||||
'value = 42': Ast.Assignment,
|
||||
@ -768,7 +768,7 @@ describe('Code edit', () => {
|
||||
|
||||
test('Rename binding', () => {
|
||||
const beforeCase = testCase({
|
||||
'main =': Ast.Function,
|
||||
'main =': Ast.FunctionDef,
|
||||
' value = 42': Ast.Assignment,
|
||||
' sum = value + 23': Ast.Assignment,
|
||||
' call_result = func sum 12': Ast.Assignment,
|
||||
@ -782,12 +782,14 @@ describe('Code edit', () => {
|
||||
'\n sum = the_number + 23',
|
||||
'\n call_result = func sum 12',
|
||||
].join('')
|
||||
edit.root()!.syncToCode(newCode)
|
||||
const editRoot = edit.root()
|
||||
assertDefined(editRoot)
|
||||
editRoot.syncToCode(newCode)
|
||||
// Ensure the change was made.
|
||||
expect(edit.root()?.code()).toBe(newCode)
|
||||
// Ensure the identities of all the original nodes were maintained.
|
||||
const after = tryFindExpressions(edit.root()!, {
|
||||
'main =': Ast.Function,
|
||||
'main =': Ast.FunctionDef,
|
||||
'call_result = func sum 12': Ast.Assignment,
|
||||
'sum = the_number + 23': Ast.Assignment,
|
||||
'the_number = 42': Ast.Assignment,
|
||||
@ -840,8 +842,8 @@ describe('Code edit', () => {
|
||||
})
|
||||
|
||||
test('No-op block change', () => {
|
||||
const code = 'a = 1\nb = 2\n'
|
||||
const block = Ast.parseBlock(code)
|
||||
const code = 'main =\n a = 1\n b = 2\n'
|
||||
const block = Ast.parseModule(code)
|
||||
const module = block.module
|
||||
module.setRoot(block)
|
||||
block.syncToCode(code)
|
||||
@ -854,7 +856,7 @@ describe('Code edit', () => {
|
||||
const before = findExpressions(beforeRoot, {
|
||||
value: Ast.Ident,
|
||||
'1': Ast.NumericLiteral,
|
||||
'value = 1 +': Ast.Function,
|
||||
'value = 1 +': Ast.FunctionDef,
|
||||
})
|
||||
const edit = beforeRoot.module.edit()
|
||||
const newCode = 'value = 1 \n'
|
||||
@ -865,7 +867,7 @@ describe('Code edit', () => {
|
||||
const after = findExpressions(edit.root()!, {
|
||||
value: Ast.Ident,
|
||||
'1': Ast.NumericLiteral,
|
||||
'value = 1': Ast.Function,
|
||||
'value = 1': Ast.FunctionDef,
|
||||
})
|
||||
expect(after.value.id).toBe(before.value.id)
|
||||
expect(after['1'].id).toBe(before['1'].id)
|
||||
@ -938,6 +940,7 @@ test.each([
|
||||
'Substitute qualified name $pattern inside $original',
|
||||
({ original, pattern, substitution, expected }) => {
|
||||
const expression = Ast.parseExpression(original) ?? Ast.parseStatement(original)
|
||||
assertDefined(expression)
|
||||
const module = expression.module
|
||||
module.setRoot(expression)
|
||||
const edit = expression.module.edit()
|
||||
@ -994,6 +997,7 @@ test.each([
|
||||
'Substitute identifier $pattern inside $original',
|
||||
({ original, pattern, substitution, expected }) => {
|
||||
const expression = Ast.parseExpression(original) ?? Ast.parseStatement(original)
|
||||
assertDefined(expression)
|
||||
const module = expression.module
|
||||
module.setRoot(expression)
|
||||
const edit = expression.module.edit()
|
||||
@ -1039,7 +1043,7 @@ test.prop({ rawText: sometimesUnicodeString })('Text interpolation roundtrip', (
|
||||
})
|
||||
|
||||
test.prop({ rawText: sometimesUnicodeString })('AST text literal new', ({ rawText }) => {
|
||||
const literal = TextLiteral.new(rawText)
|
||||
const literal = Ast.TextLiteral.new(rawText)
|
||||
expect(literal.rawTextContent).toBe(rawText)
|
||||
})
|
||||
|
||||
@ -1047,7 +1051,7 @@ test.prop({
|
||||
boundary: fc.constantFrom('"', "'"),
|
||||
rawText: sometimesUnicodeString,
|
||||
})('AST text literal rawTextContent', ({ boundary, rawText }) => {
|
||||
const literal = TextLiteral.new('')
|
||||
const literal = Ast.TextLiteral.new('')
|
||||
literal.setBoundaries(boundary)
|
||||
literal.setRawTextContent(rawText)
|
||||
expect(literal.rawTextContent).toBe(rawText)
|
||||
@ -1062,7 +1066,7 @@ test.prop({
|
||||
})
|
||||
|
||||
test('setRawTextContent promotes single-line uninterpolated text to interpolated if a newline is added', () => {
|
||||
const literal = TextLiteral.new('')
|
||||
const literal = Ast.TextLiteral.new('')
|
||||
literal.setBoundaries('"')
|
||||
const rawText = '\n'
|
||||
literal.setRawTextContent(rawText)
|
||||
@ -1093,6 +1097,7 @@ test.each([
|
||||
{ code: 'operator1 + operator2', expected: { subject: 'operator1 + operator2', accesses: [] } },
|
||||
])('Access chain in $code', ({ code, expected }) => {
|
||||
const ast = Ast.parseExpression(code)
|
||||
assertDefined(ast)
|
||||
const { subject, accessChain } = Ast.accessChain(ast)
|
||||
expect({
|
||||
subject: subject.code(),
|
||||
@ -1108,7 +1113,9 @@ test.each`
|
||||
`('Pushing $pushed to vector $initial', ({ initial, pushed, expected }) => {
|
||||
const vector = Ast.Vector.tryParse(initial)
|
||||
assertDefined(vector)
|
||||
vector.push(Ast.parseExpression(pushed, vector.module))
|
||||
const elem = Ast.parseExpression(pushed, vector.module)
|
||||
assertDefined(elem)
|
||||
vector.push(elem)
|
||||
expect(vector.code()).toBe(expected)
|
||||
})
|
||||
|
||||
@ -1188,7 +1195,9 @@ test.each`
|
||||
({ initial, index, value, expected }) => {
|
||||
const vector = Ast.Vector.tryParse(initial)
|
||||
assertDefined(vector)
|
||||
vector.set(index, Ast.parseExpression(value, vector.module))
|
||||
const elemValue = Ast.parseExpression(value, vector.module)
|
||||
assertDefined(elemValue)
|
||||
vector.set(index, elemValue)
|
||||
expect(vector.code()).toBe(expected)
|
||||
},
|
||||
)
|
||||
@ -1211,10 +1220,11 @@ test.each`
|
||||
({ ensoNumber, jsNumber, expectedEnsoNumber }) => {
|
||||
if (ensoNumber != null) {
|
||||
const literal = Ast.parseExpression(ensoNumber)
|
||||
assertDefined(literal)
|
||||
expect(tryEnsoToNumber(literal)).toBe(jsNumber)
|
||||
}
|
||||
if (jsNumber != null) {
|
||||
const convertedToAst = tryNumberToEnso(jsNumber, MutableModule.Transient())
|
||||
const convertedToAst = tryNumberToEnso(jsNumber, Ast.MutableModule.Transient())
|
||||
expect(convertedToAst?.code()).toBe(expectedEnsoNumber)
|
||||
}
|
||||
},
|
||||
|
@ -7,14 +7,26 @@ import { splitFileContents } from 'ydoc-shared/ensoFile'
|
||||
// file format handling to shared and create a test utility for easy *.enso file fixture loading.
|
||||
import { deserializeIdMap } from 'ydoc-server'
|
||||
|
||||
/** Print the AST and re-parse it, copying `externalId`s (but not other metadata) from the original. */
|
||||
function normalize(rootIn: Ast.Ast): Ast.Ast {
|
||||
const printed = Ast.printWithSpans(rootIn)
|
||||
const idMap = Ast.spanMapToIdMap(printed.info)
|
||||
const module = Ast.MutableModule.Transient()
|
||||
const tree = Ast.rawParseModule(printed.code)
|
||||
const { root: parsed, spans } = Ast.abstract(module, tree, printed.code)
|
||||
module.setRoot(parsed)
|
||||
Ast.setExternalIds(module, spans, idMap)
|
||||
return parsed
|
||||
}
|
||||
|
||||
test('full file IdMap round trip', () => {
|
||||
const content = fs.readFileSync(__dirname + '/fixtures/stargazers.enso').toString()
|
||||
const { code, idMapJson, metadataJson: _ } = splitFileContents(content)
|
||||
const idMapOriginal = deserializeIdMap(idMapJson!)
|
||||
const idMap = idMapOriginal.clone()
|
||||
const ast_ = Ast.parseExtended(code, idMapOriginal.clone()).root
|
||||
const ast = Ast.parseExtended(code, idMap).root
|
||||
const ast2 = Ast.normalize(ast)
|
||||
const ast_ = Ast.parseUpdatingIdMap(code, idMapOriginal.clone()).root
|
||||
const ast = Ast.parseUpdatingIdMap(code, idMap).root
|
||||
const ast2 = normalize(ast)
|
||||
const astTT = Ast.tokenTreeWithIds(ast)
|
||||
expect(ast2.code()).toBe(ast.code())
|
||||
expect(Ast.tokenTreeWithIds(ast2), 'Print/parse preserves IDs').toStrictEqual(astTT)
|
||||
|
@ -1,90 +0,0 @@
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { test } from '@fast-check/vitest'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
test.each([
|
||||
{ code: '## Simple\nnode', documentation: 'Simple' },
|
||||
{
|
||||
code: '## Preferred indent\n 2nd line\n 3rd line\nnode',
|
||||
documentation: 'Preferred indent\n2nd line\n3rd line',
|
||||
},
|
||||
{
|
||||
code: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
|
||||
documentation: 'Extra-indented child\n2nd line\n3rd line',
|
||||
normalized: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
|
||||
},
|
||||
{
|
||||
code: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
|
||||
documentation: 'Extra-indented child, beyond 4th column\n2nd line\n 3rd line',
|
||||
normalized: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
|
||||
},
|
||||
{
|
||||
code: '##Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
|
||||
documentation: 'Preferred indent, no initial space\n2nd line\n3rd line',
|
||||
normalized: '## Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
|
||||
},
|
||||
{
|
||||
code: '## Minimum indent\n 2nd line\n 3rd line\nnode',
|
||||
documentation: 'Minimum indent\n2nd line\n3rd line',
|
||||
normalized: '## Minimum indent\n 2nd line\n 3rd line\nnode',
|
||||
},
|
||||
])('Documentation edit round-trip: $code', (docCase) => {
|
||||
const { code, documentation } = docCase
|
||||
const parsed = Ast.parseStatement(code)!
|
||||
const parsedDocumentation = parsed.documentationText()
|
||||
expect(parsedDocumentation).toBe(documentation)
|
||||
const edited = Ast.MutableModule.Transient().copy(parsed)
|
||||
assert('setDocumentationText' in edited)
|
||||
edited.setDocumentationText(parsedDocumentation)
|
||||
expect(edited.code()).toBe(docCase.normalized ?? code)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'## Some documentation\nf x = 123',
|
||||
'## Some documentation\n and a second line\nf x = 123',
|
||||
'## Some documentation## Another documentation??\nf x = 123',
|
||||
])('Finding documentation: $code', (code) => {
|
||||
const block = Ast.parseBlock(code)
|
||||
const method = Ast.findModuleMethod(block, 'f')!.statement
|
||||
expect(method.documentationText()).toBeTruthy()
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
code: '## Already documented\nf x = 123',
|
||||
expected: '## Already documented\nf x = 123',
|
||||
},
|
||||
{
|
||||
code: 'f x = 123',
|
||||
expected: '##\nf x = 123',
|
||||
},
|
||||
])('Adding documentation: $code', ({ code, expected }) => {
|
||||
const block = Ast.parseBlock(code)
|
||||
const module = block.module
|
||||
const method = module.getVersion(Ast.findModuleMethod(block, 'f')!.statement)
|
||||
if (method.documentationText() === undefined) {
|
||||
method.setDocumentationText('')
|
||||
}
|
||||
expect(block.code()).toBe(expected)
|
||||
})
|
||||
|
||||
test('Creating comments', () => {
|
||||
const block = Ast.parseBlock('2 + 2')
|
||||
block.module.setRoot(block)
|
||||
const statement = [...block.statements()][0]! as Ast.MutableExpressionStatement
|
||||
const docText = 'Calculate five'
|
||||
statement.setDocumentationText(docText)
|
||||
expect(statement.module.root()?.code()).toBe(`## ${docText}\n2 + 2`)
|
||||
})
|
||||
|
||||
test('Creating comments: indented', () => {
|
||||
const block = Ast.parseBlock('main =\n x = 1')
|
||||
const module = block.module
|
||||
module.setRoot(block)
|
||||
const main = module.getVersion(Ast.findModuleMethod(block, 'main')!.statement)
|
||||
const statement = [...main.bodyAsBlock().statements()][0]! as Ast.MutableAssignment
|
||||
const docText = 'The smallest natural number'
|
||||
statement.setDocumentationText(docText)
|
||||
expect(statement.module.root()?.code()).toBe(`main =\n ## ${docText}\n x = 1`)
|
||||
})
|
@ -1,9 +1,9 @@
|
||||
import { RawAst, rawParseModule, readAstOrTokenSpan, walkRecursive } from '@/util/ast/raw'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { assert, expect, test } from 'vitest'
|
||||
import { Token, Tree } from 'ydoc-shared/ast/generated/ast'
|
||||
import type { LazyObject } from 'ydoc-shared/ast/parserSupport'
|
||||
import { assertDefined } from 'ydoc-shared/util/assert'
|
||||
import { tryGetSoleValue } from 'ydoc-shared/util/data/iterable'
|
||||
|
||||
/**
|
||||
* Read a single line of code
|
||||
@ -12,7 +12,7 @@ import { tryGetSoleValue } from 'ydoc-shared/util/data/iterable'
|
||||
*/
|
||||
function rawParseLine(code: string): RawAst.Tree {
|
||||
const block = rawParseModule(code)
|
||||
const soleExpression = tryGetSoleValue(block.statements)?.expression
|
||||
const soleExpression = iter.tryGetSoleValue(block.statements)?.expression
|
||||
assertDefined(soleExpression)
|
||||
return soleExpression
|
||||
}
|
||||
|
@ -1,34 +1,40 @@
|
||||
import { assert, assertDefined } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { reactiveModule } from '@/util/ast/reactive'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import { expect, test } from 'vitest'
|
||||
import { nextTick, watchEffect } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
function getAppAtModuleRoot(module: Ast.MutableModule) {
|
||||
const expressionStatement = iter.first(module.root()!.statements())
|
||||
assert(expressionStatement instanceof Ast.MutableExpressionStatement)
|
||||
const app2 = expressionStatement.expression
|
||||
assert(app2 instanceof Ast.MutableApp)
|
||||
return app2
|
||||
}
|
||||
|
||||
test('Module reactivity: applyEdit', async () => {
|
||||
const beforeEdit = Ast.parseBlock('func arg1 arg2')
|
||||
const beforeEdit = Ast.parseModule('func arg1 arg2')
|
||||
beforeEdit.module.setRoot(beforeEdit)
|
||||
|
||||
const module = reactiveModule(new Y.Doc(), () => {})
|
||||
module.applyEdit(beforeEdit.module)
|
||||
expect(module.root()!.code()).toBe(beforeEdit.code())
|
||||
expect(module.root()?.code()).toBe(beforeEdit.code())
|
||||
|
||||
const app2 = (
|
||||
(module.root() as Ast.MutableBodyBlock).lines[0]!.statement!
|
||||
.node as Ast.MutableExpressionStatement
|
||||
).expression as unknown as Ast.App
|
||||
const app2 = getAppAtModuleRoot(module)
|
||||
let app2Code: string | undefined = undefined
|
||||
watchEffect(() => (app2Code = app2.argument.code()))
|
||||
expect(app2Code).toBe('arg2')
|
||||
|
||||
const edit = beforeEdit.module.edit()
|
||||
const editApp2 = (
|
||||
edit.getVersion(beforeEdit).lines[0]!.statement!.node as Ast.MutableExpressionStatement
|
||||
).expression as Ast.MutableApp
|
||||
const editApp2 = getAppAtModuleRoot(edit)
|
||||
const newArg = Ast.Ident.tryParse('newArg', edit)
|
||||
assertDefined(newArg)
|
||||
expect(newArg).toBeDefined()
|
||||
editApp2.setArgument(newArg!)
|
||||
editApp2.setArgument(newArg)
|
||||
const codeAfterEdit = 'func arg1 newArg'
|
||||
expect(edit.root()!.code()).toBe(codeAfterEdit)
|
||||
expect(edit.root()?.code()).toBe(codeAfterEdit)
|
||||
|
||||
module.applyEdit(edit)
|
||||
expect(app2Code).toBe('arg2')
|
||||
@ -37,21 +43,21 @@ test('Module reactivity: applyEdit', async () => {
|
||||
})
|
||||
|
||||
test('Module reactivity: Direct Edit', async () => {
|
||||
const beforeEdit = Ast.parseExpression('func arg1 arg2')
|
||||
const beforeEdit = Ast.parseModule('func arg1 arg2')
|
||||
beforeEdit.module.setRoot(beforeEdit)
|
||||
|
||||
const module = reactiveModule(new Y.Doc(), () => {})
|
||||
module.applyEdit(beforeEdit.module)
|
||||
expect(module.root()!.code()).toBe(beforeEdit.code())
|
||||
expect(module.root()?.code()).toBe(beforeEdit.code())
|
||||
|
||||
const app2 = module.root() as unknown as Ast.MutableApp
|
||||
const app2 = getAppAtModuleRoot(module)
|
||||
let app2Code: string | undefined = undefined
|
||||
watchEffect(() => (app2Code = app2.argument.code()))
|
||||
expect(app2Code).toBe('arg2')
|
||||
|
||||
app2.setArgument(Ast.Ident.tryParse('newArg', module)!)
|
||||
const codeAfterEdit = 'func arg1 newArg'
|
||||
expect(module.root()!.code()).toBe(codeAfterEdit)
|
||||
expect(module.root()?.code()).toBe(codeAfterEdit)
|
||||
|
||||
expect(app2Code).toBe('arg2')
|
||||
await nextTick()
|
||||
@ -66,14 +72,19 @@ test('Module reactivity: Tracking access to ancestors', async () => {
|
||||
module.applyEdit(beforeEdit.module)
|
||||
expect(module.root()!.code()).toBe(beforeEdit.code())
|
||||
|
||||
const block = module.root() as any as Ast.BodyBlock
|
||||
const block = module.root()
|
||||
assertDefined(block)
|
||||
|
||||
const [func, otherFunc] = block.statements() as [Ast.Function, Ast.Function]
|
||||
const [func, otherFunc] = block.statements()
|
||||
assert(func instanceof Ast.MutableFunctionDef)
|
||||
assert(otherFunc instanceof Ast.MutableFunctionDef)
|
||||
expect(func.name.code()).toBe('main')
|
||||
expect(otherFunc.name.code()).toBe('other')
|
||||
const expression = Array.from(func.bodyExpressions())[0]!
|
||||
const expression = iter.first(func.bodyExpressions())
|
||||
assert(!!expression?.isExpression())
|
||||
expect(expression.code()).toBe('23')
|
||||
const otherExpression = Array.from(otherFunc.bodyExpressions())[0]!
|
||||
const otherExpression = iter.first(otherFunc.bodyExpressions())
|
||||
assert(!!otherExpression?.isExpression())
|
||||
expect(otherExpression.code()).toBe('f')
|
||||
|
||||
let parentAccesses = 0
|
||||
@ -84,7 +95,9 @@ test('Module reactivity: Tracking access to ancestors', async () => {
|
||||
expect(parentAccesses).toBe(1)
|
||||
|
||||
const edit = beforeEdit.module.edit()
|
||||
const taken = edit.getVersion(expression).replaceValue(Ast.parseExpression('replacement', edit))
|
||||
const replacementValue = Ast.parseExpression('replacement', edit)
|
||||
assertDefined(replacementValue)
|
||||
const taken = edit.getVersion(expression).replaceValue(replacementValue)
|
||||
edit.getVersion(otherExpression).updateValue((oe) => Ast.App.positional(oe, taken, edit))
|
||||
module.applyEdit(edit)
|
||||
|
||||
|
@ -1,32 +1,41 @@
|
||||
import { normalizeQualifiedName, qnFromSegments } from '@/util/qualifiedName'
|
||||
import {
|
||||
Ast,
|
||||
BodyBlock,
|
||||
import type {
|
||||
Expression,
|
||||
Function,
|
||||
Ident,
|
||||
IdentifierOrOperatorIdentifier,
|
||||
Mutable,
|
||||
MutableExpression,
|
||||
MutableStatement,
|
||||
Owned,
|
||||
QualifiedName,
|
||||
Statement,
|
||||
} from 'ydoc-shared/ast'
|
||||
import {
|
||||
App,
|
||||
Ast,
|
||||
BodyBlock,
|
||||
FunctionDef,
|
||||
Group,
|
||||
Ident,
|
||||
MutableAst,
|
||||
MutableBodyBlock,
|
||||
MutableExpression,
|
||||
MutableFunction,
|
||||
MutableFunctionDef,
|
||||
MutableIdent,
|
||||
MutableModule,
|
||||
MutablePropertyAccess,
|
||||
MutableStatement,
|
||||
NegationApp,
|
||||
NumericLiteral,
|
||||
OprApp,
|
||||
Owned,
|
||||
PropertyAccess,
|
||||
QualifiedName,
|
||||
Statement,
|
||||
Token,
|
||||
Wildcard,
|
||||
abstract,
|
||||
isTokenId,
|
||||
parseExpression,
|
||||
print,
|
||||
rawParseModule,
|
||||
setExternalIds,
|
||||
} from 'ydoc-shared/ast'
|
||||
import { spanMapToIdMap, spanMapToSpanGetter } from 'ydoc-shared/ast/idMap'
|
||||
import { IdMap } from 'ydoc-shared/yjsModel'
|
||||
|
||||
export * from 'ydoc-shared/ast'
|
||||
|
||||
@ -39,7 +48,7 @@ export function deserializeExpression(serialized: string): Owned<MutableExpressi
|
||||
|
||||
/** Returns a serialized representation of the expression. */
|
||||
export function serializeExpression(ast: Expression): string {
|
||||
return print(ast).code
|
||||
return ast.code()
|
||||
}
|
||||
|
||||
export type TokenTree = (TokenTree | string)[]
|
||||
@ -76,7 +85,7 @@ export function tokenTreeWithIds(root: Ast): TokenTree {
|
||||
export function moduleMethodNames(topLevel: BodyBlock): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const statement of topLevel.statements()) {
|
||||
if (statement instanceof Function) result.add(statement.name.code())
|
||||
if (statement instanceof FunctionDef) result.add(statement.name.code())
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -84,26 +93,26 @@ export function moduleMethodNames(topLevel: BodyBlock): Set<string> {
|
||||
export function findModuleMethod(
|
||||
topLevel: MutableBodyBlock,
|
||||
name: string,
|
||||
): { statement: MutableFunction; index: number } | undefined
|
||||
): { statement: MutableFunctionDef; index: number } | undefined
|
||||
export function findModuleMethod(
|
||||
topLevel: BodyBlock,
|
||||
name: string,
|
||||
): { statement: Function; index: number } | undefined
|
||||
): { statement: FunctionDef; index: number } | undefined
|
||||
/** Find the definition of the function with the specified name in the given block. */
|
||||
export function findModuleMethod(
|
||||
topLevel: BodyBlock,
|
||||
name: string,
|
||||
): { statement: Function; index: number } | undefined {
|
||||
): { statement: FunctionDef; index: number } | undefined {
|
||||
// FIXME: We should use alias analysis to handle shadowing correctly.
|
||||
const isFunctionWithName = (statement: Statement, name: string) =>
|
||||
statement instanceof Function && statement.name.code() === name
|
||||
statement instanceof FunctionDef && statement.name.code() === name
|
||||
const index = topLevel.lines.findIndex(
|
||||
(line) => line.statement && isFunctionWithName(line.statement.node, name),
|
||||
)
|
||||
if (index === -1) return undefined
|
||||
const statement = topLevel.lines[index]!.statement!.node as Function
|
||||
const statement = topLevel.lines[index]!.statement!.node as FunctionDef
|
||||
return {
|
||||
/** The `Function` definition. */
|
||||
/** The function definition. */
|
||||
statement,
|
||||
/** The index into the block's `lines` where the definition was found. */
|
||||
index,
|
||||
@ -271,6 +280,79 @@ export function dropMutability<T extends Ast>(value: Owned<Mutable<T>>): T {
|
||||
return value as unknown as T
|
||||
}
|
||||
|
||||
function unwrapGroups(ast: Ast) {
|
||||
while (ast instanceof Group && ast.expression) ast = ast.expression
|
||||
return ast
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to recognize inputs that are semantically-equivalent to a sequence of `App`s, and returns the arguments
|
||||
* identified and LHS of the analyzable chain.
|
||||
*
|
||||
* In particular, this function currently recognizes syntax used in visualization-preprocessor expressions.
|
||||
*/
|
||||
export function analyzeAppLike(ast: Ast): { func: Ast; args: Ast[] } {
|
||||
const deferredOperands = new Array<Ast>()
|
||||
while (
|
||||
ast instanceof OprApp &&
|
||||
ast.operator.ok &&
|
||||
ast.operator.value.code() === '<|' &&
|
||||
ast.lhs &&
|
||||
ast.rhs
|
||||
) {
|
||||
deferredOperands.push(unwrapGroups(ast.rhs))
|
||||
ast = unwrapGroups(ast.lhs)
|
||||
}
|
||||
deferredOperands.reverse()
|
||||
const args = new Array<Ast>()
|
||||
while (ast instanceof App) {
|
||||
const deferredOperand = ast.argument instanceof Wildcard ? deferredOperands.pop() : undefined
|
||||
args.push(deferredOperand ?? unwrapGroups(ast.argument))
|
||||
ast = ast.function
|
||||
}
|
||||
args.reverse()
|
||||
return { func: ast, args }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unroll the provided chain of `PropertyAccess` nodes, returning the first non-access as `subject` and the accesses
|
||||
* from left-to-right.
|
||||
*/
|
||||
export function accessChain(ast: Expression): {
|
||||
subject: Expression
|
||||
accessChain: PropertyAccess[]
|
||||
} {
|
||||
const accessChain = new Array<PropertyAccess>()
|
||||
while (ast instanceof PropertyAccess && ast.lhs) {
|
||||
accessChain.push(ast)
|
||||
ast = ast.lhs
|
||||
}
|
||||
accessChain.reverse()
|
||||
return { subject: ast, accessChain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the input, and apply the given `IdMap`. Return the parsed tree, the updated `IdMap`, the span map, and a
|
||||
* mapping to the `RawAst` representation.
|
||||
*/
|
||||
export function parseUpdatingIdMap(
|
||||
code: string,
|
||||
idMap?: IdMap | undefined,
|
||||
inModule?: MutableModule,
|
||||
) {
|
||||
const rawRoot = rawParseModule(code)
|
||||
const module = inModule ?? MutableModule.Transient()
|
||||
const { root, spans, toRaw } = module.transact(() => {
|
||||
const { root, spans, toRaw } = abstract(module, rawRoot, code)
|
||||
root.module.setRoot(root)
|
||||
if (idMap) setExternalIds(root.module, spans, idMap)
|
||||
return { root, spans, toRaw }
|
||||
})
|
||||
const getSpan = spanMapToSpanGetter(spans)
|
||||
const idMapOut = spanMapToIdMap(spans)
|
||||
return { root, idMap: idMapOut, getSpan, toRaw }
|
||||
}
|
||||
|
||||
declare const tokenKey: unique symbol
|
||||
declare module '@/providers/widgetRegistry' {
|
||||
export interface WidgetInputTypes {
|
||||
|
@ -9,13 +9,13 @@ import {
|
||||
walkRecursive,
|
||||
} from '@/util/ast/raw'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as sha256 from 'lib0/hash/sha256'
|
||||
import * as map from 'lib0/map'
|
||||
import { markRaw } from 'vue'
|
||||
import * as Ast from 'ydoc-shared/ast/generated/ast'
|
||||
import { Token, Tree } from 'ydoc-shared/ast/generated/ast'
|
||||
import { tryGetSoleValue } from 'ydoc-shared/util/data/iterable'
|
||||
import type { ExternalId, IdMap, SourceRange } from 'ydoc-shared/yjsModel'
|
||||
|
||||
export { AstExtended as RawAstExtended }
|
||||
@ -63,7 +63,7 @@ class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends boolea
|
||||
const block = AstExtended.parse(code)
|
||||
assert(block.isTree(Tree.Type.BodyBlock))
|
||||
return block.map((block) => {
|
||||
const soleStatement = tryGetSoleValue(block.statements)
|
||||
const soleStatement = iter.tryGetSoleValue(block.statements)
|
||||
assertDefined(soleStatement?.expression)
|
||||
return soleStatement.expression
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { assert, assertDefined } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { zipLongest } from '@/util/data/iterable'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
|
||||
/**
|
||||
* A pattern is an AST object with "placeholder" expressions.
|
||||
@ -68,7 +68,7 @@ export class Pattern<T extends Ast.Ast = Ast.Expression> {
|
||||
): Ast.Owned<Ast.Mutable<T>> {
|
||||
const template = edit.copy(this.template)
|
||||
const placeholders = findPlaceholders(template, this.placeholder).map((ast) => edit.tryGet(ast))
|
||||
for (const [placeholder, replacement] of zipLongest(placeholders, subtrees)) {
|
||||
for (const [placeholder, replacement] of iter.zipLongest(placeholders, subtrees)) {
|
||||
assertDefined(placeholder)
|
||||
assertDefined(replacement)
|
||||
placeholder.replace(replacement)
|
||||
@ -118,7 +118,7 @@ function matchSubtree(
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [patternNode, targetNode] of zipLongest(pattern.children(), target.children())) {
|
||||
for (const [patternNode, targetNode] of iter.zipLongest(pattern.children(), target.children())) {
|
||||
if (!patternNode || !targetNode) return false
|
||||
if (patternNode instanceof Ast.Token && targetNode instanceof Ast.Token) {
|
||||
if (patternNode.code() !== targetNode.code()) return false
|
||||
|
@ -1,5 +1,5 @@
|
||||
/** @file Configuration options for an application. */
|
||||
import CONFIG from '@/config.json' assert { type: 'json' }
|
||||
import CONFIG from '@/config.json' with { type: 'json' }
|
||||
|
||||
export type ApplicationConfig = typeof baseConfig
|
||||
export type ApplicationConfigValue = ConfigValue<typeof baseConfig>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { assertDefined, assertEqual } from '@/util/assert'
|
||||
import type { NonEmptyArray } from '@/util/data/array'
|
||||
import { mapIterator } from 'lib0/iterator'
|
||||
import * as iter from 'enso-common/src/utilities/data/iter'
|
||||
|
||||
/**
|
||||
* Map that supports Object-based keys.
|
||||
@ -85,9 +85,7 @@ export class MappedSet<T extends object> {
|
||||
private readonly valueMapper: (key: T) => any,
|
||||
elements: Iterable<T> = [],
|
||||
) {
|
||||
this.set = new Map(
|
||||
mapIterator(elements[Symbol.iterator](), (elem) => [valueMapper(elem), elem]),
|
||||
)
|
||||
this.set = new Map(iter.map(elements, (elem) => [valueMapper(elem), elem]))
|
||||
}
|
||||
|
||||
/** Add the given value to the set. */
|
||||
|
@ -1,33 +0,0 @@
|
||||
export * from 'ydoc-shared/util/data/iterable'
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function* filterDefined<T>(iterable: Iterable<T | undefined>): IterableIterator<T> {
|
||||
for (const value of iterable) {
|
||||
if (value !== undefined) yield value
|
||||
}
|
||||
}
|
||||
|
||||
/** TODO: Add docs */
|
||||
export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
|
||||
for (const value of iter) if (!f(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Return the first element returned by the iterable which meets the condition. */
|
||||
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
|
||||
for (const value of iter) {
|
||||
if (f(value)) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Return last element returned by the iterable.
|
||||
* NOTE: Linear complexity. This function always visits the whole iterable. Using this with an
|
||||
* infinite generator will cause an infinite loop.
|
||||
*/
|
||||
export function last<T>(iter: Iterable<T>): T | undefined {
|
||||
let last
|
||||
for (const el of iter) last = el
|
||||
return last
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user