studium/frontend/src/components/course/MarkdownEditor.vue
Antoine Pelletier 08b69851cd wip
2026-09-29 08:57:04 +02:00

185 lines
5.6 KiB
Vue

<script setup lang="ts">
/**
* Markdown textarea with a toolbar for what the course template supports: headings,
* lists, code, math, tables and the definition / theorem / example boxes
* (`::: {.defbox title="..."}`, turned into tcolorbox by `box-filter.lua`).
*/
import { ref, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Bold,
BookMarked,
Code,
FileCode,
Heading1,
Heading2,
Heading3,
Highlighter,
Italic,
Lightbulb,
Link,
List,
ListOrdered,
Sigma,
SquareFunction,
Table,
Sparkles,
} from '@lucide/vue'
import { Separator } from '@/components/ui/separator'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { applyEdit, block, prefixLines, wrap, type Edit } from '@/utils/markdown-editor'
const content = defineModel<string>({ required: true })
const emit = defineEmits<{ save: [] }>()
const { t } = useI18n()
const textarea = ref<HTMLTextAreaElement>()
type Action = (selection: { text: string; start: number; end: number }) => Edit
interface Tool {
/** i18n key under `notes.editor` */
label: string
icon: Component
action: Action
shortcut?: string
}
const HEADING = /^#{1,6} /
const LIST = /^(\s*)([-*] |\d+\. )/
/** Box of the template, titled with the translated label of the tool */
function box(kind: string, label: string): Action {
return (s) =>
block(
s,
`::: {.${kind} title="${t(`notes.editor.${label}`)}"}\n`,
'\n:::',
t('notes.editor.box-content'),
)
}
const groups: Tool[][] = [
[
{ label: 'h1', icon: Heading1, action: (s) => prefixLines(s, () => '# ', HEADING) },
{ label: 'h2', icon: Heading2, action: (s) => prefixLines(s, () => '## ', HEADING) },
{ label: 'h3', icon: Heading3, action: (s) => prefixLines(s, () => '### ', HEADING) },
],
[
{
label: 'bold',
icon: Bold,
shortcut: 'Ctrl+B',
action: (s) => wrap(s, '**', '**', t('notes.editor.text')),
},
{
label: 'italic',
icon: Italic,
shortcut: 'Ctrl+I',
action: (s) => wrap(s, '*', '*', t('notes.editor.text')),
},
{
label: 'highlight',
icon: Highlighter,
action: (s) => wrap(s, '\\hword{', '}', t('notes.editor.text')),
},
{ label: 'code', icon: Code, action: (s) => wrap(s, '`', '`', 'code') },
{
label: 'link',
icon: Link,
action: (s) => wrap(s, '[', '](https://)', t('notes.editor.text')),
},
],
[
{ label: 'bullets', icon: List, action: (s) => prefixLines(s, () => '- ', LIST) },
{
label: 'numbered',
icon: ListOrdered,
action: (s) => prefixLines(s, (i) => `${i + 1}. `, LIST),
},
],
[
{ label: 'math', icon: Sigma, action: (s) => wrap(s, '$', '$', 'x^2') },
{
label: 'math-block',
icon: SquareFunction,
action: (s) => block(s, '$$\n', '\n$$', '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}'),
},
{
label: 'code-block',
icon: FileCode,
action: (s) => block(s, '```c\n', '\n```', 'int main() {\n return 0;\n}'),
},
{
label: 'table',
icon: Table,
action: (s) => block(s, '', '\n|:--|:--:|--:|\n| 1 | 2 | 3 |', '| A | B | C |'),
},
],
[
{ label: 'definition', icon: BookMarked, action: box('defbox', 'definition') },
{ label: 'theorem', icon: Sparkles, action: box('theobox', 'theorem') },
{ label: 'example', icon: Lightbulb, action: box('exbox', 'example') },
],
]
function run(action: Action) {
const el = textarea.value
if (!el) return
applyEdit(el, action({ text: el.value, start: el.selectionStart, end: el.selectionEnd }))
}
function onKeydown(event: KeyboardEvent) {
const el = event.currentTarget as HTMLTextAreaElement
const mod = event.ctrlKey || event.metaKey
if (mod && event.key === 's') {
event.preventDefault()
emit('save')
} else if (mod && event.key === 'b') {
event.preventDefault()
run(groups[1]![0]!.action)
} else if (mod && event.key === 'i') {
event.preventDefault()
run(groups[1]![1]!.action)
} else if (event.key === 'Tab' && !event.shiftKey && el.selectionStart === el.selectionEnd) {
// Indents (nested lists) instead of leaving the editor
event.preventDefault()
applyEdit(el, { from: el.selectionStart, to: el.selectionEnd, insert: ' ', select: [2, 2] })
}
}
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-lg border bg-background">
<div class="flex flex-wrap items-center gap-0.5 border-b bg-muted/40 p-1">
<template v-for="(group, index) in groups" :key="index">
<Separator v-if="index > 0" orientation="vertical" class="mx-0.5 !h-5" />
<Tooltip v-for="tool in group" :key="tool.label">
<TooltipTrigger as-child>
<button
type="button"
class="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
:aria-label="$t(`notes.editor.${tool.label}`)"
@click="run(tool.action)"
>
<component :is="tool.icon" class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{{ $t(`notes.editor.${tool.label}`) }}
<span v-if="tool.shortcut" class="ml-1 opacity-70">{{ tool.shortcut }}</span>
</TooltipContent>
</Tooltip>
</template>
</div>
<textarea
ref="textarea"
v-model="content"
class="min-h-[60svh] flex-1 resize-none bg-transparent p-4 font-mono text-sm leading-relaxed outline-none"
spellcheck="true"
:placeholder="$t('notes.editor.placeholder')"
@keydown="onKeydown"
/>
</div>
</template>