mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 06:01:37 +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">
|
<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>
|
||||||
|
@ -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