Added Internal Links #84
This commit is contained in:
@@ -4,26 +4,26 @@
|
||||
<div v-if="editor" class="border-b border-gray-100 dark:border-gray-800 px-4 py-2 flex flex-wrap gap-1 items-center sticky top-0 bg-white dark:bg-gray-900 z-20 shadow-sm select-none">
|
||||
|
||||
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
|
||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }" class="toolbar-btn font-bold">B</button>
|
||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }" class="toolbar-btn italic">I</button>
|
||||
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }" class="toolbar-btn line-through">S</button>
|
||||
<button @click="editor.chain().focus().toggleHighlight().run()" :class="{ 'is-active': editor.isActive('highlight') }" class="toolbar-btn text-yellow-500 font-bold">M</button>
|
||||
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }" class="toolbar-btn font-bold" title="Fett">B</button>
|
||||
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }" class="toolbar-btn italic" title="Kursiv">I</button>
|
||||
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }" class="toolbar-btn line-through" title="Durchgestrichen">S</button>
|
||||
<button @click="editor.chain().focus().toggleHighlight().run()" :class="{ 'is-active': editor.isActive('highlight') }" class="toolbar-btn text-yellow-500 font-bold" title="Markieren">M</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" class="toolbar-btn">H1</button>
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="toolbar-btn">H2</button>
|
||||
<button @click="editor.chain().focus().toggleCodeBlock().run()" :class="{ 'is-active': editor.isActive('codeBlock') }" class="toolbar-btn font-mono text-xs"></></button>
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" class="toolbar-btn" title="Überschrift 1">H1</button>
|
||||
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" class="toolbar-btn" title="Überschrift 2">H2</button>
|
||||
<button @click="editor.chain().focus().toggleCodeBlock().run()" :class="{ 'is-active': editor.isActive('codeBlock') }" class="toolbar-btn font-mono text-xs" title="Code Block"></></button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-2 mr-2">
|
||||
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }" class="toolbar-btn">•</button>
|
||||
<button @click="editor.chain().focus().toggleTaskList().run()" :class="{ 'is-active': editor.isActive('taskList') }" class="toolbar-btn">☑</button>
|
||||
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }" class="toolbar-btn" title="Liste">•</button>
|
||||
<button @click="editor.chain().focus().toggleTaskList().run()" :class="{ 'is-active': editor.isActive('taskList') }" class="toolbar-btn" title="Aufgabenliste">☑</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 items-center">
|
||||
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="toolbar-btn">▦</button>
|
||||
<button @click="addVideo" class="toolbar-btn text-red-600">
|
||||
<button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()" class="toolbar-btn" title="Tabelle">▦</button>
|
||||
<button @click="addVideo" class="toolbar-btn text-red-600" title="YouTube Video">
|
||||
<UIcon name="i-heroicons-video-camera" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -71,19 +71,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRouter } from '#app'
|
||||
import { useEditor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
|
||||
// @ts-ignore
|
||||
import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
|
||||
|
||||
// Tiptap Extensions
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import CodeBlock from '@tiptap/extension-code-block'
|
||||
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 BubbleMenuExtension from '@tiptap/extension-bubble-menu'
|
||||
import FloatingMenuExtension from '@tiptap/extension-floating-menu'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
@@ -92,18 +87,39 @@ import Highlight from '@tiptap/extension-highlight'
|
||||
import Youtube from '@tiptap/extension-youtube'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import CodeBlock from '@tiptap/extension-code-block'
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// WICHTIGE ÄNDERUNG: NUR Table, KEINE Row/Cell/Header Imports
|
||||
// -----------------------------------------------------------
|
||||
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 BubbleMenuExtension from '@tiptap/extension-bubble-menu'
|
||||
import FloatingMenuExtension from '@tiptap/extension-floating-menu'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
|
||||
// IMPORT DER NEUEN KOMPONENTE
|
||||
import TiptapTaskItem from './TiptapTaskItem.vue'
|
||||
import wikiSuggestion from '~/composables/useWikiSuggestion'
|
||||
|
||||
const props = defineProps<{ modelValue: any }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const router = useRouter()
|
||||
|
||||
// Damit wir TaskItem nicht ständig neu initialisieren
|
||||
const CustomTaskItem = TaskItem.extend({
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(TiptapTaskItem)
|
||||
},
|
||||
})
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
Placeholder.configure({ placeholder: 'Tippe "/" oder schreibe los...' }),
|
||||
Placeholder.configure({ placeholder: 'Tippe "/" für Befehle oder "#" für Seiten...' }),
|
||||
|
||||
BubbleMenuExtension.configure({ shouldShow: ({ editor, from, to }) => !editor.state.selection.empty && (to - from > 0) }),
|
||||
FloatingMenuExtension,
|
||||
@@ -112,35 +128,63 @@ const editor = useEditor({
|
||||
Image, Highlight, Typography, CharacterCount, CodeBlock,
|
||||
Youtube.configure({ width: 640, height: 480 }),
|
||||
|
||||
Table.configure({ resizable: true }), TableRow, TableHeader, TableCell,
|
||||
// -----------------------------------------------------------
|
||||
// WICHTIGE ÄNDERUNG: Nur Table.configure()
|
||||
// TableRow, TableHeader, TableCell wurden HIER ENTFERNT
|
||||
// -----------------------------------------------------------
|
||||
Table.configure({ resizable: true }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
|
||||
// TASK LIST KONFIGURATION
|
||||
// TASK LIST
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
CustomTaskItem.configure({
|
||||
nested: true,
|
||||
}).extend({
|
||||
// HIER SAGEN WIR TIPTAP: BENUTZE UNSERE VUE KOMPONENTE
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(TiptapTaskItem)
|
||||
}),
|
||||
|
||||
// INTERNAL LINKING
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'wiki-mention',
|
||||
},
|
||||
suggestion: {
|
||||
...wikiSuggestion,
|
||||
char: '#'
|
||||
},
|
||||
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'min-h-[500px] pb-40',
|
||||
},
|
||||
handleClick: (view, pos, event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.wiki-mention')) {
|
||||
const id = target.getAttribute('data-id')
|
||||
if (id) {
|
||||
router.push(`/wiki/${id}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
emit('update:modelValue', editor.getJSON())
|
||||
},
|
||||
})
|
||||
|
||||
// Sync Model -> Editor
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (editor.value && JSON.stringify(val) !== JSON.stringify(editor.value.getJSON())) {
|
||||
//@ts-ignore
|
||||
editor.value.commands.setContent(val, false)
|
||||
}
|
||||
})
|
||||
|
||||
// ACTIONS
|
||||
const setLink = () => {
|
||||
if (!editor.value) return
|
||||
const previousUrl = editor.value.getAttributes('link').href
|
||||
@@ -174,22 +218,38 @@ const addVideo = () => {
|
||||
@apply bg-gray-200 dark:bg-gray-600 text-black dark:text-white;
|
||||
}
|
||||
|
||||
/* EDITOR STYLES */
|
||||
/* GLOBAL EDITOR STYLES */
|
||||
:deep(.prose) {
|
||||
/* Cursor Fix */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
caret-color: currentColor;
|
||||
}
|
||||
|
||||
/* LIST RESET: Wir müssen nur die UL resetten, die LI wird von Vue gerendert */
|
||||
/* LIST RESET */
|
||||
ul[data-type="taskList"] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Table Fixes */
|
||||
/* MENTION */
|
||||
.wiki-mention {
|
||||
/* Pill-Shape, grau/neutral statt knallig blau */
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 px-1.5 py-0.5 rounded-md text-sm font-medium no-underline inline-block mx-0.5 align-middle border border-gray-200 dark:border-gray-700;
|
||||
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
.wiki-mention::before {
|
||||
@apply text-gray-400 dark:text-gray-500 mr-0.5;
|
||||
}
|
||||
|
||||
.wiki-mention:hover {
|
||||
@apply bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
table { width: 100% !important; border-collapse: collapse; table-layout: fixed; margin: 1rem 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 0.5rem; vertical-align: top; box-sizing: border-box; min-width: 1em; }
|
||||
.dark th, .dark td { border-color: #374151; }
|
||||
@@ -197,16 +257,16 @@ const addVideo = () => {
|
||||
.dark th { background-color: #1f2937; }
|
||||
.column-resize-handle { background-color: #3b82f6; width: 4px; }
|
||||
|
||||
/* Code Block */
|
||||
/* CODE */
|
||||
pre { background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono', monospace; padding: 0.75rem 1rem; border-radius: 0.5rem; margin: 1rem 0; overflow-x: auto; }
|
||||
code { color: inherit; padding: 0; background: none; font-size: 0.9rem; }
|
||||
|
||||
/* Images */
|
||||
/* IMG */
|
||||
img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 1rem 0; }
|
||||
|
||||
/* Misc */
|
||||
/* MISC */
|
||||
p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
|
||||
mark { background-color: #fef08a; padding: 0.1rem 0.2rem; border-radius: 0.2rem; }
|
||||
.ProseMirror-selectednode { outline: 2px solid #3b82f6; }
|
||||
p.is-editor-empty:first-child::before { color: #9ca3af; content: attr(data-placeholder); float: left; height: 0; pointer-events: none; }
|
||||
}
|
||||
</style>
|
||||
57
frontend/components/wiki/WikiPageList.vue
Normal file
57
frontend/components/wiki/WikiPageList.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[12rem] flex flex-col p-1 gap-0.5">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-2 px-2 py-1.5 text-sm rounded text-left transition-colors w-full"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400': index === selectedIndex, 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200': index !== selectedIndex }"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<UIcon :name="item.isFolder ? 'i-heroicons-folder' : 'i-heroicons-document-text'" class="w-4 h-4 text-gray-400" />
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="px-3 py-2 text-xs text-gray-400 text-center">
|
||||
Keine Seite gefunden
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
command: { type: Function, required: true },
|
||||
})
|
||||
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
// Wenn sich die Liste ändert, Reset Selection
|
||||
watch(() => props.items, () => { selectedIndex.value = 0 })
|
||||
|
||||
function selectItem(index: number) {
|
||||
const item = props.items[index]
|
||||
if (item) props.command({ id: item.id, label: item.title })
|
||||
}
|
||||
|
||||
function onKeyDown({ event }: { event: KeyboardEvent }) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex.value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Expose für Tiptap Render Logic
|
||||
defineExpose({ onKeyDown })
|
||||
</script>
|
||||
65
frontend/composables/useWikiSuggestion.ts
Normal file
65
frontend/composables/useWikiSuggestion.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import tippy from 'tippy.js'
|
||||
import WikiPageList from '~/components/wiki/WikiPageList.vue'
|
||||
|
||||
// Wir brauchen Zugriff auf die rohen Items aus useWikiTree
|
||||
// Da wir hier ausserhalb von setup() sind, müssen wir den State direkt holen oder übergeben.
|
||||
// Einfacher: Wir nutzen useNuxtApp() oder übergeben die Items in der Config.
|
||||
|
||||
export default {
|
||||
items: ({ query }: { query: string }) => {
|
||||
// 1. Zugriff auf unsere Wiki Items
|
||||
const { items } = useWikiTree()
|
||||
|
||||
// 2. Filtern
|
||||
const allItems = items.value || []
|
||||
return allItems
|
||||
.filter(item => item.title.toLowerCase().includes(query.toLowerCase()))
|
||||
.slice(0, 10) // Max 10 Vorschläge
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: any
|
||||
let popup: any
|
||||
|
||||
return {
|
||||
onStart: (props: any) => {
|
||||
component = new VueRenderer(WikiPageList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate(props: any) {
|
||||
component.updateProps(props)
|
||||
if (!props.clientRect) return
|
||||
popup[0].setProps({ getReferenceClientRect: props.clientRect })
|
||||
},
|
||||
|
||||
onKeyDown(props: any) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
return true
|
||||
}
|
||||
return component.ref?.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
|
||||
modules: ['@pinia/nuxt', '@nuxt/ui', '@nuxtjs/supabase', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', 'nuxt-tiptap-editor', '@nuxtjs/leaflet', '@vueuse/nuxt'],
|
||||
modules: ['@pinia/nuxt', '@nuxt/ui', '@nuxtjs/supabase', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', '@nuxtjs/leaflet', '@vueuse/nuxt'],
|
||||
|
||||
ssr: false,
|
||||
|
||||
@@ -41,9 +41,42 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
vite: {
|
||||
resolve: {
|
||||
dedupe: [
|
||||
'vue',
|
||||
'@tiptap/vue-3',
|
||||
'prosemirror-model',
|
||||
'prosemirror-view',
|
||||
'prosemirror-state',
|
||||
'prosemirror-commands',
|
||||
'prosemirror-schema-list',
|
||||
'prosemirror-transform',
|
||||
'prosemirror-history',
|
||||
'prosemirror-gapcursor',
|
||||
'prosemirror-dropcursor',
|
||||
'prosemirror-tables'
|
||||
]
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@editorjs/editorjs", "dayjs",'@tiptap/vue-3','@tiptap/extension-code-block-lowlight',
|
||||
'lowlight',],
|
||||
include: [
|
||||
"@editorjs/editorjs",
|
||||
"dayjs",
|
||||
'@tiptap/vue-3',
|
||||
'@tiptap/extension-code-block-lowlight',
|
||||
'lowlight',
|
||||
'vue',
|
||||
'@tiptap/extension-task-item',
|
||||
'@tiptap/extension-task-list',
|
||||
'@tiptap/extension-table',
|
||||
'@tiptap/extension-mention',
|
||||
'prosemirror-model',
|
||||
'prosemirror-view',
|
||||
'prosemirror-state',
|
||||
'prosemirror-commands',
|
||||
'prosemirror-transform',
|
||||
'tippy.js',
|
||||
'prosemirror-tables',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,11 +88,6 @@ export default defineNuxtConfig({
|
||||
preference: 'system'
|
||||
},
|
||||
|
||||
|
||||
tiptap: {
|
||||
prefix: "Tiptap"
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
|
||||
public: {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/nuxt": "^14.1.0",
|
||||
"nuxt": "^3.14.1592",
|
||||
"nuxt-tiptap-editor": "^1.2.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
@@ -53,6 +52,7 @@
|
||||
"@tiptap/extension-highlight": "^3.17.1",
|
||||
"@tiptap/extension-image": "^3.17.1",
|
||||
"@tiptap/extension-link": "^3.17.1",
|
||||
"@tiptap/extension-mention": "^3.17.1",
|
||||
"@tiptap/extension-placeholder": "^3.17.1",
|
||||
"@tiptap/extension-table": "^3.17.1",
|
||||
"@tiptap/extension-table-cell": "^3.17.1",
|
||||
@@ -95,6 +95,7 @@
|
||||
"sass": "^1.69.7",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss-safe-area-capacitor": "^0.5.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^11.0.3",
|
||||
"uuidv4": "^6.2.13",
|
||||
"v-calendar": "^3.1.2",
|
||||
|
||||
Reference in New Issue
Block a user