mirror of
https://github.com/enso-org/enso.git
synced 2024-08-16 16:30:33 +03:00
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:
parent
d95ebd4836
commit
410298e173
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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', {
|
||||
|
@ -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>
|
@ -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;
|
||||
|
@ -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)
|
||||
|
@ -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%;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -14,5 +14,6 @@
|
||||
<style scoped>
|
||||
.LexicalContent {
|
||||
outline-style: none;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
@ -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)
|
||||
})
|
464
app/gui2/src/components/lexical/LinkPlugin/autoMatcher.ts
Normal file
464
app/gui2/src/components/lexical/LinkPlugin/autoMatcher.ts
Normal 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,
|
||||
),
|
||||
)
|
||||
}
|
143
app/gui2/src/components/lexical/LinkPlugin/index.ts
Normal file
143
app/gui2/src/components/lexical/LinkPlugin/index.ts
Normal 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 }
|
||||
}
|
35
app/gui2/src/components/lexical/LinkToolbar.vue
Normal file
35
app/gui2/src/components/lexical/LinkToolbar.vue
Normal 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>
|
@ -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 }
|
||||
}
|
||||
|
@ -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>()
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user