mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 01:21:33 +03:00
Comment UI refinement (#9271)
* Comment UI refinement - Eliminate edit mode: - Beginning edit does not change appearance - Edit text as rendered, not as formatted in code - Enter finishes edit - Shift+Enter inserts a newline - Click begins edit regardless of Ctrl
This commit is contained in:
parent
64e29f8761
commit
be4f04f7ff
@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorView as EditorViewType } from '@/components/CodeEditor/codemirror'
|
||||
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { defineKeybinds } from '@/util/shortcuts'
|
||||
import * as random from 'lib0/random'
|
||||
import { assertDefined } from 'shared/util/assert'
|
||||
import { textChangeToEdits } from 'shared/util/data/text'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
|
||||
const { minimalSetup, EditorState, EditorView, textEditToChangeSpec } = await import(
|
||||
'@/components/CodeEditor/codemirror'
|
||||
@ -16,83 +15,109 @@ const emit = defineEmits<{
|
||||
'update:editing': [boolean]
|
||||
}>()
|
||||
|
||||
const paragraphs = computed(() => props.modelValue.split('\n\n'))
|
||||
|
||||
const contentElement = ref<HTMLElement>()
|
||||
const editor = ref<EditorViewType>()
|
||||
|
||||
const interaction = injectInteractionHandler()
|
||||
interaction.setWhen(() => editor.value != null, {
|
||||
cancel() {
|
||||
finishEdit()
|
||||
},
|
||||
click(e: Event) {
|
||||
if (e.target instanceof Element && !contentElement.value?.contains(e.target)) finishEdit()
|
||||
return false
|
||||
},
|
||||
const text = computed({
|
||||
get: () => rawTextToCooked(props.modelValue),
|
||||
set: (value) => emit('update:modelValue', cookedTextToRaw(value)),
|
||||
})
|
||||
|
||||
const handleClick = defineKeybinds(`comment-${random.uint53()}`, {
|
||||
startEdit: ['Mod+PointerMain'],
|
||||
}).handler({ startEdit })
|
||||
const commentRoot = ref<HTMLElement>()
|
||||
const editor = ref<EditorViewType>()
|
||||
|
||||
const interactions = injectInteractionHandler()
|
||||
const editInteraction = {
|
||||
cancel: () => finishEdit(),
|
||||
click: (e: Event) => {
|
||||
if (e.target instanceof Element && !commentRoot.value?.contains(e.target)) finishEdit()
|
||||
return false
|
||||
},
|
||||
}
|
||||
interactions.setWhen(() => props.editing, editInteraction)
|
||||
onUnmounted(() => interactions.end(editInteraction))
|
||||
|
||||
function startEdit() {
|
||||
if (editor.value) {
|
||||
editor.value.focus()
|
||||
} else {
|
||||
const editorView = new EditorView()
|
||||
editorView.setState(EditorState.create({ extensions: [minimalSetup] }))
|
||||
contentElement.value!.prepend(editorView.dom)
|
||||
editor.value = editorView
|
||||
setTimeout(() => editorView.focus())
|
||||
}
|
||||
if (!props.editing) emit('update:editing', true)
|
||||
}
|
||||
|
||||
function finishEdit() {
|
||||
if (editor.value) {
|
||||
if (editor.value.state.doc.toString() !== props.modelValue)
|
||||
emit('update:modelValue', editor.value.state.doc.toString())
|
||||
editor.value.dom.remove()
|
||||
editor.value = undefined
|
||||
if (props.editing) {
|
||||
if (editor.value) {
|
||||
const viewText = editor.value.state.doc.toString()
|
||||
if (viewText !== text.value) text.value = viewText
|
||||
}
|
||||
emit('update:editing', false)
|
||||
}
|
||||
if (props.editing) emit('update:editing', false)
|
||||
}
|
||||
|
||||
function insertTextAtCursor(insert: string) {
|
||||
if (!editor.value) return
|
||||
const range = editor.value.state.selection.ranges[0] ?? { from: 0, to: 0 }
|
||||
editor.value.dispatch({
|
||||
changes: {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: range.from + insert.length },
|
||||
})
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (event.shiftKey) insertTextAtCursor('\n')
|
||||
else finishEdit()
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const text = props.modelValue
|
||||
if (!editor.value) return
|
||||
const viewText = editor.value.state.doc.toString()
|
||||
editor.value.dispatch({
|
||||
changes: textChangeToEdits(viewText, text).map(textEditToChangeSpec),
|
||||
changes: textChangeToEdits(viewText, text.value).map(textEditToChangeSpec),
|
||||
})
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (contentElement.value && props.editing && !editor.value) startEdit()
|
||||
if (!editor.value) return
|
||||
if (props.editing) editor.value.focus()
|
||||
else editor.value.contentDOM.blur()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
assertDefined(commentRoot.value)
|
||||
const editorView = new EditorView({ parent: commentRoot.value })
|
||||
editorView.setState(EditorState.create({ extensions: [minimalSetup, EditorView.lineWrapping] }))
|
||||
editor.value = editorView
|
||||
})
|
||||
</script>
|
||||
<script lang="ts">
|
||||
/** Interpret a comment from source-code format to display format.
|
||||
*
|
||||
* Hard-wrapped lines are combined similarly to how whitespace is interpreted in Markdown:
|
||||
* - A single linebreak is treated as a space.
|
||||
* - A sequence of linebreaks is treated as a paragraph-break.
|
||||
*/
|
||||
export function rawTextToCooked(raw: string) {
|
||||
return raw.replaceAll(/(?<!\n)\n(?!\n)/g, ' ').replaceAll(/\n(\n+)/g, '$1')
|
||||
}
|
||||
|
||||
/** Invert the transformation applied by @{rawTextToCooked}. */
|
||||
export function cookedTextToRaw(cooked: string) {
|
||||
return cooked.replaceAll('\n', '\n\n')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="commentRoot"
|
||||
class="GraphNodeComment"
|
||||
@keydown.enter.stop
|
||||
@keydown.backspace.stop
|
||||
@keydown.enter.capture.stop.prevent="handleEnter"
|
||||
@keydown.space.stop
|
||||
@keydown.delete.stop
|
||||
@wheel.stop.passive
|
||||
@blur="finishEdit"
|
||||
@pointerdown.stop="handleClick"
|
||||
@focusout="finishEdit"
|
||||
@pointerdown.stop
|
||||
@pointerup.stop
|
||||
@click.stop
|
||||
@click.stop="startEdit"
|
||||
@contextmenu.stop
|
||||
>
|
||||
<div ref="contentElement" class="content">
|
||||
<template v-if="!editor">
|
||||
<p v-for="(paragraph, i) in paragraphs" :key="i" v-text="paragraph" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -100,17 +125,38 @@ watchEffect(() => {
|
||||
width: max(100% - 60px, 800px);
|
||||
}
|
||||
|
||||
.content {
|
||||
:deep(.cm-editor) {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
display: inline-block;
|
||||
padding: 0 8px 2px;
|
||||
font-weight: 400;
|
||||
padding: 0 0 2px;
|
||||
border-radius: var(--radius-default);
|
||||
background-color: var(--node-color-no-type);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
:deep(.cm-content) {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-inversed);
|
||||
line-height: 18px;
|
||||
background-color: var(--node-color-no-type);
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.cm-content),
|
||||
:deep(.cm-line) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.cm-scroller) {
|
||||
width: 100%;
|
||||
}
|
||||
:deep(.cm-scroller) {
|
||||
overflow-x: clip;
|
||||
/* Horizontal padding is in the CodeMirror element so that it has room to draw its cursor. */
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
:deep(.cm-editor),
|
||||
:deep(.cm-line) {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
|
@ -0,0 +1,21 @@
|
||||
import { cookedTextToRaw, rawTextToCooked } from '@/components/GraphEditor/GraphNodeComment.vue'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
const cases = [
|
||||
{
|
||||
raw: 'First paragraph\n\nSecond paragraph',
|
||||
cooked: 'First paragraph\nSecond paragraph',
|
||||
},
|
||||
{
|
||||
raw: 'First line\ncontinues on second line',
|
||||
cooked: 'First line continues on second line',
|
||||
normalized: 'First line continues on second line',
|
||||
},
|
||||
]
|
||||
test.each(cases)('Interpreting comments', ({ raw, cooked }) => {
|
||||
expect(rawTextToCooked(raw)).toBe(cooked)
|
||||
})
|
||||
test.each(cases)('Lowering comments', (testCase) => {
|
||||
const { raw, cooked } = testCase
|
||||
expect(cookedTextToRaw(cooked)).toBe(testCase?.normalized ?? raw)
|
||||
})
|
Loading…
Reference in New Issue
Block a user