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"> <script setup lang="ts">
import type { EditorView as EditorViewType } from '@/components/CodeEditor/codemirror' import type { EditorView as EditorViewType } from '@/components/CodeEditor/codemirror'
import { injectInteractionHandler } from '@/providers/interactionHandler' import { injectInteractionHandler } from '@/providers/interactionHandler'
import { defineKeybinds } from '@/util/shortcuts' import { assertDefined } from 'shared/util/assert'
import * as random from 'lib0/random'
import { textChangeToEdits } from 'shared/util/data/text' 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( const { minimalSetup, EditorState, EditorView, textEditToChangeSpec } = await import(
'@/components/CodeEditor/codemirror' '@/components/CodeEditor/codemirror'
@ -16,83 +15,109 @@ const emit = defineEmits<{
'update:editing': [boolean] 'update:editing': [boolean]
}>() }>()
const paragraphs = computed(() => props.modelValue.split('\n\n')) const text = computed({
get: () => rawTextToCooked(props.modelValue),
const contentElement = ref<HTMLElement>() set: (value) => emit('update:modelValue', cookedTextToRaw(value)),
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 handleClick = defineKeybinds(`comment-${random.uint53()}`, { const commentRoot = ref<HTMLElement>()
startEdit: ['Mod+PointerMain'], const editor = ref<EditorViewType>()
}).handler({ startEdit })
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() { 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) if (!props.editing) emit('update:editing', true)
} }
function finishEdit() { function finishEdit() {
if (editor.value) { if (props.editing) {
if (editor.value.state.doc.toString() !== props.modelValue) if (editor.value) {
emit('update:modelValue', editor.value.state.doc.toString()) const viewText = editor.value.state.doc.toString()
editor.value.dom.remove() if (viewText !== text.value) text.value = viewText
editor.value = undefined }
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(() => { watchEffect(() => {
const text = props.modelValue
if (!editor.value) return if (!editor.value) return
const viewText = editor.value.state.doc.toString() const viewText = editor.value.state.doc.toString()
editor.value.dispatch({ editor.value.dispatch({
changes: textChangeToEdits(viewText, text).map(textEditToChangeSpec), changes: textChangeToEdits(viewText, text.value).map(textEditToChangeSpec),
}) })
}) })
watchEffect(() => { 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> </script>
<template> <template>
<div <div
ref="commentRoot"
class="GraphNodeComment" class="GraphNodeComment"
@keydown.enter.stop @keydown.enter.capture.stop.prevent="handleEnter"
@keydown.backspace.stop
@keydown.space.stop @keydown.space.stop
@keydown.delete.stop @keydown.delete.stop
@wheel.stop.passive @wheel.stop.passive
@blur="finishEdit" @focusout="finishEdit"
@pointerdown.stop="handleClick" @pointerdown.stop
@pointerup.stop @pointerup.stop
@click.stop @click.stop="startEdit"
@contextmenu.stop @contextmenu.stop
> ></div>
<div ref="contentElement" class="content">
<template v-if="!editor">
<p v-for="(paragraph, i) in paragraphs" :key="i" v-text="paragraph" />
</template>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
@ -100,17 +125,38 @@ watchEffect(() => {
width: max(100% - 60px, 800px); width: max(100% - 60px, 800px);
} }
.content { :deep(.cm-editor) {
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
display: inline-block; display: inline-block;
padding: 0 8px 2px; padding: 0 0 2px;
font-weight: 400;
border-radius: var(--radius-default); 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); color: var(--color-text-inversed);
line-height: 18px; 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> </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)
})