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:
Kaz Wesley 2024-03-12 14:13:36 -04:00 committed by GitHub
parent 64e29f8761
commit be4f04f7ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 123 additions and 56 deletions

View File

@ -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>

View File

@ -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)
})