Implement markdown link syntax and "follow link" popup. (#10353)

Fixes #9907

Implements link support in the comment markdown editor. Supports both markdown-style links and auto-detection for raw URLs.

https://github.com/enso-org/enso/assets/919491/b38f860a-ee4a-41a8-b936-41e9ef1602b9


https://github.com/enso-org/enso/assets/919491/059beca2-7737-41a1-a136-31a42ef9ee48
This commit is contained in:
Paweł Grabarz 2024-06-27 16:18:33 +02:00 committed by GitHub
parent d95ebd4836
commit 410298e173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 765 additions and 56 deletions

View File

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

View File

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

View File

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

View File

@ -1,19 +1,24 @@
<script setup lang="ts">
import { useSelectionBounds } from '@/composables/domSelection'
import { useResizeObserver } from '@/composables/events'
import { flip, offset, useFloating } from '@floating-ui/vue'
import type { MaybeElement } from '@vueuse/core'
import { computed, shallowRef, toRef } from 'vue'
import { computed, shallowRef, toRef, watch } from 'vue'
const props = defineProps<{ selectionElement: MaybeElement }>()
const rootElement = shallowRef<HTMLElement>()
const rootSize = useResizeObserver(rootElement)
const { bounds: selectionBounds } = useSelectionBounds(toRef(props, 'selectionElement'))
const { bounds: selectionBounds, collapsed: selectionCollapsed } = useSelectionBounds(
toRef(props, 'selectionElement'),
true,
)
const virtualElement = computed(() => {
const rect = selectionBounds.value?.toDomRect()
return rect ? { getBoundingClientRect: () => rect } : undefined
})
const { floatingStyles } = useFloating(virtualElement, rootElement, {
const { floatingStyles, update } = useFloating(virtualElement, rootElement, {
placement: 'top-start',
middleware: [
offset({
@ -23,15 +28,12 @@ const { floatingStyles } = useFloating(virtualElement, rootElement, {
flip(),
],
})
watch(rootSize, update)
</script>
<template>
<div
v-if="selectionBounds"
ref="rootElement"
:style="floatingStyles"
class="FloatingSelectionMenu"
>
<slot />
<div ref="rootElement" :style="floatingStyles" class="FloatingSelectionMenu">
<slot v-if="selectionBounds" :collapsed="selectionCollapsed" />
</div>
</template>

View File

@ -33,7 +33,7 @@ syncRef(editing, useFocusDelayed(textEditor).focused)
</template>
<style scoped>
.GraphNodeComment > :deep(*) {
.GraphNodeComment > :deep(.LexicalContent) {
display: inline-block;
padding: 0 8px 0 8px;
min-width: 22px;

View File

@ -18,8 +18,8 @@ const IMAGE: TextMatchTransformer = {
}
return `![${node.getAltText()}](${node.getSrc()})`
},
importRegExp: /!\[([^[]+)]\(([^()\s]+)\)/,
regExp: /!\[([^[]+)]\(([^()\s]+)\)$/,
importRegExp: /!\[([^[]+)]\(([^()\n]+)\)/,
regExp: /!\[([^[]+)]\(([^()\n]+)\)$/,
replace: (textNode, match) => {
const [, altText, src] = match
assertDefined(altText)

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import FloatingSelectionMenu from '@/components/MarkdownEditor/FloatingSelectionMenu.vue'
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'
@ -13,6 +13,8 @@ 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'
const markdown = defineModel<string>({ required: true })
@ -25,21 +27,24 @@ const contentElement = shallowRef<ComponentInstance<typeof LexicalContent>>()
provideLexicalImageUrlTransformer(toRef(props, 'transformImageUrl'))
const theme = lexicalRichTextTheme(useCssModule('lexicalTheme'))
const { editor } = useLexical(
contentElement,
'MarkdownEditor',
theme,
markdownPlugin(markdown, [listPlugin, imagePlugin]),
)
const { editor } = useLexical(contentElement, 'MarkdownEditor', theme, [
...markdownPlugin(markdown, [listPlugin, imagePlugin, linkPlugin]),
autoLinkPlugin,
])
const formatting = useFormatting(editor)
const { urlUnderCursor } = useLinkNode(editor)
</script>
<template>
<div class="MarkdownEditor fullHeight">
<FormattingToolbar :formatting="formatting" @pointerdown.prevent />
<LexicalContent ref="contentElement" class="fullHeight" @wheel.stop @contextmenu.stop />
<LexicalContent ref="contentElement" @wheel.stop.passive @contextmenu.stop @pointerdown.stop />
<FloatingSelectionMenu :selectionElement="contentElement">
<SelectionFormattingToolbar :formatting="formatting" />
<template #default="{ collapsed }">
<SelectionFormattingToolbar v-if="!collapsed" :formatting="formatting" />
<LinkToolbar v-else-if="urlUnderCursor" :url="urlUnderCursor" />
</template>
</FloatingSelectionMenu>
<LexicalDecorators :editor="editor" />
</div>
@ -47,6 +52,8 @@ const formatting = useFormatting(editor)
<style scoped>
.fullHeight {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@ -56,3 +56,10 @@ using `_` to separate levels. See the `lexicalTheme` function in `lexical/format
display: inline;
margin: 0 0.1em;
}
.link {
color: #555;
&:hover {
text-decoration: underline;
}
}

View File

@ -1,9 +1,12 @@
<script setup lang="ts">
import { useLexical, type LexicalPlugin } from '@/components/lexical'
import FloatingSelectionMenu from '@/components/FloatingSelectionMenu.vue'
import { lexicalTheme, useLexical, type LexicalPlugin } from '@/components/lexical'
import LexicalContent from '@/components/lexical/LexicalContent.vue'
import { autoLinkPlugin, useLinkNode } from '@/components/lexical/LinkPlugin'
import LinkToolbar from '@/components/lexical/LinkToolbar.vue'
import { useLexicalStringSync } from '@/components/lexical/sync'
import { registerPlainText } from '@lexical/plain-text'
import { ref, watch, type ComponentInstance } from 'vue'
import { ref, useCssModule, watch, type ComponentInstance } from 'vue'
const text = defineModel<string>({ required: true })
@ -21,9 +24,27 @@ const textSync: LexicalPlugin = {
},
}
useLexical(contentElement, 'PlainTextEditor', {}, [plainText, textSync])
const theme = lexicalTheme(useCssModule('lexicalTheme'))
const { editor } = useLexical(contentElement, 'PlainTextEditor', theme, [
autoLinkPlugin,
plainText,
textSync,
])
const { urlUnderCursor } = useLinkNode(editor)
</script>
<template>
<LexicalContent ref="contentElement" />
<LexicalContent ref="contentElement" v-bind="$attrs" />
<FloatingSelectionMenu :selectionElement="contentElement">
<LinkToolbar v-if="urlUnderCursor" :url="urlUnderCursor" />
</FloatingSelectionMenu>
</template>
<style module="lexicalTheme">
.link {
color: #ddf;
&:hover {
text-decoration: underline;
}
}
</style>

View File

@ -14,5 +14,6 @@
<style scoped>
.LexicalContent {
outline-style: none;
flex: 1;
}
</style>

View File

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

View File

@ -0,0 +1,464 @@
import type { LinkAttributes } from '@lexical/link'
import type { ElementNode, LexicalEditor, LexicalNode, Spread } from 'lexical'
export type AutoLinkAttributes = Partial<Spread<LinkAttributes, { isUnlinked?: boolean }>>
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<LinkMatcher>): 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<LinkMatcher>,
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<LinkMatcher>,
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<LinkMatcher>,
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<LexicalNode> {
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<LinkMatcher>,
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,
),
)
}

View File

@ -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 =
/(?<!\]\()((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
const EMAIL_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<string>()
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
urlUnderCursor.value = $getSelectedLinkNode()?.getURL()
return false
},
COMMAND_PRIORITY_LOW,
)
return { urlUnderCursor }
}

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { documentationEditorBindings } from '@/bindings'
const props = defineProps<{ url: string }>()
</script>
<template>
<div class="LinkToolbar">
<a :href="props.url" target="_blank" rel="nofollow">Follow link</a>
<span class="shortcut"
>({{ documentationEditorBindings.bindings.openLink.humanReadable }})</span
>
</div>
</template>
<style scoped>
.LinkToolbar {
display: flex;
background-color: white;
border-radius: var(--radius-full);
padding: 4px;
gap: 4px;
}
a {
color: steelblue;
&:hover {
text-decoration: underline;
}
}
.shortcut {
color: lightslategray;
}
</style>

View File

@ -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<MaybeElement>) {
const bounds = ref<Rect>()
export function useSelectionBounds(boundingElement: Ref<MaybeElement>, includeCollapsed = false) {
const bounds = shallowRef<Rect>()
const collapsed = shallowRef<boolean>()
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<MaybeElement>) {
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<MaybeElement>) {
const size = useResizeObserver(boundingElement)
watch(size, update)
return { bounds }
return { bounds, collapsed }
}

View File

@ -111,20 +111,18 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
const moduleRoot = ref<Ast.Ast>()
const topLevel = ref<Ast.BodyBlock>()
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<SourceRangeKey, RawAst.Tree.Function>()

View File

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