Merge branch 'develop' into wip/akirathan/11274-jvm-cmdlineopt-ni

This commit is contained in:
Pavel Marek 2024-11-13 14:19:35 +01:00
commit b89db13046
338 changed files with 5355 additions and 4802 deletions

View File

@ -1 +1 @@
20.11.1
22.11.0

View File

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

View File

@ -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"

View File

@ -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 ===

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

View File

@ -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
}

View File

@ -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 }
}

View 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

View File

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

View File

@ -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')
})

View File

@ -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,

View File

@ -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",

View File

@ -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'

View File

@ -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])

View 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

View File

@ -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%;
}

View File

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

View File

@ -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)]!,

View File

@ -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>()

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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),

View File

@ -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 {

View File

@ -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 =

View File

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

View File

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

View File

@ -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[] {

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

View File

@ -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 {

View File

@ -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"

View File

@ -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))
"

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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 ===

View File

@ -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',
})

View File

@ -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),
)

View File

@ -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'

View File

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

View File

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

View File

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

View File

@ -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, {

View File

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

View File

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

View File

@ -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
}

View File

@ -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 {},
}

View File

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

View File

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

View File

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

View File

@ -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
}

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

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

View File

@ -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
}

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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 {

View File

@ -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)"

View File

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

View File

@ -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">

View File

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

View File

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

View File

@ -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 = {

View File

@ -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
}

View File

@ -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<{

View File

@ -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)

View File

@ -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'

View File

@ -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,

View File

@ -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)

View File

@ -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() {

View File

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

View File

@ -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(

View File

@ -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 = {

View File

@ -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 {

View File

@ -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 }

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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]),
),
)

View File

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

View File

@ -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) => {

View File

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

View File

@ -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)

View File

@ -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`)
})

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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. */

View File

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