diff --git a/frontend/src/bridge/asciidoc-bridge.ts b/frontend/src/bridge/asciidoc-bridge.ts index f129a2a..b78baa5 100644 --- a/frontend/src/bridge/asciidoc-bridge.ts +++ b/frontend/src/bridge/asciidoc-bridge.ts @@ -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, '+++Fotnot+++') + // Regex matches `include::file[attrs]` keeping context. + preAdoc = preAdoc.replace(/^include::(.*?)\[(.*?)\](?:$|\r?\n)/gm, '++++\n
\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') { diff --git a/frontend/src/bridge/asciidoc-extensions.ts b/frontend/src/bridge/asciidoc-extensions.ts index bfb343c..30f6931 100644 --- a/frontend/src/bridge/asciidoc-extensions.ts +++ b/frontend/src/bridge/asciidoc-extensions.ts @@ -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] }, }) diff --git a/frontend/src/components/editor/LinkDialog.vue b/frontend/src/components/editor/LinkDialog.vue new file mode 100644 index 0000000..dbf6530 --- /dev/null +++ b/frontend/src/components/editor/LinkDialog.vue @@ -0,0 +1,133 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/editor/VisualEditor.vue b/frontend/src/components/editor/VisualEditor.vue index da72f77..b910f0f 100644 --- a/frontend/src/components/editor/VisualEditor.vue +++ b/frontend/src/components/editor/VisualEditor.vue @@ -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({ 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(`${data.text}`) + } 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' }) + } + } + } +}