mirror of
https://github.com/enso-org/enso.git
synced 2024-11-05 03:59:38 +03:00
Selection formatting toolbar (#10027)
* Selection formatting toolbar * Icons * Review * Fix bold+italic rendering * Preparing for top bar * Refactor
This commit is contained in:
parent
0f6b29c88f
commit
001e8caf67
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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 })
|
||||
},
|
||||
|
37
app/gui2/src/components/lexical/FloatingSelectionMenu.vue
Normal file
37
app/gui2/src/components/lexical/FloatingSelectionMenu.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
app/gui2/src/components/lexical/formatting.ts
Normal file
58
app/gui2/src/components/lexical/formatting.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
42
app/gui2/src/composables/domSelection.ts
Normal file
42
app/gui2/src/composables/domSelection.ts
Normal 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 }
|
||||
}
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user