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

@@ -15,6 +15,7 @@
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-code-block-lowlight": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
@@ -24,6 +25,10 @@
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-table": "^2.27.2",
"@tiptap/extension-table-cell": "^2.27.2",
"@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/html": "^2.27.2",
"@tiptap/pm": "^2.4.0",
@@ -33,6 +38,8 @@
"codemirror": "^6.0.1",
"graphql": "^16.9.0",
"graphql-request": "^6.1.0",
"highlight.js": "^11.11.1",
"lowlight": "^3.3.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
@@ -40,6 +47,7 @@
"devDependencies": {
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.2",
"@tiptap/extension-image": "^2.27.2",
"@types/node": "^20.14.0",
"@vitejs/plugin-vue": "^5.0.5",
"autoprefixer": "^10.4.19",
@@ -2978,6 +2986,23 @@
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block-lowlight": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.27.2.tgz",
"integrity": "sha512-v6NKStBbQ/XCc1NnCi3ObsL1DsxadSIBtUQNA/B+urkPgn5LEy72HAGlf0xwjRaNkAGSaTASLKmc84L5q5zlGQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-code-block": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"highlight.js": "^11",
"lowlight": "^2 || ^3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
@@ -3090,6 +3115,20 @@
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
@@ -3172,6 +3211,59 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz",
"integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz",
"integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz",
"integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz",
"integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
@@ -3304,6 +3396,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -3350,6 +3451,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -4371,6 +4478,28 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -5157,6 +5286,15 @@
"he": "bin/he"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -5859,6 +5997,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View File

@@ -16,6 +16,7 @@
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-code-block-lowlight": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
@@ -25,6 +26,10 @@
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-table": "^2.27.2",
"@tiptap/extension-table-cell": "^2.27.2",
"@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/html": "^2.27.2",
"@tiptap/pm": "^2.4.0",
@@ -34,6 +39,8 @@
"codemirror": "^6.0.1",
"graphql": "^16.9.0",
"graphql-request": "^6.1.0",
"highlight.js": "^11.11.1",
"lowlight": "^3.3.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
@@ -41,6 +48,7 @@
"devDependencies": {
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/theme-one-dark": "^6.1.2",
"@tiptap/extension-image": "^2.27.2",
"@types/node": "^20.14.0",
"@vitejs/plugin-vue": "^5.0.5",
"autoprefixer": "^10.4.19",

View File

@@ -3,13 +3,17 @@ import { generateJSON } from '@tiptap/html'
import type { JSONContent } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import { Admonition, CustomCodeBlock } from './asciidoc-extensions'
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 { Admonition, CustomCodeBlock, AsciidocImage } from './asciidoc-extensions'
const asciidoctor = Asciidoctor()
// ── AsciiDoc → TipTap ────────────────────────────────────────────────────────
export function toTipTap(adoc: string): JSONContent {
export function toTipTap(adoc: string, slug?: string): JSONContent {
if (!adoc || !adoc.trim()) {
return { type: 'doc', content: [{ type: 'paragraph' }] }
}
@@ -29,11 +33,47 @@ export function toTipTap(adoc: string): JSONContent {
}
)
// Rewrite image tags to point to backend /media/ path
// Asciidoctor outputs: <img src="filename.png" alt="alt" width="200">
// We add data-original-src so we can convert back properly, and rewrite src to /media/...
if (slug) {
const parts = slug.split('/')
parts.pop() // remove filename
const dir = parts.length > 0 ? parts.join('/') + '/' : ''
htmlContent = htmlContent.replace(
/<img([^>]*)src="([^"]+)"([^>]*)>/g,
(_match, before, src, after) => {
// Only rewrite relative paths (not http/https)
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) {
return `<img${before}src="${src}" data-original-src="${src}"${after}>`
}
const backendSrc = `/media/${dir}${src}`
return `<img${before}src="${backendSrc}" data-original-src="${src}"${after}>`
}
)
} else {
// Om slug saknas, lägg i alla fall till data-original-src så den bevaras
htmlContent = htmlContent.replace(
/<img([^>]*)src="([^"]+)"([^>]*)>/g,
(_match, before, src, after) => {
return `<img${before}src="${src}" data-original-src="${src}"${after}>`
}
)
}
const extensions = [
StarterKit.configure({ codeBlock: false }),
CustomCodeBlock,
Link,
Admonition
Admonition,
AsciidocImage,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
]
return generateJSON(htmlContent, extensions)
@@ -86,6 +126,12 @@ class TipTapToAsciidoc {
case 'admonition':
output += this.convertAdmonition(node)
break
case 'image':
output += this.convertImage(node)
break
case 'table':
output += this.convertTable(node)
break
case 'horizontalRule':
output += "'''\n\n"
break
@@ -137,6 +183,37 @@ class TipTapToAsciidoc {
return `[${type.toUpperCase()}]\n====\n${text}\n====\n\n`
}
convertImage(node: JSONContent): string {
const src = node.attrs?.['data-original-src'] || node.attrs?.src || ''
const alt = node.attrs?.alt || ''
const width = node.attrs?.width ? `, width=${node.attrs.width}` : ''
const height = node.attrs?.height ? `, height=${node.attrs.height}` : ''
const title = node.attrs?.title ? `title="${node.attrs.title}", ` : ''
if (title || width || height || alt) {
return `image::${src}[${title}${alt}${width}${height}]\n\n`
}
return `image::${src}[]\n\n`
}
convertTable(node: JSONContent): string {
if (!node.content || node.content.length === 0) return ''
let tableOut = '|===\n'
node.content.forEach((row) => {
if (row.type !== 'tableRow' || !row.content) return
row.content.forEach((cell) => {
const cellText = (cell.content ? this.processNodes(cell.content).trim() : '');
tableOut += `| ${cellText} `
})
tableOut += '\n\n'
})
tableOut += '|===\n\n'
return tableOut
}
renderTextNodes(nodes: JSONContent[]): string {
if (!nodes || nodes.length === 0) return ''
let textOut = ''

View File

@@ -1,5 +1,40 @@
import { Node } from '@tiptap/core'
import CodeBlock from '@tiptap/extension-code-block'
import { CodeBlockLowlight, CodeBlockLowlightOptions } from '@tiptap/extension-code-block-lowlight'
import { createLowlight, all } from 'lowlight'
import Image from '@tiptap/extension-image'
const lowlight = createLowlight(all)
export const AsciidocImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
src: { default: null },
alt: { default: null },
title: { default: null },
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {}
return { width: attributes.width }
}
},
height: {
default: null,
parseHTML: element => element.getAttribute('height'),
renderHTML: attributes => {
if (!attributes.height) return {}
return { height: attributes.height }
}
},
'data-original-src': {
default: null,
parseHTML: element => element.getAttribute('data-original-src'),
}
}
}
})
export const Admonition = Node.create({
name: 'admonition',
@@ -26,7 +61,18 @@ export const Admonition = Node.create({
},
})
export const CustomCodeBlock = CodeBlock.extend({
export const CustomCodeBlock = CodeBlockLowlight.extend<CodeBlockLowlightOptions>({
addOptions() {
return {
...(this.parent?.() || {}),
lowlight,
defaultLanguage: null,
languageClassPrefix: 'language-',
HTMLAttributes: {
class: 'code-block text-sm bg-gray-800 text-gray-100 p-3 rounded',
},
} as any
},
addAttributes() {
return {
language: {

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>

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import { router } from '@/router'
import App from '@/App.vue'
import '@/assets/main.css'
import 'highlight.js/styles/github-dark.css'
const app = createApp(App)
app.use(createPinia())

View File

@@ -311,6 +311,7 @@ function formatDate(dateStr: string) {
<div v-if="versionLoading" class="p-6 text-slate-400 animate-pulse">Loading version</div>
<VisualEditor
v-else-if="versionEditorMode === 'visual'"
:slug="slug"
:model-value="versionContent"
@update:model-value="() => {}"
/>
@@ -324,7 +325,7 @@ function formatDate(dateStr: string) {
<!-- Normal editor -->
<template v-else>
<div v-if="loading" class="p-6 text-slate-400 animate-pulse">Loading…</div>
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" />
<VisualEditor v-else-if="editorMode === 'visual'" v-model="content" :slug="slug" />
<SourceEditor v-else v-model="content" />
</template>
</div>

View File

@@ -25,6 +25,7 @@ export default defineConfig({
// instead of waiting for all tabs to close.
skipWaiting: true,
clientsClaim: true,
maximumFileSizeToCacheInBytes: 5000000,
// Only cache these explicit file types — do NOT cache HTML so the
// SPA shell is always fetched fresh (prevents stale SW loops).
globPatterns: ['**/*.{js,css,woff2,png,svg,ico}'],
@@ -50,6 +51,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/graphql': { target: 'http://localhost:4000', changeOrigin: true },
'/media': { target: 'http://localhost:4000', changeOrigin: true },
},
},
})