Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Comment UI refinement #9271

Merged
merged 6 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 99 additions & 53 deletions app/gui2/src/components/GraphEditor/GraphNodeComment.vue
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,101 +15,148 @@ const emit = defineEmits<{
'update:editing': [boolean]
}>()

const paragraphs = computed(() => props.modelValue.split('\n\n'))
const text = computed({
get: () => rawTextToCooked(props.modelValue),
set: (value) => emit('update:modelValue', cookedTextToRaw(value)),
})

const contentElement = ref<HTMLElement>()
const commentRoot = 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()
const interactions = injectInteractionHandler()
const editInteraction = {
cancel: () => finishEdit(),
click: (e: Event) => {
if (e.target instanceof Element && !commentRoot.value?.contains(e.target)) finishEdit()
return false
},
})

const handleClick = defineKeybinds(`comment-${random.uint53()}`, {
startEdit: ['Mod+PointerMain'],
}).handler({ startEdit })
}
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()
Comment on lines +79 to +80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks little strange as a watchEffect. Could this focus/blur be moved to the interaction methods? Or is there any reason for it being here in particular?

Copy link
Contributor Author

@kazcw kazcw Mar 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Frizi
Watching the prop ensures that focus/blur is updated when we begin/end editing for any reason, e.g:

  • If the edit-comment menu icon is clicked, that sets the prop, so the comment is focused when editing begins
  • There are various ways editing can be ended, some of them will set a new focus inherently but some don't; if there were any case where we missed unfocusing the comment, a weird state would result in which the app thinks the comment is not being edited but the user still sees a cursor.

})

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>
.GraphNodeComment {
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>
21 changes: 21 additions & 0 deletions app/gui2/src/components/GraphEditor/__tests__/comments.test.ts
Original file line number Diff line number Diff line change
@@ -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