feat: add LinkDialog and CreateItemDialog components; enhance editor with footnote and include functionalities
This commit is contained in:
@@ -7,7 +7,7 @@ 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'
|
||||
import { Admonition, CustomCodeBlock, AsciidocImage, CustomHeading, Footnote, AsciidocInclude } from './asciidoc-extensions'
|
||||
|
||||
const asciidoctor = Asciidoctor()
|
||||
|
||||
@@ -18,8 +18,13 @@ export function toTipTap(adoc: string, slug?: string): JSONContent {
|
||||
return { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
}
|
||||
|
||||
// Pre-process includes and footnotes before injecting to Tiptap parsing
|
||||
let preAdoc = adoc.replace(/footnote:\[(.*?)\]/g, '+++<span data-type="footnote" data-content="$1">Fotnot</span>+++')
|
||||
// Regex matches `include::file[attrs]` keeping context.
|
||||
preAdoc = preAdoc.replace(/^include::(.*?)\[(.*?)\](?:$|\r?\n)/gm, '++++\n<div data-type="include" data-file="$1" data-attrs="$2"></div>\n++++\n')
|
||||
|
||||
// Convert AsciiDoc to HTML, showtitle: false to skip huge document header wrappers
|
||||
let htmlContent = asciidoctor.convert(adoc, {
|
||||
let htmlContent = asciidoctor.convert(preAdoc, {
|
||||
attributes: { showtitle: false },
|
||||
standalone: false
|
||||
}) as string
|
||||
@@ -63,11 +68,14 @@ export function toTipTap(adoc: string, slug?: string): JSONContent {
|
||||
}
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
StarterKit.configure({ codeBlock: false, heading: false }),
|
||||
CustomHeading,
|
||||
CustomCodeBlock,
|
||||
Link,
|
||||
Admonition,
|
||||
AsciidocImage,
|
||||
Footnote,
|
||||
AsciidocInclude,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
@@ -132,6 +140,9 @@ class TipTapToAsciidoc {
|
||||
case 'table':
|
||||
output += this.convertTable(node)
|
||||
break
|
||||
case 'asciidocInclude':
|
||||
output += `include::${node.attrs?.file || ''}[${node.attrs?.attrs || ''}]\n\n`
|
||||
break
|
||||
case 'horizontalRule':
|
||||
output += "'''\n\n"
|
||||
break
|
||||
@@ -145,8 +156,16 @@ class TipTapToAsciidoc {
|
||||
|
||||
convertHeading(node: JSONContent): string {
|
||||
const level = (node.attrs?.level as number) || 1
|
||||
const id = node.attrs?.id as string | undefined
|
||||
const prefix = '='.repeat(level)
|
||||
const text = this.renderTextNodes(node.content || [])
|
||||
|
||||
// Om rubriken har ett faktiskt ID i Tiptap, lägg till som ett explicit Asciidoc-ankare
|
||||
if (id) {
|
||||
// Returnera explicit ankare före rubriken om det finns, fast Asciidoctor renderar ofta _rubrik automatiskt.
|
||||
return `[[${id}]]\n${prefix} ${text}\n\n`
|
||||
}
|
||||
|
||||
return `${prefix} ${text}\n\n`
|
||||
}
|
||||
|
||||
@@ -239,6 +258,11 @@ class TipTapToAsciidoc {
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (t.type === 'footnote') {
|
||||
textOut += `footnote:[${t.attrs?.content || ''}]`
|
||||
}
|
||||
if (t.type === 'text') {
|
||||
let text = t.text || ''
|
||||
textOut += text
|
||||
}
|
||||
if (t.type === 'hardBreak') {
|
||||
|
||||
@@ -1,10 +1,86 @@
|
||||
import { Node } from '@tiptap/core'
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
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 CustomHeading = Heading.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.id) {
|
||||
return {}
|
||||
}
|
||||
return { id: attributes.id }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const Footnote = Node.create({
|
||||
name: 'footnote',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
content: { default: '' }
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: 'span[data-type="footnote"]' }
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: any) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'footnote',
|
||||
class: 'inline-flex items-center justify-center px-1.5 mx-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 cursor-pointer hover:bg-indigo-200 dark:bg-indigo-900/50 dark:text-indigo-300 dark:hover:bg-indigo-800 transition-colors',
|
||||
title: HTMLAttributes.content
|
||||
}), 'Fotnot']
|
||||
}
|
||||
})
|
||||
|
||||
export const AsciidocInclude = Node.create({
|
||||
name: 'asciidocInclude',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
file: { default: '' },
|
||||
attrs: { default: '' }
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: 'div[data-type="include"]' }
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: any) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'include',
|
||||
class: 'my-4 p-3 bg-slate-100 dark:bg-slate-800/80 border-l-4 border-indigo-400 dark:border-indigo-600 rounded text-sm font-mono text-slate-600 dark:text-slate-300 flex items-center gap-2 select-none'
|
||||
}),
|
||||
['span', {}, '🔗 include::'],
|
||||
['span', { class: 'font-bold text-indigo-600 dark:text-indigo-400' }, HTMLAttributes.file || ''],
|
||||
['span', {}, `[${HTMLAttributes.attrs || ''}]`]
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export const AsciidocImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -49,14 +125,14 @@ export const Admonition = Node.create({
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="admonition"]',
|
||||
getAttrs: (el) => {
|
||||
getAttrs: (el: any) => {
|
||||
if (typeof el === 'string') return {}
|
||||
return { type: el.getAttribute('data-admonition-type') || 'note' }
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
renderHTML({ HTMLAttributes }: any) {
|
||||
return ['div', { 'data-type': 'admonition', 'data-admonition-type': HTMLAttributes.type, class: `admonition ${HTMLAttributes.type}` }, 0]
|
||||
},
|
||||
})
|
||||
|
||||
133
frontend/src/components/editor/LinkDialog.vue
Normal file
133
frontend/src/components/editor/LinkDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
initialUrl?: string;
|
||||
initialText?: string;
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'confirm', data: { url: string; text: string }): void;
|
||||
}>()
|
||||
|
||||
const textInput = ref('')
|
||||
const urlInput = ref('')
|
||||
|
||||
const documents = ref<{slug: string, title: string}[]>([])
|
||||
const loadingDocs = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const LinkType = ref<'url' | 'document' | 'anchor'>('document')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await gql<{ documents: {slug: string, title: string}[] }>(`{ documents { slug title } }`)
|
||||
documents.value = data.documents ?? []
|
||||
} catch (err) {
|
||||
console.warn('Could not load documents')
|
||||
} finally {
|
||||
loadingDocs.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
textInput.value = props.initialText || ''
|
||||
urlInput.value = props.initialUrl || ''
|
||||
if (urlInput.value.startsWith('#')) {
|
||||
LinkType.value = 'anchor'
|
||||
urlInput.value = urlInput.value.substring(1) // strip #
|
||||
} else if (urlInput.value.startsWith('http')) {
|
||||
LinkType.value = 'url'
|
||||
} else {
|
||||
LinkType.value = 'document'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { computed } from 'vue'
|
||||
const filteredDocs = computed(() => {
|
||||
return documents.value.filter(d =>
|
||||
d.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
d.slug.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
let finalUrl = urlInput.value
|
||||
if (LinkType.value === 'anchor' && !finalUrl.startsWith('#')) { // just in case
|
||||
finalUrl = '#' + finalUrl
|
||||
}
|
||||
emit('confirm', { url: finalUrl, text: textInput.value })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function selectDoc(slug: string) {
|
||||
urlInput.value = slug
|
||||
confirm()
|
||||
}
|
||||
</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">
|
||||
<!-- 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">Länk...</h3>
|
||||
<button @click="emit('close')" class="text-slate-500 hover:text-slate-700">
|
||||
<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>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1">Text att visa</label>
|
||||
<input v-model="textInput" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md focus:ring-1 focus:ring-indigo-500 text-sm" placeholder="Text..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex border-b border-slate-200 dark:border-slate-700 mb-2">
|
||||
<button @click="LinkType = 'document'" :class="LinkType === 'document' ? 'border-b-2 border-indigo-500 text-indigo-600' : 'text-slate-500'" class="px-3 py-1 text-sm font-medium">Dokument</button>
|
||||
<button @click="LinkType = 'anchor'" :class="LinkType === 'anchor' ? 'border-b-2 border-indigo-500 text-indigo-600' : 'text-slate-500'" class="px-3 py-1 text-sm font-medium">Bokmärke (#)</button>
|
||||
<button @click="LinkType = 'url'" :class="LinkType === 'url' ? 'border-b-2 border-indigo-500 text-indigo-600' : 'text-slate-500'" class="px-3 py-1 text-sm font-medium">Extern URL</button>
|
||||
</div>
|
||||
|
||||
<div v-if="LinkType === 'url'">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1">Webbadress</label>
|
||||
<input v-model="urlInput" type="url" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md text-sm" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="LinkType === 'anchor'">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1">ID att hoppa till (utan #)</label>
|
||||
<input v-model="urlInput" type="text" class="w-full px-3 py-1.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md text-sm" placeholder="avancerat" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="LinkType === 'document'" class="h-48 flex flex-col">
|
||||
<input v-model="searchQuery" type="text" class="w-full px-3 py-1.5 bg-slate-50 dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-md text-sm mb-2" placeholder="Sök dokument..." />
|
||||
<div class="flex-1 overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-md">
|
||||
<div v-if="loadingDocs" class="p-2 text-xs text-center text-slate-400">Laddar...</div>
|
||||
<button
|
||||
v-else
|
||||
v-for="doc in filteredDocs"
|
||||
:key="doc.slug"
|
||||
@click="selectDoc(doc.slug)"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-800 last:border-0 truncate"
|
||||
>
|
||||
🎵 {{ doc.title || doc.slug }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<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="emit('close')" class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 dark:hover:text-slate-200">Avbryt</button>
|
||||
<button v-if="LinkType !== 'document'" @click="confirm" class="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700">Infoga länk</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,9 +8,10 @@ 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, AsciidocImage } from '@/bridge/asciidoc-extensions'
|
||||
import { Admonition, CustomCodeBlock, AsciidocImage, CustomHeading, Footnote, AsciidocInclude } from '@/bridge/asciidoc-extensions'
|
||||
import ImagePickerDialog from './ImagePickerDialog.vue'
|
||||
import ImageEditDialog from './ImageEditDialog.vue'
|
||||
import LinkDialog from './LinkDialog.vue'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = defineProps<{ slug?: string }>()
|
||||
@@ -18,17 +19,38 @@ const props = defineProps<{ slug?: string }>()
|
||||
const isEditing = ref(false)
|
||||
const showImagePicker = ref(false)
|
||||
const showImageEditor = ref(false)
|
||||
const showLinkDialog = ref({ open: false, url: '', text: '' })
|
||||
|
||||
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 tocItems = ref<{id: string, text: string, level: number, pos: number}[]>([])
|
||||
|
||||
function extractTOC() {
|
||||
if (!editor.value) return
|
||||
const items: {id: string, text: string, level: number, pos: number}[] = []
|
||||
editor.value.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
let id = node.attrs.id
|
||||
if (!id) {
|
||||
id = '_' + node.textContent.toLowerCase().replace(/[^a-z0-9]+/g, '_')
|
||||
}
|
||||
items.push({ id, text: node.textContent, level: node.attrs.level, pos: pos + 1 })
|
||||
}
|
||||
})
|
||||
tocItems.value = items
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
StarterKit.configure({ codeBlock: false, heading: false }),
|
||||
CustomHeading,
|
||||
CustomCodeBlock,
|
||||
Link,
|
||||
Admonition,
|
||||
AsciidocImage,
|
||||
Footnote,
|
||||
AsciidocInclude,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
HTMLAttributes: {
|
||||
@@ -49,7 +71,11 @@ const editor = useEditor({
|
||||
editable: false,
|
||||
onUpdate({ editor }) {
|
||||
model.value = fromTipTap(editor.getJSON())
|
||||
extractTOC()
|
||||
},
|
||||
onCreate() {
|
||||
extractTOC()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isEditing, (editing) => {
|
||||
@@ -164,6 +190,35 @@ function handleImageInsert(data: { src: string; alt: string; title: string, 'dat
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkInsert(data: { url: string; text: string }) {
|
||||
if (!editor.value) return
|
||||
if (data.text) {
|
||||
editor.value.commands.insertContent(`<a href="${data.url}">${data.text}</a>`)
|
||||
} else {
|
||||
editor.value.commands.setLink({ href: data.url })
|
||||
}
|
||||
}
|
||||
|
||||
function openLinkDialog() {
|
||||
if (!editor.value) return
|
||||
const attrs = editor.value.getAttributes('link')
|
||||
|
||||
// Try to get selected text if no link currently active
|
||||
const { from, to } = editor.value.state.selection
|
||||
const text = editor.value.state.doc.textBetween(from, to, ' ')
|
||||
|
||||
showLinkDialog.value = {
|
||||
open: true,
|
||||
url: attrs.href || '',
|
||||
text: text || ''
|
||||
}
|
||||
}
|
||||
|
||||
function unsetLink() {
|
||||
if (!editor.value) return
|
||||
editor.value.commands.unsetLink()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
document.removeEventListener('click', closeContextMenu)
|
||||
@@ -179,22 +234,92 @@ onMounted(() => {
|
||||
document.addEventListener('click', closeContextMenu)
|
||||
})
|
||||
|
||||
function insertFootnote() {
|
||||
const text = prompt('Ange fotnot-text:')
|
||||
if (text && editor.value) {
|
||||
editor.value.commands.insertContent({
|
||||
type: 'footnote',
|
||||
attrs: { content: text }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function insertInclude() {
|
||||
const file = prompt('Ange fil att inkludera (ex. undermapp/fil.adoc):')
|
||||
if (file && editor.value) {
|
||||
const attrs = prompt('Extrainställningar ex: leveloffset=+1 (lämna tomt annars):') || ''
|
||||
editor.value.commands.insertContent({
|
||||
type: 'asciidocInclude',
|
||||
attrs: { file, attrs }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHeading(item: any) {
|
||||
if (isEditing.value && editor.value) {
|
||||
editor.value.commands.setTextSelection(item.pos)
|
||||
editor.value.commands.scrollIntoView()
|
||||
} else {
|
||||
// In read mode, typical asciidoctor generates IDs like _heading_name
|
||||
const els = document.querySelectorAll(`h${item.level}`)
|
||||
let target: HTMLElement | null = document.getElementById(item.id)
|
||||
if (!target) {
|
||||
// Find by text content if ID fails to match asciidoctor's exact generation
|
||||
for (const h of els) {
|
||||
if (h.textContent?.includes(item.text)) {
|
||||
target = h as HTMLElement
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function btnClass(active: boolean | undefined) {
|
||||
return [
|
||||
'px-2 py-0.5 rounded text-sm font-medium transition-colors select-none',
|
||||
active ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-700',
|
||||
]
|
||||
}
|
||||
function handleEditorClick(e: MouseEvent) {
|
||||
if (isEditing.value) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const closestLink = target.closest('a')
|
||||
|
||||
if (closestLink) {
|
||||
const href = closestLink.getAttribute('href')
|
||||
if (href && href.startsWith('#')) {
|
||||
e.preventDefault()
|
||||
const element = document.getElementById(href.slice(1))
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-white dark:bg-slate-900 relative">
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
v-if="editor"
|
||||
v-if="editor && isEditing"
|
||||
class="flex overflow-x-auto items-center gap-1 p-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/80 flex-shrink-0"
|
||||
:class="{ 'opacity-50 pointer-events-none': !isEditing }"
|
||||
>
|
||||
<button
|
||||
@click="editor.chain().focus().unsetAllMarks().run()"
|
||||
class="px-2 py-0.5 rounded text-sm font-medium transition-colors select-none text-slate-500 hover:text-slate-800 hover:bg-slate-200"
|
||||
title="Rensa textformatering"
|
||||
>
|
||||
T
|
||||
</button>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
||||
|
||||
<button
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="btnClass(editor.isActive('bold'))"
|
||||
@@ -218,6 +343,25 @@ function btnClass(active: boolean | undefined) {
|
||||
</button>
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
||||
|
||||
<button
|
||||
@click="openLinkDialog"
|
||||
:class="btnClass(editor.isActive('link'))"
|
||||
title="Länk"
|
||||
>
|
||||
Länk
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="unsetLink"
|
||||
v-if="editor.isActive('link')"
|
||||
class="text-xs px-1 text-red-500 hover:text-red-700 font-bold ml-1"
|
||||
title="Ta bort länk"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-slate-600 mx-1"></div>
|
||||
|
||||
<!-- Rubriker -->
|
||||
<button
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
@@ -301,16 +445,86 @@ function btnClass(active: boolean | undefined) {
|
||||
Bild
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="insertFootnote"
|
||||
class="px-2 py-0.5 text-sm font-medium transition-colors select-none text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 mx-1"
|
||||
title="Infoga Fotnot"
|
||||
>
|
||||
<span class="text-xs sup bg-indigo-100 dark:bg-indigo-900 px-1 rounded">[#]</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="insertInclude"
|
||||
class="px-2 py-0.5 text-sm font-medium transition-colors select-none text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700"
|
||||
title="Infoga deldokument"
|
||||
>
|
||||
<span class="text-xs font-mono">include::</span>
|
||||
</button>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<!-- Finish editing -->
|
||||
<button
|
||||
v-if="isEditing"
|
||||
@click="isEditing = false"
|
||||
class="ml-auto px-3 py-1 bg-green-500 hover:bg-green-600 text-white rounded text-sm shadow-sm transition"
|
||||
>
|
||||
✔ Klar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Edit Area -->
|
||||
<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)"
|
||||
<!-- Banner when not editing -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="bg-indigo-50 dark:bg-slate-800/80 border-b border-indigo-100 dark:border-slate-700/60 flex items-center justify-between p-2 flex-shrink-0"
|
||||
>
|
||||
<span class="text-sm text-indigo-700 dark:text-indigo-300 ml-2">Läsläge</span>
|
||||
<button
|
||||
@click="isEditing = true"
|
||||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-sm shadow transition font-medium"
|
||||
>
|
||||
<EditorContent :editor="editor" />
|
||||
✏ Börja redigera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Edit Area with Splitted TOC Sidebar -->
|
||||
<div class="flex-grow flex overflow-hidden w-full relative">
|
||||
<!-- Main Content -->
|
||||
<div
|
||||
class="flex-grow overflow-y-auto p-4 w-full"
|
||||
@contextmenu="onContextMenu"
|
||||
@click="handleEditorClick"
|
||||
>
|
||||
<div
|
||||
class="max-w-2xl mx-auto prose prose-indigo dark:prose-invert"
|
||||
>
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar (TOC) -->
|
||||
<div
|
||||
v-if="tocItems.length > 1"
|
||||
class="hidden lg:block w-64 border-l border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/10 p-4 overflow-y-auto select-none flex-shrink-0 relative"
|
||||
>
|
||||
<div class="sticky top-0">
|
||||
<h4 class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">
|
||||
Innehåll
|
||||
</h4>
|
||||
<ul class="space-y-1.5">
|
||||
<li v-for="item in tocItems" :key="item.pos" :style="{ paddingLeft: `${(item.level - 1) * 0.75}rem` }">
|
||||
<button
|
||||
@click="scrollToHeading(item)"
|
||||
class="group flex text-left w-full items-baseline space-x-1"
|
||||
>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-slate-300 dark:bg-slate-600 flex-shrink-0 group-hover:bg-indigo-500 transition-colors"></div>
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-slate-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 truncate w-[calc(100%-8px)] transition-colors">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,6 +542,14 @@ function btnClass(active: boolean | undefined) {
|
||||
@update="handleImageUpdate"
|
||||
/>
|
||||
|
||||
<LinkDialog
|
||||
:is-open="showLinkDialog.open"
|
||||
:initial-url="showLinkDialog.url"
|
||||
:initial-text="showLinkDialog.text"
|
||||
@close="showLinkDialog.open = false"
|
||||
@confirm="handleLinkInsert"
|
||||
/>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div
|
||||
v-if="contextMenu.show"
|
||||
|
||||
70
frontend/src/components/layout/CreateItemDialog.vue
Normal file
70
frontend/src/components/layout/CreateItemDialog.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
type: 'folder' | 'document';
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'confirm', name: string): void;
|
||||
}>()
|
||||
|
||||
const inputName = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
inputName.value = ''
|
||||
setTimeout(() => inputRef.value?.focus(), 50)
|
||||
}
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
if (inputName.value.trim()) {
|
||||
emit('confirm', inputName.value.trim())
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isOpen" class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-sm w-full overflow-hidden border border-slate-200 dark:border-slate-700 transform transition-all">
|
||||
<div class="p-5 flex items-center gap-3 border-b border-slate-100 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-800/50">
|
||||
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||
<svg v-if="type === 'folder'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"></path></svg>
|
||||
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 9.414V19a2 2 0 01-2 2z"></path></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg text-slate-800 dark:text-slate-100">
|
||||
{{ type === 'folder' ? 'Ny Mapp' : 'Nytt Dokument' }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1">
|
||||
{{ type === 'folder' ? 'Välj ett namn för mappen' : 'Välj en titel för dokumentet' }}
|
||||
</label>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputName"
|
||||
@keydown.enter="confirm"
|
||||
@keydown.esc="emit('close')"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-lg shadow-sm text-slate-800 dark:text-slate-100 transition-colors"
|
||||
:placeholder="type === 'folder' ? 't.ex. Projekt X 📂' : 't.ex. Mötesanteckningar 📝'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 border-t border-slate-100 dark:border-slate-700/50 bg-slate-50 dark:bg-slate-800/80 flex justify-end gap-3">
|
||||
<button @click="emit('close')" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
|
||||
Avbryt
|
||||
</button>
|
||||
<button @click="confirm" :disabled="!inputName.trim()" class="px-5 py-2 text-sm font-medium bg-indigo-600 text-white rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all">
|
||||
Skapa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
||||
import CreateItemDialog from './CreateItemDialog.vue'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
@@ -70,41 +71,47 @@ function buildTree(docs: DocMeta[], folders: string[]): TreeFolder {
|
||||
return root
|
||||
}
|
||||
|
||||
async function handleCreateRootFolder() {
|
||||
const name = prompt('Enter folder name:')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
await createFolder(name)
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
}
|
||||
const createDialog = reactive({
|
||||
isOpen: false,
|
||||
type: 'document' as 'folder' | 'document',
|
||||
parentPath: ''
|
||||
})
|
||||
|
||||
function promptCreateRootFolder() {
|
||||
createDialog.type = 'folder'
|
||||
createDialog.parentPath = ''
|
||||
createDialog.isOpen = true
|
||||
}
|
||||
|
||||
async function handleCreateFolder(parentPath: string) {
|
||||
const name = prompt('Enter folder name:')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
const newPath = parentPath ? `${parentPath}/${name}` : name
|
||||
await createFolder(newPath)
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
openFolders[parentPath] = true
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
}
|
||||
function promptCreateItem(type: 'folder' | 'document', parentPath: string) {
|
||||
createDialog.type = type
|
||||
createDialog.parentPath = parentPath
|
||||
createDialog.isOpen = true
|
||||
}
|
||||
|
||||
function handleCreateDoc(parentPath: string) {
|
||||
const title = prompt('Enter document title:')
|
||||
if (!title?.trim()) return
|
||||
const slug = parentPath ? `${parentPath}/${title.toLowerCase().replace(/\\s+/g, '-')}` : title.toLowerCase().replace(/\\s+/g, '-')
|
||||
async function handleDialogConfirm(name: string) {
|
||||
const { type, parentPath } = createDialog
|
||||
|
||||
// store intended initial slug for the /doc/new page to pick up via state or similar
|
||||
sessionStorage.setItem('newDocSlug', slug)
|
||||
router.push('/doc/new')
|
||||
emit('close')
|
||||
if (type === 'folder') {
|
||||
try {
|
||||
const newPath = parentPath ? `${parentPath}/${name}` : name
|
||||
await createFolder(newPath)
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
if (parentPath) openFolders[parentPath] = true
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
}
|
||||
} else {
|
||||
// Document
|
||||
const slug = parentPath
|
||||
? `${parentPath}/${name.toLowerCase().replace(/\\s+/g, '-')}`
|
||||
: name.toLowerCase().replace(/\\s+/g, '-')
|
||||
|
||||
sessionStorage.setItem('newDocSlug', slug)
|
||||
router.push('/doc/new')
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, item: FlatItem) {
|
||||
@@ -313,16 +320,16 @@ function isActive(slug: string) {
|
||||
class="absolute right-0 top-full mt-1 w-32 bg-white dark:bg-slate-800 rounded-lg shadow-lg z-50 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<button
|
||||
@click.stop="handleCreateFolder(item.path); showCreateMenu[item.path] = false"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-t-lg"
|
||||
@click.stop="promptCreateItem('folder', item.path); showCreateMenu[item.path] = false"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-t-lg flex items-center gap-2"
|
||||
>
|
||||
+ Folder
|
||||
<span class="text-amber-500">📁</span> Mapp
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleCreateDoc(item.path); showCreateMenu[item.path] = false"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-b-lg"
|
||||
@click.stop="promptCreateItem('document', item.path); showCreateMenu[item.path] = false"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 rounded-b-lg flex items-center gap-2"
|
||||
>
|
||||
+ Document
|
||||
<span class="text-indigo-400">📄</span> Dokument
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,25 +362,20 @@ function isActive(slug: string) {
|
||||
<!-- Bottom actions -->
|
||||
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
|
||||
<button
|
||||
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-1.5"
|
||||
@click="handleCreateRootFolder"
|
||||
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-2"
|
||||
@click="promptCreateRootFolder"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
New folder
|
||||
<span class="text-amber-500">📁</span>
|
||||
Ny mapp (rot)
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
to="/doc/new"
|
||||
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-1.5"
|
||||
@click="emit('close')"
|
||||
<button
|
||||
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-2"
|
||||
@click="promptCreateItem('document', '')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
New document
|
||||
</router-link>
|
||||
<span class="text-indigo-200">📝</span>
|
||||
Nytt dokument (rot)
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
to="/admin"
|
||||
@@ -388,5 +390,12 @@ function isActive(slug: string) {
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Create Dialog (Folder / Document) -->
|
||||
<CreateItemDialog
|
||||
:is-open="createDialog.isOpen"
|
||||
:type="createDialog.type"
|
||||
@close="createDialog.isOpen = false"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user