diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be9b97311..b0b8c74b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ disallowed changing it again.][10337] - [Added click through on table and vector visualisation][10340] clicking on index column will select row or value in seperate node +- [Added support for links in documentation panels][10353]. [10064]: https://github.com/enso-org/enso/pull/10064 [10179]: https://github.com/enso-org/enso/pull/10179 @@ -34,6 +35,7 @@ [10327]: https://github.com/enso-org/enso/pull/10327 [10337]: https://github.com/enso-org/enso/pull/10337 [10340]: https://github.com/enso-org/enso/pull/10340 +[10353]: https://github.com/enso-org/enso/pull/10353 #### Enso Standard Library diff --git a/app/gui2/shared/ast/parse.ts b/app/gui2/shared/ast/parse.ts index 8b78515e66..9e87c3e72f 100644 --- a/app/gui2/shared/ast/parse.ts +++ b/app/gui2/shared/ast/parse.ts @@ -271,9 +271,7 @@ class Abstractor { } case RawAst.Tree.Type.Documented: { const open = this.abstractToken(tree.documentation.open) - const elements = Array.from(tree.documentation.elements, (raw) => - this.abstractTextToken(raw), - ) + const elements = Array.from(tree.documentation.elements, this.abstractTextToken.bind(this)) const newlines = Array.from(tree.documentation.newlines, this.abstractToken.bind(this)) const expression = tree.expression ? this.abstractTree(tree.expression) : undefined node = Documented.concrete(this.module, open, elements, newlines, expression) diff --git a/app/gui2/src/bindings.ts b/app/gui2/src/bindings.ts index dc9f606abc..00b477d470 100644 --- a/app/gui2/src/bindings.ts +++ b/app/gui2/src/bindings.ts @@ -11,6 +11,7 @@ export const codeEditorBindings = defineKeybinds('code-editor', { export const documentationEditorBindings = defineKeybinds('documentation-editor', { toggle: ['Mod+D'], + openLink: ['Mod+PointerMain'], }) export const interactionBindings = defineKeybinds('current-interaction', { diff --git a/app/gui2/src/components/MarkdownEditor/FloatingSelectionMenu.vue b/app/gui2/src/components/FloatingSelectionMenu.vue similarity index 54% rename from app/gui2/src/components/MarkdownEditor/FloatingSelectionMenu.vue rename to app/gui2/src/components/FloatingSelectionMenu.vue index fee8f16a53..7b9830f6b7 100644 --- a/app/gui2/src/components/MarkdownEditor/FloatingSelectionMenu.vue +++ b/app/gui2/src/components/FloatingSelectionMenu.vue @@ -1,19 +1,24 @@ diff --git a/app/gui2/src/components/GraphEditor/GraphNodeComment.vue b/app/gui2/src/components/GraphEditor/GraphNodeComment.vue index f7bdcc0e85..9c21e2ffce 100644 --- a/app/gui2/src/components/GraphEditor/GraphNodeComment.vue +++ b/app/gui2/src/components/GraphEditor/GraphNodeComment.vue @@ -33,7 +33,7 @@ syncRef(editing, useFocusDelayed(textEditor).focused) diff --git a/app/gui2/src/components/lexical/LexicalContent.vue b/app/gui2/src/components/lexical/LexicalContent.vue index 5db4d6cef4..5fc55d5e75 100644 --- a/app/gui2/src/components/lexical/LexicalContent.vue +++ b/app/gui2/src/components/lexical/LexicalContent.vue @@ -14,5 +14,6 @@ diff --git a/app/gui2/src/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts b/app/gui2/src/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts new file mode 100644 index 0000000000..16ab36107a --- /dev/null +++ b/app/gui2/src/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest' +import { __TEST } from '..' + +const { URL_REGEX, EMAIL_REGEX } = __TEST + +test('Auto linking URL_REGEX', () => { + expect('www.a.b').toMatch(URL_REGEX) + expect('http://example.com').toMatch(URL_REGEX) + expect('https://a.b').toMatch(URL_REGEX) + expect('https://some.local').toMatch(URL_REGEX) + expect('http://AsDf.GhI').toMatch(URL_REGEX) + expect('https://xn--ls8h.la/').toMatch(URL_REGEX) + expect('https://💩.la/').not.toMatch(URL_REGEX) + expect('a.b').not.toMatch(URL_REGEX) + expect('a@b').not.toMatch(URL_REGEX) + expect('http://AsDf').not.toMatch(URL_REGEX) + expect('file://hello.world').not.toMatch(URL_REGEX) + expect('https://localhost').not.toMatch(URL_REGEX) + expect('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==').not.toMatch(URL_REGEX) + expect('](http://example.com').not.toMatch(URL_REGEX) +}) + +test('Auto linking EMAIL_REGEX', () => { + expect('example@gmail.com').toMatch(EMAIL_REGEX) + expect('EXAMPLE@GMAIL.COM').toMatch(EMAIL_REGEX) + expect('example..+hello.world@gmail.com').toMatch(EMAIL_REGEX) + expect('a@b.bla').toMatch(EMAIL_REGEX) + expect('(a@b.cd)').toMatch(EMAIL_REGEX) + expect('http://example.com').not.toMatch(EMAIL_REGEX) + expect('').not.toMatch(EMAIL_REGEX) + expect('a@b').not.toMatch(EMAIL_REGEX) + expect('a@b.c').not.toMatch(EMAIL_REGEX) +}) diff --git a/app/gui2/src/components/lexical/LinkPlugin/autoMatcher.ts b/app/gui2/src/components/lexical/LinkPlugin/autoMatcher.ts new file mode 100644 index 0000000000..97e5920811 --- /dev/null +++ b/app/gui2/src/components/lexical/LinkPlugin/autoMatcher.ts @@ -0,0 +1,464 @@ +import type { LinkAttributes } from '@lexical/link' +import type { ElementNode, LexicalEditor, LexicalNode, Spread } from 'lexical' + +export type AutoLinkAttributes = Partial> + +import { + $createAutoLinkNode, + $isAutoLinkNode, + $isLinkNode, + AutoLinkNode, + TOGGLE_LINK_COMMAND, +} from '@lexical/link' +import { mergeRegister } from '@lexical/utils' +import { + $createTextNode, + $getSelection, + $isElementNode, + $isLineBreakNode, + $isNodeSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_LOW, + TextNode, +} from 'lexical' +import { assert } from 'shared/util/assert' + +type ChangeHandler = (url: string | null, prevUrl: string | null) => void + +type MatchedLink = { + attributes?: AutoLinkAttributes + index: number + length: number + text: string + url: string +} + +export type LinkMatcher = (text: string) => MatchedLink | null + +export function createLinkMatcherWithRegExp( + regExp: RegExp, + urlTransformer: (text: string) => string = (text) => text, +) { + return (text: string) => { + const match = regExp.exec(text) + if (match === null) { + return null + } + return { + index: match.index, + length: match[0].length, + text: match[0], + url: urlTransformer(match[0]), + } + } +} + +function findFirstMatch(text: string, matchers: Array): MatchedLink | null { + for (let i = 0; i < matchers.length; i++) { + const match = matchers[i]!(text) + + if (match) { + return match + } + } + + return null +} + +const PUNCTUATION_OR_SPACE = /[.,;\s]/ + +function isSeparator(char: string): boolean { + return PUNCTUATION_OR_SPACE.test(char) +} + +function endsWithSeparator(textContent: string): boolean { + return isSeparator(textContent[textContent.length - 1] ?? '') +} + +function startsWithSeparator(textContent: string): boolean { + return isSeparator(textContent[0] ?? '') +} + +function startsWithFullStop(textContent: string): boolean { + return /^\.[a-zA-Z0-9]{1,}/.test(textContent) +} + +function isPreviousNodeValid(node: LexicalNode): boolean { + let previousNode = node.getPreviousSibling() + if ($isElementNode(previousNode)) { + previousNode = previousNode.getLastDescendant() + } + return ( + previousNode === null || + $isLineBreakNode(previousNode) || + ($isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent())) + ) +} + +function isNextNodeValid(node: LexicalNode): boolean { + let nextNode = node.getNextSibling() + if ($isElementNode(nextNode)) { + nextNode = nextNode.getFirstDescendant() + } + return ( + nextNode === null || + $isLineBreakNode(nextNode) || + ($isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent())) + ) +} + +function isContentAroundIsValid( + matchStart: number, + matchEnd: number, + text: string, + nodes: TextNode[], +): boolean { + const contentBeforeIsValid = + matchStart > 0 ? isSeparator(text[matchStart - 1]!) : isPreviousNodeValid(nodes[0]!) + if (!contentBeforeIsValid) { + return false + } + + const contentAfterIsValid = + matchEnd < text.length ? + isSeparator(text[matchEnd]!) + : isNextNodeValid(nodes[nodes.length - 1]!) + return contentAfterIsValid +} + +function extractMatchingNodes( + nodes: TextNode[], + startIndex: number, + endIndex: number, +): [ + matchingOffset: number, + unmodifiedBeforeNodes: TextNode[], + matchingNodes: TextNode[], + unmodifiedAfterNodes: TextNode[], +] { + const unmodifiedBeforeNodes: TextNode[] = [] + const matchingNodes: TextNode[] = [] + const unmodifiedAfterNodes: TextNode[] = [] + let matchingOffset = 0 + + let currentOffset = 0 + const currentNodes = [...nodes] + + while (currentNodes.length > 0) { + const currentNode = currentNodes[0]! + const currentNodeText = currentNode.getTextContent() + const currentNodeLength = currentNodeText.length + const currentNodeStart = currentOffset + const currentNodeEnd = currentOffset + currentNodeLength + + if (currentNodeEnd <= startIndex) { + unmodifiedBeforeNodes.push(currentNode) + matchingOffset += currentNodeLength + } else if (currentNodeStart >= endIndex) { + unmodifiedAfterNodes.push(currentNode) + } else { + matchingNodes.push(currentNode) + } + currentOffset += currentNodeLength + currentNodes.shift() + } + return [matchingOffset, unmodifiedBeforeNodes, matchingNodes, unmodifiedAfterNodes] +} + +function $createAutoLinkNode_( + nodes: TextNode[], + startIndex: number, + endIndex: number, + match: MatchedLink, +): TextNode | undefined { + const linkNode = $createAutoLinkNode(match.url, match.attributes) + if (nodes.length === 1) { + let remainingTextNode: TextNode | undefined = nodes[0]! + let linkTextNode: TextNode | undefined + console.log('startIndex', startIndex) + if (startIndex === 0) { + ;[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex) + } else { + ;[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex) + } + + assert(linkTextNode != null) + + const textNode = $createTextNode(match.text) + textNode.setFormat(linkTextNode.getFormat()) + textNode.setDetail(linkTextNode.getDetail()) + textNode.setStyle(linkTextNode.getStyle()) + linkNode.append(textNode) + + linkTextNode.replace(linkNode) + return remainingTextNode + } else if (nodes.length > 1) { + const firstTextNode = nodes[0]! + let offset = firstTextNode.getTextContent().length + let firstLinkTextNode: TextNode + if (startIndex === 0) { + firstLinkTextNode = firstTextNode + } else { + firstLinkTextNode = firstTextNode.splitText(startIndex)[1]! + } + const linkNodes = [] + let remainingTextNode: TextNode | undefined + for (let i = 1; i < nodes.length; i++) { + const currentNode = nodes[i]! + const currentNodeText = currentNode.getTextContent() + const currentNodeLength = currentNodeText.length + const currentNodeStart = offset + const currentNodeEnd = offset + currentNodeLength + if (currentNodeStart < endIndex) { + if (currentNodeEnd <= endIndex) { + linkNodes.push(currentNode) + } else { + const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart) + linkNodes.push(linkTextNode!) + remainingTextNode = endNode + } + } + offset += currentNodeLength + } + const selection = $getSelection() + const selectedTextNode = selection ? selection.getNodes().find($isTextNode) : undefined + const textNode = $createTextNode(firstLinkTextNode.getTextContent()) + textNode.setFormat(firstLinkTextNode.getFormat()) + textNode.setDetail(firstLinkTextNode.getDetail()) + textNode.setStyle(firstLinkTextNode.getStyle()) + linkNode.append(textNode, ...linkNodes) + // it does not preserve caret position if caret was at the first text node + // so we need to restore caret position + if (selectedTextNode && selectedTextNode === firstLinkTextNode) { + if ($isRangeSelection(selection)) { + textNode.select(selection.anchor.offset, selection.focus.offset) + } else if ($isNodeSelection(selection)) { + textNode.select(0, textNode.getTextContent().length) + } + } + firstLinkTextNode.replace(linkNode) + return remainingTextNode + } + return undefined +} + +function $handleLinkCreation( + nodes: TextNode[], + matchers: Array, + onChange: ChangeHandler, +): void { + let currentNodes = [...nodes] + const initialText = currentNodes.map((node) => node.getTextContent()).join('') + let text = initialText + let match + let invalidMatchEnd = 0 + + while ((match = findFirstMatch(text, matchers)) && match !== null) { + const matchStart = match.index + const matchLength = match.length + const matchEnd = matchStart + matchLength + const isValid = isContentAroundIsValid( + invalidMatchEnd + matchStart, + invalidMatchEnd + matchEnd, + initialText, + currentNodes, + ) + + if (isValid) { + const [matchingOffset, , matchingNodes, unmodifiedAfterNodes] = extractMatchingNodes( + currentNodes, + invalidMatchEnd + matchStart, + invalidMatchEnd + matchEnd, + ) + + const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset + const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset + const remainingTextNode = $createAutoLinkNode_( + matchingNodes, + actualMatchStart, + actualMatchEnd, + match, + ) + currentNodes = + remainingTextNode ? [remainingTextNode, ...unmodifiedAfterNodes] : unmodifiedAfterNodes + onChange(match.url, null) + invalidMatchEnd = 0 + } else { + invalidMatchEnd += matchEnd + } + + text = text.substring(matchEnd) + } +} + +function handleLinkEdit( + linkNode: AutoLinkNode, + matchers: Array, + onChange: ChangeHandler, +): void { + // Check children are simple text + const children = linkNode.getChildren() + const childrenLength = children.length + for (let i = 0; i < childrenLength; i++) { + const child = children[i] + if (!$isTextNode(child) || !child.isSimpleText()) { + replaceWithChildren(linkNode) + onChange(null, linkNode.getURL()) + return + } + } + + // Check text content fully matches + const text = linkNode.getTextContent() + const match = findFirstMatch(text, matchers) + if (match === null || match.text !== text) { + replaceWithChildren(linkNode) + onChange(null, linkNode.getURL()) + return + } + + // Check neighbors + if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) { + replaceWithChildren(linkNode) + onChange(null, linkNode.getURL()) + return + } + + const url = linkNode.getURL() + if (url !== match.url) { + linkNode.setURL(match.url) + onChange(match.url, url) + } + + if (match.attributes) { + const rel = linkNode.getRel() + if (rel !== match.attributes.rel) { + linkNode.setRel(match.attributes.rel || null) + onChange(match.attributes.rel || null, rel) + } + + const target = linkNode.getTarget() + if (target !== match.attributes.target) { + linkNode.setTarget(match.attributes.target || null) + onChange(match.attributes.target || null, target) + } + } +} + +// Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible. +// Given the creation preconditions, these can only be simple text nodes. +function handleBadNeighbors( + textNode: TextNode, + matchers: Array, + onChange: ChangeHandler, +): void { + const previousSibling = textNode.getPreviousSibling() + const nextSibling = textNode.getNextSibling() + const text = textNode.getTextContent() + + // FIXME: Uncommend usages of `IsUnlinked` once released lexical supports it. + if ( + $isAutoLinkNode(previousSibling) && + /*!previousSibling.getIsUnlinked() &&*/ + (!startsWithSeparator(text) || startsWithFullStop(text)) + ) { + previousSibling.append(textNode) + handleLinkEdit(previousSibling, matchers, onChange) + onChange(null, previousSibling.getURL()) + } + + if ( + $isAutoLinkNode(nextSibling) /*&& !nextSibling.getIsUnlinked()*/ && + !endsWithSeparator(text) + ) { + replaceWithChildren(nextSibling) + handleLinkEdit(nextSibling, matchers, onChange) + onChange(null, nextSibling.getURL()) + } +} + +function replaceWithChildren(node: ElementNode): Array { + const children = node.getChildren() + const childrenLength = children.length + + for (let j = childrenLength - 1; j >= 0; j--) { + node.insertAfter(children[j]!) + } + + node.remove() + return children.map((child) => child.getLatest()) +} + +function getTextNodesToMatch(textNode: TextNode): TextNode[] { + // check if next siblings are simple text nodes till a node contains a space separator + const textNodesToMatch = [textNode] + let nextSibling = textNode.getNextSibling() + while (nextSibling !== null && $isTextNode(nextSibling) && nextSibling.isSimpleText()) { + textNodesToMatch.push(nextSibling) + if (/[\s]/.test(nextSibling.getTextContent())) { + break + } + nextSibling = nextSibling.getNextSibling() + } + return textNodesToMatch +} + +export function useAutoLink( + editor: LexicalEditor, + matchers: Array, + onChange?: ChangeHandler, +): void { + assert( + editor.hasNodes([AutoLinkNode]), + 'LexicalAutoLinkPlugin: AutoLinkNode not registered on editor', + ) + + const onChangeWrapped = (url: string | null, prevUrl: string | null) => { + if (onChange) { + onChange(url, prevUrl) + } + } + + mergeRegister( + editor.registerNodeTransform(TextNode, (textNode: TextNode) => { + const parent = textNode.getParentOrThrow() + const previous = textNode.getPreviousSibling() + if ($isAutoLinkNode(parent) /*&& !parent.getIsUnlinked() */) { + handleLinkEdit(parent, matchers, onChangeWrapped) + } else if (!$isLinkNode(parent)) { + if ( + textNode.isSimpleText() && + (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous)) + ) { + const textNodesToMatch = getTextNodesToMatch(textNode) + $handleLinkCreation(textNodesToMatch, matchers, onChangeWrapped) + } + + handleBadNeighbors(textNode, matchers, onChangeWrapped) + } + }), + editor.registerCommand( + TOGGLE_LINK_COMMAND, + (payload) => { + const selection = $getSelection() + if (payload !== null || !$isRangeSelection(selection)) { + return false + } + const nodes = selection.extract() + nodes.forEach((node) => { + const parent = node.getParent() + + if ($isAutoLinkNode(parent)) { + // invert the value + // parent.setIsUnlinked(!parent.getIsUnlinked()) + parent.markDirty() + return true + } + }) + return false + }, + COMMAND_PRIORITY_LOW, + ), + ) +} diff --git a/app/gui2/src/components/lexical/LinkPlugin/index.ts b/app/gui2/src/components/lexical/LinkPlugin/index.ts new file mode 100644 index 0000000000..bca7a9b7e9 --- /dev/null +++ b/app/gui2/src/components/lexical/LinkPlugin/index.ts @@ -0,0 +1,143 @@ +import { documentationEditorBindings } from '@/bindings' +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 { $getNearestNodeOfType } from '@lexical/utils' +import { + $createTextNode, + $getSelection, + $isTextNode, + CLICK_COMMAND, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_LOW, + SELECTION_CHANGE_COMMAND, + type LexicalEditor, +} from 'lexical' +import { shallowRef } from 'vue' +import { createLinkMatcherWithRegExp, useAutoLink } from './autoMatcher' + +const URL_REGEX = + /(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ + +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 { + 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', +} + +export function $getSelectedLinkNode() { + const selection = $getSelection() + if (selection?.isCollapsed) { + const node = selection?.getNodes()[0] + if (node) { + return ( + $getNearestNodeOfType(node, LinkNode) ?? + $getNearestNodeOfType(node, AutoLinkNode) ?? + undefined + ) + } + } +} + +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() + if (link instanceof AutoLinkNode) { + window.open(link.getURL(), '_blank')?.focus() + return true + } + return false + }, +}) + +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 { + editor.registerCommand( + CLICK_COMMAND, + (event) => autoLinkClickHandler(event), + COMMAND_PRIORITY_CRITICAL, + ) + + useAutoLink(editor, [ + createLinkMatcherWithRegExp(URL_REGEX, (t) => (t.startsWith('http') ? t : `https://${t}`)), + createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => `mailto:${text}`), + ]) + }, +} + +export function useLinkNode(editor: LexicalEditor) { + const urlUnderCursor = shallowRef() + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + urlUnderCursor.value = $getSelectedLinkNode()?.getURL() + return false + }, + COMMAND_PRIORITY_LOW, + ) + return { urlUnderCursor } +} diff --git a/app/gui2/src/components/lexical/LinkToolbar.vue b/app/gui2/src/components/lexical/LinkToolbar.vue new file mode 100644 index 0000000000..9a015c1dbd --- /dev/null +++ b/app/gui2/src/components/lexical/LinkToolbar.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/app/gui2/src/composables/domSelection.ts b/app/gui2/src/composables/domSelection.ts index fdbcb4ec37..51c6b1321e 100644 --- a/app/gui2/src/composables/domSelection.ts +++ b/app/gui2/src/composables/domSelection.ts @@ -1,23 +1,30 @@ import { unrefElement, useEvent, useResizeObserver } from '@/composables/events' import { Rect } from '@/util/data/rect' import type { MaybeElement } from '@vueuse/core' -import { ref, watch, type Ref } from 'vue' +import { shallowRef, watch, type Ref } from 'vue' -export function useSelectionBounds(boundingElement: Ref) { - const bounds = ref() +export function useSelectionBounds(boundingElement: Ref, includeCollapsed = false) { + const bounds = shallowRef() + const collapsed = shallowRef() function getSelectionBounds(selection: Selection, element: Element) { - if (!selection.isCollapsed && element.contains(selection.anchorNode)) { - const domRange = selection.getRangeAt(0) + if ((includeCollapsed || !selection.isCollapsed) && element.contains(selection.anchorNode)) { if (selection.anchorNode === element) { let inner = element while (inner.firstElementChild != null) { inner = inner.firstElementChild as HTMLElement } return Rect.FromDomRect(inner.getBoundingClientRect()) - } else { - return Rect.FromDomRect(domRange.getBoundingClientRect()) + } else if (selection.isCollapsed && selection.anchorNode) { + const element = + selection.anchorNode instanceof Element ? + selection.anchorNode + : selection.anchorNode.parentElement + if (element) return Rect.FromDomRect(element.getBoundingClientRect()) } + + const domRange = selection.getRangeAt(0) + return Rect.FromDomRect(domRange.getBoundingClientRect()) } else { return undefined } @@ -26,6 +33,7 @@ export function useSelectionBounds(boundingElement: Ref) { function update() { const selection = window.getSelection() const element = unrefElement(boundingElement) + collapsed.value = selection?.isCollapsed if (selection != null && element != null) { bounds.value = getSelectionBounds(selection, element) } else { @@ -38,5 +46,5 @@ export function useSelectionBounds(boundingElement: Ref) { const size = useResizeObserver(boundingElement) watch(size, update) - return { bounds } + return { bounds, collapsed } } diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 01488939ab..0c970eb205 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -111,20 +111,18 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC const moduleRoot = ref() const topLevel = ref() - let disconnectSyncModule: undefined | (() => void) - watch(syncModule, (syncModule) => { + watch(syncModule, (syncModule, _, onCleanup) => { if (!syncModule) return let moduleChanged = true - disconnectSyncModule?.() const handle = syncModule.observe((update) => { moduleSource.applyUpdate(syncModule, update) handleModuleUpdate(syncModule, moduleChanged, update) moduleChanged = false }) - disconnectSyncModule = () => { + onCleanup(() => { syncModule.unobserve(handle) moduleSource.clear() - } + }) }) let toRaw = new Map() diff --git a/app/ide-desktop/lib/client/src/security.ts b/app/ide-desktop/lib/client/src/security.ts index 2064b2cbca..8825861cab 100644 --- a/app/ide-desktop/lib/client/src/security.ts +++ b/app/ide-desktop/lib/client/src/security.ts @@ -2,7 +2,6 @@ * guide: https://www.electronjs.org/docs/latest/tutorial/security. */ import * as electron from 'electron' -import * as common from 'enso-common' // ================= // === Constants === @@ -22,15 +21,8 @@ const TRUSTED_HOSTS = [ '127.0.0.1:30535', ] -/** The list of hosts that the app can open external links to. */ -const TRUSTED_EXTERNAL_HOSTS = [ - 'enso.org', - common.CLOUD_DASHBOARD_DOMAIN, - 'www.youtube.com', - 'discord.gg', - 'github.com', -] -const TRUSTED_EXTERNAL_PROTOCOLS = ['mailto:'] +/** The list of protocols that the app can open external links to. */ +const TRUSTED_EXTERNAL_PROTOCOLS = ['mailto:', 'http:', 'https:'] /** The list of URLs a new WebView can be pointed to. */ const WEBVIEW_URL_WHITELIST: string[] = [] @@ -160,10 +152,7 @@ function disableNewWindowsCreation() { contents.setWindowOpenHandler(details => { const { url } = details const parsedUrl = new URL(url) - if ( - TRUSTED_EXTERNAL_HOSTS.includes(parsedUrl.host) || - TRUSTED_EXTERNAL_PROTOCOLS.includes(parsedUrl.protocol) - ) { + if (TRUSTED_EXTERNAL_PROTOCOLS.includes(parsedUrl.protocol)) { void electron.shell.openExternal(url) return { action: 'deny' } } else {