upd md editor

This commit is contained in:
beo3000 2026-02-27 22:14:20 +01:00
parent c47f288e3e
commit d5a9f9ce00
9 changed files with 329 additions and 22 deletions

View File

@ -24,6 +24,7 @@
"@tiptap/starter-kit": "^3.20.0",
"dexie": "^4.0.11",
"dompurify": "^3.3.1",
"highlight.js": "^11.11.1",
"marked": "^17.0.3",
"tiptap-markdown": "^0.9.0"
},

View File

@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
@import 'highlight.js/styles/github-dark-dimmed.css';
:root {
--scope-color: #555566; /* updated dynamically by layout */
--bg-color: #1a1a22;
@ -79,6 +81,87 @@ ul.tree-list li {
margin-bottom: 0;
}
/* Callout blocks */
.callout {
border-radius: 6px;
padding: 0.6rem 0.9rem;
margin: 0.75rem 0;
border-left: 4px solid;
}
.callout-title {
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.callout-body > :first-child { margin-top: 0; }
.callout-body > :last-child { margin-bottom: 0; }
.callout-note { background: #1a2a3a; border-color: var(--accent); }
.callout-note .callout-title { color: var(--accent); }
.callout-info { background: #1a2a3a; border-color: var(--info); }
.callout-info .callout-title { color: var(--info); }
.callout-tip { background: #1a2e22; border-color: var(--success); }
.callout-tip .callout-title { color: var(--success); }
.callout-success { background: #1a2e22; border-color: var(--success); }
.callout-success .callout-title { color: var(--success); }
.callout-warning { background: #2e2510; border-color: var(--warning); }
.callout-warning .callout-title { color: var(--warning); }
.callout-danger { background: #2e1515; border-color: var(--danger); }
.callout-danger .callout-title { color: var(--danger); }
.callout-caution { background: #2e1515; border-color: var(--danger); }
.callout-caution .callout-title { color: var(--danger); }
/* Tables */
.markdown-content .table-wrapper {
overflow-x: auto;
margin: 0.75rem 0;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
font-size: 0.875rem;
}
.markdown-content th {
background: #2a2a38;
color: #e0e0e0;
font-weight: 600;
text-align: left;
padding: 0.4rem 0.75rem;
border: 1px solid var(--border-color);
}
.markdown-content td {
padding: 0.35rem 0.75rem;
border: 1px solid var(--border-color);
vertical-align: top;
}
.markdown-content tbody tr:nth-child(even) td {
background: #22222e;
}
/* Typography tweaks */
.markdown-content blockquote {
border-left: 3px solid var(--accent);
padding-left: 0.75rem;
color: #aaa;
font-style: italic;
margin-left: 0;
}
.markdown-content hr {
border: none;
height: 1px;
background: linear-gradient(to right, transparent, var(--border-color), transparent);
margin: 1.25rem 0;
}
/* Tiptap Editor */
.ka-editor-wrapper {
@apply rounded border border-[#444] bg-bg font-mono text-white;
@ -122,6 +205,38 @@ ul.tree-list li {
padding-left: 20px;
}
/* Table styles in editor */
.ka-editor-wrapper .ProseMirror table {
border-collapse: collapse;
width: 100%;
font-size: 0.875rem;
margin: 0.5rem 0;
overflow-x: auto;
display: block;
}
.ka-editor-wrapper .ProseMirror th,
.ka-editor-wrapper .ProseMirror td {
border: 1px solid #444;
padding: 0.3rem 0.6rem;
vertical-align: top;
min-width: 60px;
position: relative;
}
.ka-editor-wrapper .ProseMirror th {
background: #2a2a38;
font-weight: 600;
}
.ka-editor-wrapper .ProseMirror .selectedCell::after {
content: '';
position: absolute;
inset: 0;
background: rgba(74, 158, 255, 0.15);
pointer-events: none;
}
/* Rating indicators on @NAME tags */
.person-ref.rating-1 {
border-bottom: 3px solid #d9534f !important;

View File

@ -28,6 +28,7 @@
let element: HTMLDivElement;
let bubbleMenuEl: HTMLDivElement;
let tableMenuEl: HTMLDivElement;
let editor: Editor | null = null;
let skipNextUpdate = false;
let lastExternalContent = $state(content);
@ -103,10 +104,17 @@
if (!view.hasFocus()) return false;
if (from === to) return false;
if (e.isActive('image')) return false;
if (e.isActive('table')) return false;
const { empty } = e.state.selection;
return !empty;
},
}),
BubbleMenu.configure({
element: tableMenuEl,
pluginKey: 'tableMenu',
tippyOptions: { duration: 100, placement: 'top-start', hideOnClick: false },
shouldShow: ({ editor: e }) => e.isActive('table'),
}),
],
content: initialContent,
editorProps: {
@ -163,6 +171,27 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="ka-editor-wrapper" onclick={(e) => e.stopPropagation()}>
<div bind:this={element}></div>
<div bind:this={tableMenuEl} class="ka-bubble-menu ka-table-menu">
<span class="bubble-label">Zeile</span>
<button class="bubble-btn" title="Zeile darunter einfügen"
onmousedown={(e) => { e.preventDefault(); editor?.commands.addRowAfter(); }}
>+</button>
<button class="bubble-btn" title="Zeile löschen"
onmousedown={(e) => { e.preventDefault(); editor?.commands.deleteRow(); }}
></button>
<span class="bubble-sep"></span>
<span class="bubble-label">Spalte</span>
<button class="bubble-btn" title="Spalte rechts einfügen"
onmousedown={(e) => { e.preventDefault(); editor?.commands.addColumnAfter(); }}
>+</button>
<button class="bubble-btn" title="Spalte löschen"
onmousedown={(e) => { e.preventDefault(); editor?.commands.deleteColumn(); }}
></button>
<span class="bubble-sep"></span>
<button class="bubble-btn bubble-btn-danger" title="Tabelle löschen"
onmousedown={(e) => { e.preventDefault(); editor?.commands.deleteTable(); }}
>✕</button>
</div>
<div bind:this={bubbleMenuEl} class="ka-bubble-menu">
<button class="bubble-btn"
class:bubble-btn-active={editor?.isActive('bold')}

View File

@ -5,6 +5,23 @@
gap: 8px;
}
.slash-submenu-arrow {
margin-left: auto;
color: #777;
font-size: 0.85rem;
line-height: 1;
}
.slash-submenu-arrow-open {
color: #aaa;
}
.slash-child-item {
padding-left: 2rem !important;
font-size: 0.9em;
border-left: 2px solid #444;
}
.slash-command-icon {
display: inline-flex;
align-items: center;
@ -59,3 +76,31 @@
.bubble-btn-italic {
font-style: italic;
}
.bubble-btn-danger {
color: #e05555;
}
.bubble-btn-danger:hover {
background: #3a1515;
color: #ff6b6b;
}
.bubble-sep {
width: 1px;
height: 16px;
background: #555;
margin: 0 2px;
flex-shrink: 0;
}
.bubble-label {
font-size: 0.7rem;
color: #777;
padding: 0 2px;
user-select: none;
}
.ka-table-menu {
gap: 1px;
}

View File

@ -10,9 +10,28 @@ interface SlashCommand {
label: string;
icon: string;
keywords: string[];
execute: (editor: Editor) => void;
execute?: (editor: Editor) => void;
children?: SlashCommand[];
}
const TABLE_CHILDREN: SlashCommand[] = [
{ label: '2 × 2', icon: '⊞', keywords: [], execute: (e) => e.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run() },
{ label: '3 × 3', icon: '⊞', keywords: [], execute: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
{ label: '4 × 4', icon: '⊞', keywords: [], execute: (e) => e.chain().focus().insertTable({ rows: 4, cols: 4, withHeaderRow: true }).run() },
{ label: '5 × 5', icon: '⊞', keywords: [], execute: (e) => e.chain().focus().insertTable({ rows: 5, cols: 5, withHeaderRow: true }).run() },
{ label: '2 × 5', icon: '⊞', keywords: [], execute: (e) => e.chain().focus().insertTable({ rows: 5, cols: 2, withHeaderRow: true }).run() },
{ label: '3 × 6', icon: '⊞', keywords: [], execute: (e) => e.chain().focus().insertTable({ rows: 6, cols: 3, withHeaderRow: true }).run() },
];
const CALLOUT_CHILDREN: SlashCommand[] = [
{ label: 'Note', icon: '📝', keywords: ['note'], execute: (e) => e.chain().focus().insertContent('> [!NOTE]\n> ').run() },
{ label: 'Info', icon: '', keywords: ['info'], execute: (e) => e.chain().focus().insertContent('> [!INFO]\n> ').run() },
{ label: 'Tip', icon: '💡', keywords: ['tip'], execute: (e) => e.chain().focus().insertContent('> [!TIP]\n> ').run() },
{ label: 'Success', icon: '✅', keywords: ['success'], execute: (e) => e.chain().focus().insertContent('> [!SUCCESS]\n> ').run() },
{ label: 'Warning', icon: '⚠️', keywords: ['warning'], execute: (e) => e.chain().focus().insertContent('> [!WARNING]\n> ').run() },
{ label: 'Danger', icon: '🚨', keywords: ['danger'], execute: (e) => e.chain().focus().insertContent('> [!DANGER]\n> ').run() },
];
const ALL_COMMANDS: SlashCommand[] = [
{
label: 'Heading 1',
@ -66,7 +85,7 @@ const ALL_COMMANDS: SlashCommand[] = [
label: 'Table',
icon: '⊞',
keywords: ['table', 'grid', 'tabelle'],
execute: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
children: TABLE_CHILDREN,
},
{
label: 'Divider',
@ -74,6 +93,12 @@ const ALL_COMMANDS: SlashCommand[] = [
keywords: ['hr', 'rule', 'divider', 'horizontal', 'trennlinie'],
execute: (e) => e.chain().focus().setHorizontalRule().run(),
},
{
label: 'Callout',
icon: '⚠',
keywords: ['callout', 'note', 'warning', 'info', 'tip', 'success', 'danger', 'hinweis'],
children: CALLOUT_CHILDREN,
},
];
export const TiptapSlashCommand = Extension.create({
@ -83,9 +108,12 @@ export const TiptapSlashCommand = Extension.create({
const editor = this.editor;
let dropdown: HTMLDivElement | null = null;
let filtered: SlashCommand[] = [];
// selectedIndex: index into the flat rendered list (parents + expanded children)
let selectedIndex = 0;
let triggerFrom = -1;
let active = false;
// Which parent item is expanded (by index in filtered), -1 = none
let expandedIndex = -1;
function hide() {
dropdown?.remove();
@ -93,36 +121,80 @@ export const TiptapSlashCommand = Extension.create({
active = false;
filtered = [];
triggerFrom = -1;
expandedIndex = -1;
selectedIndex = 0;
}
/** Build the flat list of items currently rendered: parents, and if one is expanded, its children inline */
function flatList(): Array<{ cmd: SlashCommand; isChild: boolean; parentIdx: number }> {
const list: Array<{ cmd: SlashCommand; isChild: boolean; parentIdx: number }> = [];
filtered.forEach((cmd, i) => {
list.push({ cmd, isChild: false, parentIdx: i });
if (i === expandedIndex && cmd.children) {
cmd.children.forEach(child => list.push({ cmd: child, isChild: true, parentIdx: i }));
}
});
return list;
}
function render() {
if (!dropdown) return;
if (filtered.length === 0) { hide(); return; }
dropdown.innerHTML = '';
filtered.forEach((cmd, i) => {
const items = flatList();
items.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'mention-item slash-command-item'
+ (i === selectedIndex ? ' mention-item-active' : '');
+ (i === selectedIndex ? ' mention-item-active' : '')
+ (item.isChild ? ' slash-child-item' : '');
const iconSpan = document.createElement('span');
iconSpan.className = 'slash-command-icon';
iconSpan.textContent = cmd.icon;
iconSpan.textContent = item.cmd.icon;
const labelSpan = document.createElement('span');
labelSpan.textContent = cmd.label;
labelSpan.textContent = item.cmd.label;
row.appendChild(iconSpan);
row.appendChild(labelSpan);
row.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
selectItem(i);
if (!item.isChild && item.cmd.children) {
const arrowSpan = document.createElement('span');
arrowSpan.className = 'slash-submenu-arrow'
+ (expandedIndex === item.parentIdx ? ' slash-submenu-arrow-open' : '');
arrowSpan.textContent = expandedIndex === item.parentIdx ? '' : '';
row.appendChild(arrowSpan);
}
row.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
selectFlat(i);
});
row.addEventListener('mouseenter', () => { selectedIndex = i; render(); });
dropdown!.appendChild(row);
});
}
function selectFlat(flatIdx: number) {
const items = flatList();
const item = items[flatIdx];
if (!item) return;
if (!item.isChild && item.cmd.children) {
// Toggle expand
expandedIndex = expandedIndex === item.parentIdx ? -1 : item.parentIdx;
selectedIndex = flatIdx;
render();
return;
}
if (!item.cmd.execute) return;
const savedFrom = triggerFrom;
const savedTo = editor.state.selection.from;
editor.chain().focus().deleteRange({ from: savedFrom, to: savedTo }).run();
item.cmd.execute(editor);
hide();
}
function show(view: EditorView) {
if (!dropdown) {
dropdown = document.createElement('div');
@ -146,15 +218,6 @@ export const TiptapSlashCommand = Extension.create({
render();
}
function selectItem(index: number) {
const cmd = filtered[index];
if (!cmd) return;
const savedFrom = triggerFrom;
const savedTo = editor.state.selection.from;
editor.chain().focus().deleteRange({ from: savedFrom, to: savedTo }).run();
cmd.execute(editor);
hide();
}
function getSlashState(view: EditorView): { query: string; from: number } | null {
const { state } = view;
@ -219,7 +282,9 @@ export const TiptapSlashCommand = Extension.create({
props: {
handleKeyDown(view, event) {
if (!active) return false;
const count = filtered.length;
const items = flatList();
const count = items.length;
if (event.key === 'ArrowDown') {
event.preventDefault();
@ -235,7 +300,7 @@ export const TiptapSlashCommand = Extension.create({
}
if (event.key === 'Enter') {
event.preventDefault();
selectItem(selectedIndex);
selectFlat(selectedIndex);
return true;
}
if (event.key === 'Escape') {

View File

@ -1,5 +1,6 @@
import { Marked, type TokenizerExtension, type RendererExtension } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
// --- Custom inline extensions for Ka-Note tags ---
@ -113,10 +114,49 @@ const markedInstance = new Marked({
const display = isRawUrl ? truncateLinkUrl(href) : text;
const t = title ? ` title="${title}"` : '';
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${t}>${display}</a>`;
},
code({ text, lang }) {
const language = lang && hljs.getLanguage(lang) ? lang : undefined;
const highlighted = language
? hljs.highlight(text, { language }).value
: hljs.highlightAuto(text).value;
return `<pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre>`;
},
table() {
return false; // use default renderer; table-wrapper added in post-processing
}
}
});
// --- Table wrapper post-processing ---
function wrapTables(html: string): string {
return html.replace(/<table>/g, '<div class="table-wrapper"><table>').replace(/<\/table>/g, '</table></div>');
}
// --- Callout blocks post-processing ---
const CALLOUT_ICONS: Record<string, string> = {
note: '📝', info: '', tip: '💡', success: '✅',
warning: '⚠️', danger: '🚨', caution: '⚠️'
};
function processCallouts(html: string): string {
return html.replace(
/<blockquote>\s*<p>\[!(NOTE|INFO|TIP|SUCCESS|WARNING|DANGER|CAUTION)\](.*?)<\/p>([\s\S]*?)<\/blockquote>/gi,
(_, type: string, title: string, body: string) => {
const t = type.toLowerCase();
const icon = CALLOUT_ICONS[t] ?? '';
const label = title.trim() || type.toUpperCase();
const content = body.trim();
return `<div class="callout callout-${t}">`
+ `<div class="callout-title">${icon} ${label}</div>`
+ (content ? `<div class="callout-body">${content}</div>` : '')
+ `</div>`;
}
);
}
// --- Collapsible list post-processing ---
function addCollapsibleToggles(html: string): string {
@ -161,6 +201,8 @@ export function renderMarkdown(text: string): string {
// tiptap-markdown escapes [ as \[ — restore [[WikiLinks]] before parsing
const unescaped = text.replace(/\\\[\\\[(.+?)\\\]\\\]/g, '[[$1]]');
const raw = markedInstance.parse(unescaped) as string;
const sanitized = DOMPurify.sanitize(raw, PURIFY_CONFIG);
const withTables = wrapTables(raw);
const withCallouts = processCallouts(withTables);
const sanitized = DOMPurify.sanitize(withCallouts, PURIFY_CONFIG);
return addCollapsibleToggles(sanitized);
}

View File

@ -35,6 +35,7 @@
"@tiptap/starter-kit": "^3.20.0",
"dexie": "^4.0.11",
"dompurify": "^3.3.1",
"highlight.js": "^11.11.1",
"marked": "^17.0.3",
"tiptap-markdown": "^0.9.0"
},
@ -6595,6 +6596,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/hono": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",

Binary file not shown.

Binary file not shown.