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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Laddar...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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' })
+ }
+ }
+ }
+}
+
+
+
+
-
-
-
+
+ Läsläge
+
-
+ ✏ Börja redigera
+
+
+
+
+
+
+
+
+
+
+
+
+ Innehåll
+
+
+ -
+
+
+
+ {{ item.text }}
+
+
+
+
+
@@ -328,6 +542,14 @@ function btnClass(active: boolean | undefined) {
@update="handleImageUpdate"
/>
+
+
+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
(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')
+ }
+}
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue
index 9019f7e..be47243 100644
--- a/frontend/src/components/layout/Sidebar.vue
+++ b/frontend/src/components/layout/Sidebar.vue
@@ -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"
>
- + Folder
+ 📁 Mapp
- + Document
+ 📄 Dokument
@@ -355,25 +362,20 @@ function isActive(slug: string) {
-
- New folder
+ 📁
+ Ny mapp (rot)
-
-
- New document
-
+
📝
+ Nytt dokument (rot)
+
+
+