feat: add syntax highlighting and enhance document editing features

- Import GitHub dark theme for highlight.js in main.ts
- Pass slug prop to VisualEditor in DocumentView.vue for version and normal editing modes
- Update Vite configuration to increase maximum cache size and add media proxy
- Add new dependencies for Tiptap extensions and highlight.js in package.json and package-lock.json
This commit is contained in:
Björn Blomberg
2026-04-13 15:14:46 +02:00
parent a8c854e61b
commit e89d2c87f0
13 changed files with 1380 additions and 15 deletions

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
isOpen: boolean;
initialData: {
src: string;
width: string;
height: string;
alt: string;
title: string;
'data-original-src': string;
} | null;
}>()
const emit = defineEmits<{
(e: 'close'): void;
(e: 'update', data: { src: string; width: string; height: string; alt: string; title: string, 'data-original-src': string }): void;
}>()
const formData = ref({
src: '',
width: '',
height: '',
alt: '',
title: '',
'data-original-src': ''
})
watch(() => props.isOpen, (open) => {
if (open && props.initialData) {
formData.value = { ...props.initialData }
}
})
function save() {
emit('update', { ...formData.value })
emit('close')
}
function cancel() {
emit('close')
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-sm w-full overflow-hidden flex flex-col">
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center bg-slate-50 dark:bg-slate-900/50">
<h3 class="font-bold text-lg text-slate-800 dark:text-slate-100">Redigera bild</h3>
<button @click="cancel" class="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="p-4 flex flex-col gap-4 overflow-y-auto">
<!-- Källa (Bild-URL) -->
<div>
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Bildkälla (URL)</label>
<input v-model="formData.src" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" />
</div>
<!-- Original src (t.ex. kopplad fil) -->
<div>
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Kopplad fil (i AsciiDoc)</label>
<input v-model="formData['data-original-src']" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" />
<p class="text-[10px] text-slate-400 mt-1">Namnet filen refererad i texten.</p>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Bredd (px/% m.m.)</label>
<input v-model="formData.width" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" placeholder="t.ex. 300" />
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Höjd (px/% m.m.)</label>
<input v-model="formData.height" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" placeholder="t.ex. 200" />
</div>
</div>
<div>
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Alt-text</label>
<input v-model="formData.alt" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" />
</div>
<div>
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Titel</label>
<input v-model="formData.title" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" />
</div>
</div>
<div class="p-4 border-t dark:border-slate-700 flex justify-end gap-2 bg-slate-50 dark:bg-slate-900/50">
<button @click="cancel" class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-100 transition-colors">Avbryt</button>
<button @click="save" class="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors">Spara</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { gql } from '@/lib/gql'
const props = defineProps<{
slug?: string;
isOpen: boolean;
}>()
const emit = defineEmits<{
(e: 'close'): void;
(e: 'insert', data: { src: string; alt: string; title: string, 'data-original-src': string }): void;
}>()
const activeTab = ref<'upload' | 'library' | 'url'>('library')
const images = ref<{ name: string; url: string; size: number }[]>([])
const isLoading = ref(false)
const urlInput = ref('')
const altInput = ref('')
const titleInput = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
async function fetchImages() {
if (!props.slug) return
isLoading.value = true
try {
const data = await gql<{ images: { name: string; url: string; size: number }[] }>(
`query GetImages($slug: String!) { images(slug: $slug) { name url size } }`,
{ slug: props.slug }
)
images.value = data.images
} catch (err) {
console.error('Failed to load images:', err)
} finally {
isLoading.value = false
}
}
onMounted(() => {
if (props.slug) fetchImages()
})
function onOpen() {
if (props.isOpen && props.slug) fetchImages()
}
// Watch prop manually if mounted was called before isOpen was true
import { watch } from 'vue'
watch(() => props.isOpen, (open) => {
if (open) onOpen()
})
async function uploadImage() {
const file = fileInput.value?.files?.[0]
if (!file || !props.slug) return
isUploading.value = true
const token = localStorage.getItem('token') || ''
const formData = new FormData()
formData.append('image', file)
formData.append('slug', props.slug)
try {
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
if (!res.ok) throw new Error('Upload failed')
const data = await res.json()
// data.url, data.name
emit('insert', {
src: data.url,
alt: altInput.value || data.name,
title: titleInput.value,
'data-original-src': data.name
})
close()
} catch (err) {
console.error('Upload Error:', err)
alert('Upload failed')
} finally {
isUploading.value = false
}
}
function selectImage(img: { name: string; url: string }) {
emit('insert', {
src: img.url,
alt: altInput.value || img.name,
title: titleInput.value,
'data-original-src': img.name
})
close()
}
function insertUrl() {
if (!urlInput.value) return
emit('insert', {
src: urlInput.value,
alt: altInput.value || 'Image',
title: titleInput.value,
'data-original-src': urlInput.value
})
close()
}
function close() {
urlInput.value = ''
altInput.value = ''
titleInput.value = ''
if (fileInput.value) fileInput.value.value = ''
emit('close')
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-lg w-full overflow-hidden flex flex-col max-h-[90vh]">
<!-- Header -->
<div class="p-4 border-b dark:border-slate-700 flex justify-between items-center bg-slate-50 dark:bg-slate-900/50">
<h3 class="font-bold text-lg text-slate-800 dark:text-slate-100">Infoga bild</h3>
<button @click="close" class="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<!-- Tabs -->
<div class="flex border-b dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
<button v-if="slug" @click="activeTab = 'library'" class="px-4 py-2 font-medium text-sm transition-colors border-b-2" :class="activeTab === 'library' ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'">
Bibliotek
</button>
<button v-if="slug" @click="activeTab = 'upload'" class="px-4 py-2 font-medium text-sm transition-colors border-b-2" :class="activeTab === 'upload' ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'">
Ladda upp
</button>
<button @click="activeTab = 'url'" class="px-4 py-2 font-medium text-sm transition-colors border-b-2" :class="activeTab === 'url' ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'">
Bild-URL
</button>
</div>
<!-- Body -->
<div class="p-4 overflow-y-auto flex-grow">
<!-- Metadata fields applied to all methods -->
<div class="flex gap-4 mb-6 pb-4 border-b dark:border-slate-700">
<div class="flex-1">
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Alt-text (valfri)</label>
<input v-model="altInput" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" placeholder="Alternativ text för skärmläsare" />
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Titel (valfri)</label>
<input v-model="titleInput" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border dark:border-slate-700 rounded-md text-sm text-slate-800 dark:text-slate-200" placeholder="Text visas vid hovring" />
</div>
</div>
<div v-if="activeTab === 'url'">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Klistra in bild-URL</label>
<input v-model="urlInput" type="text" class="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md shadow-sm mb-4" placeholder="https://example.com/image.png" @keydown.enter="insertUrl" />
<div class="flex justify-end">
<button @click="insertUrl" :disabled="!urlInput" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 transition-colors">
Infoga bild
</button>
</div>
</div>
<div v-else-if="activeTab === 'upload'" class="flex flex-col gap-4">
<div class="border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-lg p-6 flex flex-col items-center justify-center text-center bg-slate-50 dark:bg-slate-900/30">
<input type="file" accept="image/*" ref="fileInput" class="block w-full text-sm text-slate-500 dark:text-slate-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 dark:file:bg-indigo-900/50 dark:file:text-indigo-400 cursor-pointer" />
</div>
<div class="flex justify-end">
<button @click="uploadImage" :disabled="isUploading || !fileInput" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 transition-colors inline-flex items-center gap-2">
<svg v-if="isUploading" class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>
{{ isUploading ? 'Laddar upp...' : 'Ladda upp & infoga' }}
</button>
</div>
</div>
<div v-else-if="activeTab === 'library'">
<div v-if="isLoading" class="flex justify-center p-8">
<svg class="animate-spin h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>
</div>
<div v-else-if="images.length === 0" class="text-center py-8 text-slate-500 dark:text-slate-400">
<p>Inga uppladdade bilder hittades.</p>
</div>
<div v-else class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="img in images"
:key="img.name"
@click="selectImage(img)"
class="group relative aspect-square border border-slate-200 dark:border-slate-700 rounded-md overflow-hidden bg-slate-100 dark:bg-slate-900 hover:border-indigo-500 dark:hover:border-indigo-400 focus:outline-none transition-colors"
:title="img.name"
>
<img :src="img.url" :alt="img.name" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-indigo-500/0 group-hover:bg-indigo-500/10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all">
<span class="bg-indigo-600 text-white text-xs px-2 py-1 rounded shadow-sm">Välj</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -3,21 +3,49 @@ import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { toTipTap, fromTipTap } from '@/bridge/asciidoc-bridge'
import { Admonition, CustomCodeBlock } from '@/bridge/asciidoc-extensions'
import { Admonition, CustomCodeBlock, AsciidocImage } from '@/bridge/asciidoc-extensions'
import ImagePickerDialog from './ImagePickerDialog.vue'
import ImageEditDialog from './ImageEditDialog.vue'
const model = defineModel<string>({ required: true })
const props = defineProps<{ slug?: string }>()
const isEditing = ref(false)
const showImagePicker = ref(false)
const showImageEditor = ref(false)
const contextMenu = ref({ show: false, x: 0, y: 0, type: '' as 'table' | 'image' | '' })
const activeImageData = ref<{ src: string; width: string; height: string; alt: string; title: string; 'data-original-src': string } | null>(null)
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
CustomCodeBlock,
Link,
Admonition
Admonition,
AsciidocImage,
Table.configure({
resizable: true,
HTMLAttributes: {
class: 'border-collapse table-auto w-full text-sm',
},
}),
TableRow.configure({
HTMLAttributes: { class: 'border-b dark:border-slate-700' }
}),
TableHeader.configure({
HTMLAttributes: { class: 'border p-2 bg-gray-100 dark:bg-slate-800 font-bold' }
}),
TableCell.configure({
HTMLAttributes: { class: 'border p-2' }
}),
],
content: toTipTap(model.value),
content: toTipTap(model.value, props.slug),
editable: false,
onUpdate({ editor }) {
model.value = fromTipTap(editor.getJSON())
@@ -32,14 +60,124 @@ watch(isEditing, (editing) => {
// Sync external changes (e.g. switching from SourceEditor) into TipTap.
watch(model, (adoc) => {
if (!editor.value) return
const json = toTipTap(adoc)
const json = toTipTap(adoc, props.slug)
const current = JSON.stringify(editor.value.getJSON())
if (JSON.stringify(json) !== current) {
editor.value.commands.setContent(json, false)
}
})
onBeforeUnmount(() => editor.value?.destroy())
function onContextMenu(e: MouseEvent) {
if (!isEditing.value || !editor.value) return
const target = e.target as HTMLElement
if (target instanceof HTMLImageElement) {
// Om man högerklickar på en bild vill vi välja hela noden
const pos = editor.value.view.posAtDOM(target, 0)
if (pos >= 0) {
editor.value.commands.setNodeSelection(pos)
contextMenu.value = { show: true, x: e.clientX, y: e.clientY, type: 'image' }
e.preventDefault()
return
}
}
const view = editor.value.view
const coords = view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) {
contextMenu.value.show = false
return
}
// För text/celler sätt markören inuti
editor.value.commands.setTextSelection(coords.pos)
if (editor.value.isActive('table')) {
contextMenu.value = { show: true, x: e.clientX, y: e.clientY, type: 'table' }
e.preventDefault()
} else {
contextMenu.value.show = false
}
}
function handleContextAction(action: string) {
if (!editor.value) return
const cmd = editor.value.chain().focus()
switch (action) {
case 'editImage':
openImageEditor()
break
case 'addRowBefore': cmd.addRowBefore().run(); break
case 'addRowAfter': cmd.addRowAfter().run(); break
case 'deleteRow': cmd.deleteRow().run(); break
case 'addColumnBefore': cmd.addColumnBefore().run(); break
case 'addColumnAfter': cmd.addColumnAfter().run(); break
case 'deleteColumn': cmd.deleteColumn().run(); break
case 'deleteTable': cmd.deleteTable().run(); break
}
contextMenu.value.show = false
}
function openImageEditor() {
if (!editor.value) return
const attrs = editor.value.getAttributes('image')
activeImageData.value = {
src: attrs.src || '',
width: attrs.width || '',
height: attrs.height || '',
alt: attrs.alt || '',
title: attrs.title || '',
'data-original-src': attrs['data-original-src'] || ''
}
showImageEditor.value = true
}
function handleImageUpdate(updatedData: { src: string; width: string; height: string; alt: string; title: string, 'data-original-src': string }) {
if (!editor.value) return
(editor.value.commands as any).updateAttributes('image', updatedData)
showImageEditor.value = false
}
function insertAdmonition(e: Event) {
const select = e.target as HTMLSelectElement
const type = select.value
if (!type || !editor.value) return
editor.value.commands.insertContent({
type: 'admonition',
attrs: { type: type.toLowerCase() },
content: [{ type: 'paragraph' }]
})
select.value = ''
}
function handleImageInsert(data: { src: string; alt: string; title: string, 'data-original-src': string }) {
if (editor.value) {
(editor.value.commands as any).setImage({
src: data.src,
alt: data.alt,
title: data.title,
'data-original-src': data['data-original-src']
})
}
}
onBeforeUnmount(() => {
editor.value?.destroy()
document.removeEventListener('click', closeContextMenu)
})
function closeContextMenu() {
if (contextMenu.value.show) {
contextMenu.value.show = false
}
}
import { onMounted } from 'vue'
onMounted(() => {
document.addEventListener('click', closeContextMenu)
})
function btnClass(active: boolean | undefined) {
return [
@@ -132,11 +270,42 @@ function btnClass(active: boolean | undefined) {
Kodblock
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<select
@change="insertAdmonition"
class="bg-transparent text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 px-2 py-1 rounded outline-none cursor-pointer"
:disabled="!editor || !isEditing"
>
<option value="" disabled selected hidden>Info/Varning</option>
<option value="NOTE">Information</option>
<option value="TIP">Tips</option>
<option value="WARNING">Varning</option>
<option value="CAUTION">Försiktighet</option>
<option value="IMPORTANT">Viktigt</option>
</select>
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
<button
@click="(editor as any).chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()"
:class="btnClass(editor.isActive('table'))"
>
Tabell
</button>
<button
@click="showImagePicker = true"
class="px-2 py-0.5 text-sm font-medium transition-colors select-none text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-700"
>
Bild
</button>
<div class="flex-grow"></div>
</div>
<!-- Edit Area -->
<div class="flex-grow overflow-y-auto p-4 w-full">
<div class="flex-grow overflow-y-auto p-4 w-full" @contextmenu="onContextMenu">
<div
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
@click="!isEditing && (isEditing = true)"
@@ -144,6 +313,42 @@ function btnClass(active: boolean | undefined) {
<EditorContent :editor="editor" />
</div>
</div>
<ImagePickerDialog
:is-open="showImagePicker"
:slug="props.slug"
@close="showImagePicker = false"
@insert="handleImageInsert"
/>
<ImageEditDialog
:is-open="showImageEditor"
:initial-data="activeImageData"
@close="showImageEditor = false"
@update="handleImageUpdate"
/>
<!-- Context Menu -->
<div
v-if="contextMenu.show"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
class="fixed z-50 bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-slate-700 rounded py-1 min-w-[160px] text-sm flex flex-col"
>
<template v-if="contextMenu.type === 'image'">
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200" @click="handleContextAction('editImage')">Redigera bild...</button>
</template>
<template v-if="contextMenu.type === 'table'">
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200" @click="handleContextAction('addRowBefore')">Sätt in rad ovanför</button>
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200" @click="handleContextAction('addRowAfter')">Sätt in rad nedanför</button>
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-red-600 dark:text-red-400" @click="handleContextAction('deleteRow')">Ta bort rad</button>
<div class="h-px bg-slate-200 dark:bg-slate-700 my-1"></div>
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200" @click="handleContextAction('addColumnBefore')">Sätt in kolumn före</button>
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200" @click="handleContextAction('addColumnAfter')">Sätt in kolumn efter</button>
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-red-600 dark:text-red-400" @click="handleContextAction('deleteColumn')">Ta bort kolumn</button>
<div class="h-px bg-slate-200 dark:bg-slate-700 my-1"></div>
<button class="px-4 py-2 text-left hover:bg-slate-100 dark:hover:bg-slate-700 text-red-600 dark:text-red-400" @click="handleContextAction('deleteTable')">Ta bort tabell</button>
</template>
</div>
</div>
</template>