Selection formatting toolbar (#10027)

* Selection formatting toolbar

* Icons

* Review

* Fix bold+italic rendering

* Preparing for top bar

* Refactor
This commit is contained in:
Kaz Wesley 2024-05-24 07:01:53 -07:00 committed by GitHub
parent 0f6b29c88f
commit 001e8caf67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 261 additions and 54 deletions

View File

@ -22,7 +22,7 @@ import MarkdownEditor from '@/components/MarkdownEditor.vue'
import PlusButton from '@/components/PlusButton.vue'
import ResizeHandles from '@/components/ResizeHandles.vue'
import SceneScroller from '@/components/SceneScroller.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import SvgButton from '@/components/SvgButton.vue'
import TopBar from '@/components/TopBar.vue'
import { useAstDocumentation } from '@/composables/astDocumentation'
import { useDoubleClick } from '@/composables/doubleClick'
@ -611,7 +611,7 @@ const groupColors = computed(() => {
<div class="scrollArea">
<MarkdownEditor v-model="documentation" />
</div>
<SvgIcon
<SvgButton
name="close"
class="closeButton button"
@click.stop="showDocumentationEditor = false"
@ -676,13 +676,13 @@ const groupColors = computed(() => {
border-radius: 7px 0 0;
background-color: rgba(255, 255, 255, 0.35);
backdrop-filter: var(--blur-app-bg);
padding: 4px 12px 0 6px;
/* Prevent absolutely-positioned children (such as the close button) from bypassing the show/hide animation. */
overflow-x: clip;
padding: 4px 12px 0 0;
}
.rightDock-enter-active,
.rightDock-leave-active {
transition: left 0.25s ease;
/* Prevent absolutely-positioned children (such as the close button) from bypassing the show/hide animation. */
overflow-x: clip;
}
.rightDock-enter-from,
.rightDock-leave-to {
@ -692,6 +692,7 @@ const groupColors = computed(() => {
width: 100%;
height: 100%;
overflow-y: auto;
padding-left: 6px;
}
.rightDock .closeButton {

View File

@ -411,7 +411,6 @@ declare module '@/providers/widgetRegistry' {
.arrow {
position: absolute;
pointer-events: none;
bottom: -8px;
left: 50%;
transform: translateX(-50%) rotate(90deg) scale(0.7);

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useLexical, type LexicalPlugin } from '@/components/lexical'
import LexicalContent from '@/components/lexical/LexicalContent.vue'
import { useLexicalSync } from '@/components/lexical/sync'
import { useLexicalStringSync } from '@/components/lexical/sync'
import { registerPlainText } from '@lexical/plain-text'
import { syncRef } from '@vueuse/core'
import { ref, type ComponentInstance } from 'vue'
@ -16,7 +16,7 @@ const plainText: LexicalPlugin = {
const textSync: LexicalPlugin = {
register: (editor) => {
const { content } = useLexicalSync(editor)
const { content } = useLexicalStringSync(editor)
content.value = text.value
syncRef(text, content, { immediate: false })
},

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { useSelectionBounds } from '@/composables/domSelection'
import { flip, offset, useFloating } from '@floating-ui/vue'
import type { MaybeElement } from '@vueuse/core'
import { computed, shallowRef, toRef } from 'vue'
const props = defineProps<{ selectionElement: MaybeElement }>()
const rootElement = shallowRef<HTMLElement>()
const { bounds: selectionBounds } = useSelectionBounds(toRef(props, 'selectionElement'))
const virtualElement = computed(() => {
const rect = selectionBounds.value?.toDomRect()
return rect ? { getBoundingClientRect: () => rect } : undefined
})
const { floatingStyles } = useFloating(virtualElement, rootElement, {
placement: 'top-start',
middleware: [
offset({
mainAxis: 4,
alignmentAxis: -2,
}),
flip(),
],
})
</script>
<template>
<div
v-if="selectionBounds"
ref="rootElement"
:style="floatingStyles"
class="FloatingSelectionMenu"
>
<slot />
</div>
</template>

View File

@ -1,9 +1,9 @@
<template>
<div ref="lexicalElement" class="lexical" spellcheck="false" contenteditable="true" />
<div ref="lexicalElement" class="LexicalContent" spellcheck="false" contenteditable="true" />
</template>
<style scoped>
.lexical {
.LexicalContent {
outline-style: none;
}
</style>

View File

@ -1,8 +1,11 @@
<script setup lang="ts">
import { useLexical, type LexicalPlugin } from '@/components/lexical'
import FloatingSelectionMenu from '@/components/lexical/FloatingSelectionMenu.vue'
import LexicalContent from '@/components/lexical/LexicalContent.vue'
import SelectionFormattingToolbar from '@/components/lexical/SelectionFormattingToolbar.vue'
import { useFormatting } from '@/components/lexical/formatting'
import { listPlugin } from '@/components/lexical/listPlugin'
import { useLexicalSync } from '@/components/lexical/sync'
import { useLexicalStringSync } from '@/components/lexical/sync'
import { CodeHighlightNode, CodeNode } from '@lexical/code'
import { AutoLinkNode, LinkNode } from '@lexical/link'
import { ListItemNode, ListNode } from '@lexical/list'
@ -15,11 +18,11 @@ import {
import { HeadingNode, QuoteNode, registerRichText } from '@lexical/rich-text'
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { syncRef } from '@vueuse/core'
import { ref, type ComponentInstance } from 'vue'
import { shallowRef, type ComponentInstance } from 'vue'
const markdown = defineModel<string>({ required: true })
const contentElement = ref<ComponentInstance<typeof LexicalContent>>()
const contentElement = shallowRef<ComponentInstance<typeof LexicalContent>>()
const markdownPlugin: LexicalPlugin = {
nodes: [
@ -43,7 +46,7 @@ const markdownPlugin: LexicalPlugin = {
const markdownSyncPlugin: LexicalPlugin = {
register: (editor) => {
const { content } = useLexicalSync(
const { content } = useLexicalStringSync(
editor,
() => $convertToMarkdownString(TRANSFORMERS),
(value) => $convertFromMarkdownString(value, TRANSFORMERS),
@ -53,16 +56,21 @@ const markdownSyncPlugin: LexicalPlugin = {
},
}
useLexical(contentElement, 'MarkdownEditor', [listPlugin, markdownPlugin, markdownSyncPlugin])
const { editor } = useLexical(contentElement, 'MarkdownEditor', [
listPlugin,
markdownPlugin,
markdownSyncPlugin,
])
const formatting = useFormatting(editor)
</script>
<template>
<LexicalContent
ref="contentElement"
class="MarkdownEditor fullHeight"
@wheel.stop
@contextmenu.stop
/>
<div class="MarkdownEditor fullHeight">
<LexicalContent ref="contentElement" class="fullHeight" @wheel.stop @contextmenu.stop />
<FloatingSelectionMenu :selectionElement="contentElement">
<SelectionFormattingToolbar :formatting="formatting" />
</FloatingSelectionMenu>
</div>
</template>
<style scoped>
@ -70,30 +78,42 @@ useLexical(contentElement, 'MarkdownEditor', [listPlugin, markdownPlugin, markdo
height: 100%;
}
.MarkdownEditor :deep(h1) {
.LexicalContent :deep(h1) {
font-weight: 700;
font-size: 16px;
line-height: 1.75;
}
.MarkdownEditor :deep(h2, h3, h4, h5, h6) {
.LexicalContent :deep(h2, h3, h4, h5, h6) {
font-size: 14px;
line-height: 2;
}
.MarkdownEditor :deep(p + p) {
.LexicalContent :deep(p + p) {
margin-bottom: 4px;
}
.MarkdownEditor :deep(ol) {
.LexicalContent :deep(ol) {
list-style-type: decimal;
list-style-position: outside;
padding-left: 1.6em;
}
.MarkdownEditor :deep(ul) {
.LexicalContent :deep(ul) {
list-style-type: disc;
list-style-position: outside;
padding-left: 1.6em;
}
.LexicalContent :deep(strong) {
font-weight: bold;
}
.LexicalContent :deep(.lexical-strikethrough) {
text-decoration: line-through;
}
.LexicalContent :deep(.lexical-italic) {
font-style: italic;
}
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import ToggleIcon from '@/components/ToggleIcon.vue'
import { type UseFormatting } from '@/components/lexical/formatting'
const props = defineProps<{ formatting: UseFormatting }>()
const { bold, italic, strikethrough } = props.formatting
</script>
<template>
<div class="SelectionFormattingToolbar">
<ToggleIcon v-model="bold" icon="bold" title="Bold" />
<ToggleIcon v-model="italic" icon="italic" title="Italic" />
<ToggleIcon v-model="strikethrough" icon="strike-through" title="Strikethrough" />
</div>
</template>
<style scoped>
.SelectionFormattingToolbar {
display: flex;
background-color: white;
border-radius: var(--radius-full);
padding: 4px;
gap: 4px;
}
</style>

View File

@ -0,0 +1,58 @@
import type { LexicalEditor, RangeSelection, TextFormatType } from 'lexical'
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { computed, ref } from 'vue'
export function useFormatting(editor: LexicalEditor) {
const selectionReaders = new Array<(selection: RangeSelection) => void>()
function onReadSelection(reader: (selection: RangeSelection) => void) {
selectionReaders.push(reader)
}
function $readState() {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
for (const reader of selectionReaders) {
reader(selection)
}
}
}
editor.registerUpdateListener(({ editorState }) => {
editorState.read($readState)
})
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, _newEditor) => {
$readState()
return false
},
COMMAND_PRIORITY_LOW,
)
return {
bold: useFormatProperty(editor, 'bold', onReadSelection),
italic: useFormatProperty(editor, 'italic', onReadSelection),
strikethrough: useFormatProperty(editor, 'strikethrough', onReadSelection),
}
}
export type UseFormatting = ReturnType<typeof useFormatting>
function useFormatProperty(
editor: LexicalEditor,
property: TextFormatType,
onReadSelection: ($readSelection: (selection: RangeSelection) => void) => void,
) {
const state = ref(false)
onReadSelection((selection) => (state.value = selection.hasFormat(property)))
return computed({
get: () => state.value,
set: (value) => {
if (value !== state.value) editor.dispatchCommand(FORMAT_TEXT_COMMAND, property)
},
})
}

View File

@ -6,7 +6,7 @@ import {
type LexicalNode,
type LexicalNodeReplacement,
} from 'lexical'
import { onMounted, type Ref } from 'vue'
import { markRaw, onMounted, type Ref } from 'vue'
type NodeDefinition = KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement
@ -23,13 +23,20 @@ export function useLexical(
const nodes = new Set<NodeDefinition>()
for (const node of plugins.flatMap((plugin) => plugin.nodes)) if (node) nodes.add(node)
const editor = createEditor({
editable: true,
namespace,
theme: {},
nodes: [...nodes],
onError: console.error,
})
const editor = markRaw(
createEditor({
editable: true,
namespace,
theme: {
text: {
strikethrough: 'lexical-strikethrough',
italic: 'lexical-italic',
},
},
nodes: [...nodes],
onError: console.error,
}),
)
onMounted(() => {
const element = unrefElement(contentElement.value)

View File

@ -1,6 +1,7 @@
import type { EditorState, LexicalEditor } from 'lexical'
import type { ToValue } from '@/util/reactivity'
import type { LexicalEditor } from 'lexical'
import { $createParagraphNode, $createTextNode, $getRoot, $setSelection } from 'lexical'
import { computed, ref } from 'vue'
import { computed, shallowRef, toValue } from 'vue'
const SYNC_TAG = 'ENSO_SYNC'
@ -9,12 +10,22 @@ const SYNC_TAG = 'ENSO_SYNC'
* By default, the editor's text contents are synchronized with the string. A content getter and setter may be provided
* to synchronize a different view of the state, e.g. to transform to an encoding that keeps rich text information.
*/
export function useLexicalSync(
export function useLexicalStringSync(
editor: LexicalEditor,
$getEditorContent: () => string = $getRootText,
$setEditorContent: (content: string) => void = $setRootText,
$setEditorContent: (content: string) => void = $setRootTextClearingSelection,
) {
const state = ref<EditorState>()
return useLexicalSync(editor, $getEditorContent, (content, prevContent) => {
if (content !== toValue(prevContent)) $setEditorContent(content)
})
}
export function useLexicalSync<T>(
editor: LexicalEditor,
$read: () => T,
$write: (content: T, prevContent: ToValue<T>) => void,
) {
const state = shallowRef(editor.getEditorState())
const unregister = editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has(SYNC_TAG)) return
@ -22,40 +33,37 @@ export function useLexicalSync(
})
const getContent = computed(() => {
if (!state.value) return ''
return state.value.read(() => $getEditorContent())
return state.value.read($read)
})
return {
content: computed({
get: () => getContent.value,
set: (content) => {
editor.update(
() => {
if (getContent.value !== content) $setEditorContent(content)
},
{
discrete: true,
skipTransforms: true,
tag: SYNC_TAG,
},
)
editor.update(() => $write(content, getContent), {
discrete: true,
skipTransforms: true,
tag: SYNC_TAG,
})
},
}),
unregister,
}
}
function $getRootText() {
export function $getRootText() {
return $getRoot().getTextContent()
}
function $setRootText(text: string) {
if (text === $getRoot().getTextContent()) return
export function $setRootText(text: string) {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
paragraph.append($createTextNode(text))
root.append(paragraph)
}
function $setRootTextClearingSelection(text: string) {
$setRootText(text)
$setSelection(null)
}

View File

@ -0,0 +1,42 @@
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'
export function useSelectionBounds(boundingElement: Ref<MaybeElement>) {
const bounds = ref<Rect>()
function getSelectionBounds(selection: Selection, element: Element) {
if (!selection.isCollapsed && element.contains(selection.anchorNode)) {
const domRange = selection.getRangeAt(0)
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 {
return undefined
}
}
function update() {
const selection = window.getSelection()
const element = unrefElement(boundingElement)
if (selection != null && element != null) {
bounds.value = getSelectionBounds(selection, element)
} else {
bounds.value = undefined
}
}
useEvent(document, 'selectionchange', update)
const size = useResizeObserver(boundingElement)
watch(size, update)
return { bounds }
}

View File

@ -187,6 +187,15 @@ export class Rect {
reflectXY() {
return new Rect(this.pos.reflectXY(), this.size.reflectXY())
}
toDomRect(): DOMRect {
return DOMRect.fromRect({
x: this.pos.x,
y: this.pos.y,
width: this.size.x,
height: this.size.y,
})
}
}
Rect.Zero = new Rect(Vec2.Zero, Vec2.Zero)