185 lines
5.6 KiB
Vue
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>
|