Skip to content

Commit

Permalink
Selection formatting toolbar (#10027)
Browse files Browse the repository at this point in the history
* Selection formatting toolbar

* Icons

* Review

* Fix bold+italic rendering

* Preparing for top bar

* Refactor
  • Loading branch information
kazcw authored May 24, 2024
1 parent 0f6b29c commit 001e8ca
Show file tree
Hide file tree
Showing 12 changed files with 261 additions and 54 deletions.
11 changes: 6 additions & 5 deletions app/gui2/src/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -692,6 +692,7 @@ const groupColors = computed(() => {
width: 100%;
height: 100%;
overflow-y: auto;
padding-left: 6px;
}
.rightDock .closeButton {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions app/gui2/src/components/PlainTextEditor.vue
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 })
},
Expand Down
37 changes: 37 additions & 0 deletions app/gui2/src/components/lexical/FloatingSelectionMenu.vue
Original file line number Diff line number Diff line change
@@ -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>
4 changes: 2 additions & 2 deletions app/gui2/src/components/lexical/LexicalContent.vue
Original file line number Diff line number Diff line change
@@ -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>
52 changes: 36 additions & 16 deletions app/gui2/src/components/lexical/MarkdownEditorImpl.vue
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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: [
Expand All @@ -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),
Expand All @@ -53,47 +56,64 @@ 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>
.fullHeight {
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>
26 changes: 26 additions & 0 deletions app/gui2/src/components/lexical/SelectionFormattingToolbar.vue
Original file line number Diff line number Diff line change
@@ -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>
58 changes: 58 additions & 0 deletions app/gui2/src/components/lexical/formatting.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
}
23 changes: 15 additions & 8 deletions app/gui2/src/components/lexical/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
Loading

0 comments on commit 001e8ca

Please sign in to comment.