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:
153
frontend/package-lock.json
generated
153
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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: {
|
||||
|
||||
98
frontend/src/components/editor/ImageEditDialog.vue
Normal file
98
frontend/src/components/editor/ImageEditDialog.vue
Normal 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 på 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>
|
||||
211
frontend/src/components/editor/ImagePickerDialog.vue
Normal file
211
frontend/src/components/editor/ImagePickerDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user