upd mention-funktion

This commit is contained in:
beo3000 2026-03-01 10:18:55 +01:00
parent d14669bdcb
commit 46b63b56e8
9 changed files with 77 additions and 45 deletions

View File

@ -1 +1 @@
1.1.106 1.1.108

View File

@ -28,7 +28,7 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) {
for (let i = pos - 1; i >= 0; i--) { for (let i = pos - 1; i >= 0; i--) {
const ch = text[i]; const ch = text[i];
if (ch === '@') { if (ch === '@') {
if (i === 0 || /\s/.test(text[i - 1])) { if (i === 0 || /[\s\n]/.test(text[i - 1])) {
mentionStart = i; mentionStart = i;
mentionMode = '@'; mentionMode = '@';
return { query: text.slice(i + 1, pos), mode: '@' }; return { query: text.slice(i + 1, pos), mode: '@' };
@ -45,10 +45,9 @@ export function mention(node: HTMLInputElement | HTMLTextAreaElement) {
} }
return null; return null;
} }
// Allow spaces only when we haven't yet passed a non-word char other than space // For ->, a single space between trigger and query is valid — skip it.
// (needed for "-> ANDST" where the space is between trigger and query). if (ch === ' ' && i > 1 && text[i - 1] === '>' && text[i - 2] === '-') continue;
// For @-mentions, spaces are not allowed in the query (quoted names use autocomplete). if (/[\s\n]/.test(ch)) return null;
if (/\s/.test(ch)) return null;
} }
return null; return null;
} }

View File

@ -39,6 +39,7 @@
// Metadata collapsed by default // Metadata collapsed by default
let metaOpen = $state(false); let metaOpen = $state(false);
let abbrError = $state('');
// --- Notes --- // --- Notes ---
let noteTitle = $state(''); let noteTitle = $state('');
@ -424,13 +425,25 @@
<label class="mb-1 text-sm text-[#aaa]">Kürzel <span class="text-[#666] font-normal">(für -&gt; Zuweisungen)</span>:</label> <label class="mb-1 text-sm text-[#aaa]">Kürzel <span class="text-[#666] font-normal">(für -&gt; Zuweisungen)</span>:</label>
<input class="rounded border border-[#555] bg-[#111] px-2.5 py-1.5 text-[#ddd] font-mono" <input class="rounded border border-[#555] bg-[#111] px-2.5 py-1.5 text-[#ddd] font-mono"
value={meta.abbreviation ?? ''} value={meta.abbreviation ?? ''}
placeholder="z.B. CHFI" placeholder="z.B. CHfi"
oninput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\s/g, '').toUpperCase(); }} oninput={(e) => { e.currentTarget.value = e.currentTarget.value.replace(/\s/g, ''); }}
onchange={(e) => { onchange={async (e) => {
const v = e.currentTarget.value.replace(/\s/g, '').toUpperCase(); const v = e.currentTarget.value.replace(/\s/g, '');
updateMeta('abbreviation', v || undefined); if (!v) { updateMeta('abbreviation', undefined as any); return; }
const conflict = await db.contexts
.filter(c => !c.deletedAt && c.type === 'person' && c.id !== context.id
&& (c.meta as PersonMeta | null)?.abbreviation?.toLowerCase() === v.toLowerCase())
.first();
if (conflict) {
abbrError = `Kürzel bereits vergeben (${conflict.name.replace(/^Person\s+/, '')})`;
e.currentTarget.value = meta.abbreviation ?? '';
} else {
abbrError = '';
updateMeta('abbreviation', v);
}
}} }}
/> />
{#if abbrError}<span class="text-xs text-red-400 mt-0.5">{abbrError}</span>{/if}
</div> </div>
<div class="mb-2.5 flex flex-col"> <div class="mb-2.5 flex flex-col">
<label class="mb-1 text-sm text-[#aaa]">Notizen:</label> <label class="mb-1 text-sm text-[#aaa]">Notizen:</label>
@ -507,30 +520,26 @@
{@const lines = entry.text.split('\n')} {@const lines = entry.text.split('\n')}
{@const title = lines[0]} {@const title = lines[0]}
{@const body = lines.slice(1).join('\n').trim()} {@const body = lines.slice(1).join('\n').trim()}
{@const isDone = !!entry.doneAt} <div class="group mb-3 rounded bg-card-bg p-2.5">
<div class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5"> <div class="mb-1 flex items-center justify-between">
<button <span class="text-xs text-muted">{entry.date} {formatTime(entry.updatedAt)}</span>
class="mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded border text-xs {isDone ? 'border-green-500 bg-green-500/20 text-green-400' : 'border-[#555] text-transparent hover:border-[#888]'}" <div class="flex gap-2 opacity-0 transition-opacity group-hover:opacity-100">
onclick={() => toggleHistoryEntryDone(entry.id)} <button
title={isDone ? 'Als offen markieren' : 'Als erledigt markieren'} class="text-xs text-[#666] hover:text-[#ccc]"
>{isDone ? '✓' : ''}</button> onclick={() => startEdit(entry)}
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{entry.date} {formatTime(entry.updatedAt)}</span> title="Bearbeiten"
<div class="flex-1 {isDone ? 'line-through opacity-50' : ''}"> >&#9998;</button>
<div class="font-bold"><RenderedMarkdown text={title} /></div> <button
{#if body} class="text-xs text-[#666] hover:text-red-400"
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" /> onclick={() => handleDeleteNote(entry.id)}
{/if} title="Löschen"
>&times;</button>
</div>
</div> </div>
<button <div class="font-bold"><RenderedMarkdown text={title} /></div>
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-[#ccc]" {#if body}
onclick={() => startEdit(entry)} <RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
title="Bearbeiten" {/if}
>&#9998;</button>
<button
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
onclick={() => handleDeleteNote(entry.id)}
title="Löschen"
>&times;</button>
</div> </div>
{/if} {/if}
{/each} {/each}

View File

@ -38,7 +38,8 @@
function markUnknown(chip: HTMLElement, prefix: string) { function markUnknown(chip: HTMLElement, prefix: string) {
// Replace the chip (and its preceding arrow span) with plain text // Replace the chip (and its preceding arrow span) with plain text
const arrow = chip.previousElementSibling; const arrow = chip.previousElementSibling;
const text = document.createTextNode(`${prefix}${chip.dataset.assignment ?? chip.dataset.person ?? ''}`); const name = chip.dataset.assignment ?? chip.dataset.person ?? '';
const text = document.createTextNode(`${prefix}${name}`);
chip.parentNode?.insertBefore(text, chip); chip.parentNode?.insertBefore(text, chip);
chip.remove(); chip.remove();
if (arrow && /&rarr;|→/.test(arrow.textContent ?? '')) arrow.remove(); if (arrow && /&rarr;|→/.test(arrow.textContent ?? '')) arrow.remove();
@ -49,7 +50,11 @@
await Promise.all([ await Promise.all([
...Array.from(assignmentChips).map(async chip => { ...Array.from(assignmentChips).map(async chip => {
const ctx = await findContextByAbbreviation(chip.dataset.assignment!); const ctx = await findContextByAbbreviation(chip.dataset.assignment!);
if (!ctx) { markUnknown(chip, '-> '); return; } if (!ctx) {
const showArrow = chip.dataset.showArrow === '1';
markUnknown(chip, showArrow ? '=> ' : '-> ');
return;
}
const subType = (ctx.meta as Record<string, unknown> | null)?.personSubType as PersonSubType | undefined; const subType = (ctx.meta as Record<string, unknown> | null)?.personSubType as PersonSubType | undefined;
applySubtypeColor(chip, subType); applySubtypeColor(chip, subType);
}), }),

View File

@ -1,5 +1,5 @@
export function extractAssignments(text: string): string[] { export function extractAssignments(text: string): string[] {
const regex = /->\s*([\w]+)/g; const regex = /(?:->|=>)\s*([\w]+)/g;
const result: string[] = []; const result: string[] = [];
let match; let match;
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {

View File

@ -43,9 +43,9 @@ export function renameMentions(
return text; return text;
} }
/** Replace -> OldAbbr with -> NewAbbr in text (exact word match). */ /** Replace -> OldAbbr / => OldAbbr with -> NewAbbr / => NewAbbr in text (case-insensitive word match). */
export function renameAssignment(text: string, oldAbbr: string, newAbbr: string): string { export function renameAssignment(text: string, oldAbbr: string, newAbbr: string): string {
const esc = escapeRegex(oldAbbr); const esc = escapeRegex(oldAbbr);
const pattern = new RegExp(`(->\\s*)${esc}(?=[^\\w]|$)`, 'g'); const pattern = new RegExp(`((?:->|=>)\\s*)${esc}(?=[^\\w]|$)`, 'gi');
return text.replace(pattern, `$1${newAbbr}`); return text.replace(pattern, `$1${newAbbr}`);
} }

View File

@ -4,6 +4,23 @@ import hljs from 'highlight.js';
// --- Custom inline extensions for Ka-Note tags --- // --- Custom inline extensions for Ka-Note tags ---
const assignmentWithArrowExt: TokenizerExtension & RendererExtension = {
name: 'assignmentWithArrow',
level: 'inline',
start(src: string) { return src.indexOf('=>'); },
tokenizer(src: string) {
// \u200B may be prepended during preprocessing.
const match = /^\u200B?=>\s*"([^"]+)"/.exec(src) ?? /^\u200B?=>\s*([\w-]+)/.exec(src);
if (match) {
return { type: 'assignmentWithArrow', raw: match[0], name: match[1] };
}
},
renderer(token) {
return '<span class="text-warning font-bold">&rarr;</span>'
+ `<span class="inline-block ml-1 rounded bg-tag-bg px-1.5 py-0.5 text-sm font-bold text-white border border-[#6272a4] cursor-pointer hover:border-white" data-assignment="${token.name}" data-show-arrow="1">${token.name}</span>`;
}
};
const assignmentExt: TokenizerExtension & RendererExtension = { const assignmentExt: TokenizerExtension & RendererExtension = {
name: 'assignment', name: 'assignment',
level: 'inline', level: 'inline',
@ -17,8 +34,7 @@ const assignmentExt: TokenizerExtension & RendererExtension = {
} }
}, },
renderer(token) { renderer(token) {
return '<span class="text-warning font-bold">&rarr;</span>' return `<span class="inline-block rounded bg-tag-bg px-1.5 py-0.5 text-sm font-bold text-white border border-[#6272a4] cursor-pointer hover:border-white" data-assignment="${token.name}">${token.name}</span>`;
+ `<span class="inline-block ml-1 rounded bg-tag-bg px-1.5 py-0.5 text-sm font-bold text-white border border-[#6272a4] cursor-pointer hover:border-white" data-assignment="${token.name}">${token.name}</span>`;
} }
}; };
@ -108,7 +124,7 @@ function truncateLinkUrl(url: string): string {
const markedInstance = new Marked({ const markedInstance = new Marked({
gfm: true, gfm: true,
breaks: true, breaks: true,
extensions: [wikiLinkExt, assignmentExt, projectRefExt, companyRefExt, personMentionExt], extensions: [wikiLinkExt, assignmentWithArrowExt, assignmentExt, projectRefExt, companyRefExt, personMentionExt],
renderer: { renderer: {
link({ href, title, text }) { link({ href, title, text }) {
if (!href) return text; if (!href) return text;
@ -189,6 +205,7 @@ const PURIFY_CONFIG = {
ALLOWED_ATTR: [ ALLOWED_ATTR: [
'href', 'target', 'rel', 'src', 'alt', 'title', 'width', 'height', 'href', 'target', 'rel', 'src', 'alt', 'title', 'width', 'height',
'class', 'style', 'data-person', 'data-project', 'data-company', 'data-wiki-page', 'class', 'style', 'data-person', 'data-project', 'data-company', 'data-wiki-page',
'data-assignment', 'data-show-arrow',
'type', 'checked', 'disabled' // for GFM task lists 'type', 'checked', 'disabled' // for GFM task lists
], ],
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
@ -202,9 +219,11 @@ export function renderMarkdown(text: string): string {
if (!text) return ''; if (!text) return '';
// tiptap-markdown escapes [ as \[ — restore [[WikiLinks]] before parsing // tiptap-markdown escapes [ as \[ — restore [[WikiLinks]] before parsing
const unescaped = text.replace(/\\\[\\\[(.+?)\\\]\\\]/g, '[[$1]]').replace(/\\?-(?:&gt;|>)/g, '->'); const unescaped = text.replace(/\\\[\\\[(.+?)\\\]\\\]/g, '[[$1]]').replace(/\\?-(?:&gt;|>)/g, '->');
// Prevent Marked from treating -> at line start as a list item + blockquote. // Prevent Marked from treating -> / => at line start as a list item + blockquote.
// Replace leading -> with a zero-width space prefix so it stays inline. // Replace leading -> / => with a zero-width space prefix so they stay inline.
const preprocessed = unescaped.replace(/(^|\n)([ \t]*)->/g, '$1$2\u200B->'); const preprocessed = unescaped
.replace(/(^|\n)([ \t]*)->/g, '$1$2\u200B->')
.replace(/(^|\n)([ \t]*)=>/g, '$1$2\u200B=>');
const raw = markedInstance.parse(preprocessed) as string; const raw = markedInstance.parse(preprocessed) as string;
const withTables = wrapTables(raw); const withTables = wrapTables(raw);
const withCallouts = processCallouts(withTables); const withCallouts = processCallouts(withTables);

Binary file not shown.

Binary file not shown.