#150: @-Mentions — Inline-Node, instanzweite User-Suche, Autocomplete
Neuer Inline-Atom mention {userId, username}: Markdown-Regel @username
(E-Mail-sicher über Wortgrenzen), Serializer, HTML-Span dt-mention,
Plain-Text für die Suche, Extraktor extractMentionUserIds. Neue
Endpoints GET /users/search (auth, min. 2 Zeichen, Limit 10,
Rate-Limit) und GET /users/brief (Batch-Auflösung für live
Anzeigenamen; gelöschte Nutzer → toter Chip). Editor: MentionView mit
Live-displayName, MentionAutocomplete (Klon des Wikilink-Musters),
Chip-CSS. 5 Unit-Tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
7252bd16e0
commit
7471fc70f7
57
apps/api/src/users/user-search.controller.ts
Normal file
57
apps/api/src/users/user-search.controller.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import type { UserBriefView } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
||||||
|
|
||||||
|
/** Cap for the batch `brief` lookup — a page mentions a handful of people. */
|
||||||
|
const BRIEF_MAX_IDS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance-wide user lookup for `@` mentions (issue #150). Deliberately
|
||||||
|
* minimal: only id/username/displayName, only active accounts, only for
|
||||||
|
* signed-in users, rate-limited, and never enumerable without a query — a
|
||||||
|
* documented consequence of instance-wide mentions is that logged-in users
|
||||||
|
* can discover usernames this way.
|
||||||
|
*/
|
||||||
|
@Controller('users')
|
||||||
|
@AuthenticatedOnly()
|
||||||
|
export class UserSearchController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@RateLimit({ scope: 'user-search', limit: 60, windowSeconds: 60 })
|
||||||
|
async search(@Query('q') q: string | undefined): Promise<UserBriefView[]> {
|
||||||
|
const query = (q ?? '').trim();
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
OR: [
|
||||||
|
{ username: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ displayName: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch resolution of mentioned users for live display names; unknown or
|
||||||
|
* disabled ids are simply absent (the mention renders as a dead chip). */
|
||||||
|
@Get('brief')
|
||||||
|
async brief(@Query('ids') ids: string | undefined): Promise<UserBriefView[]> {
|
||||||
|
const wanted = (ids ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, BRIEF_MAX_IDS);
|
||||||
|
if (wanted.length === 0) return [];
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: { id: { in: wanted }, status: 'ACTIVE' },
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SessionsModule } from '../auth/sessions.module';
|
import { SessionsModule } from '../auth/sessions.module';
|
||||||
|
import { UserSearchController } from './user-search.controller';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SessionsModule],
|
imports: [SessionsModule],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController, UserSearchController],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
143
apps/web/src/editor/MentionAutocomplete.tsx
Normal file
143
apps/web/src/editor/MentionAutocomplete.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import type { UserBriefView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { Editor } from '@tiptap/react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/** An open `@` context: the query typed so far and where the `@` began. */
|
||||||
|
interface QueryState {
|
||||||
|
query: string;
|
||||||
|
from: number;
|
||||||
|
coords: { left: number; bottom: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detects `@query` immediately before a collapsed cursor (issue #150) —
|
||||||
|
* only at a word boundary, so typing an e-mail address never opens it. */
|
||||||
|
function detectQuery(editor: Editor): { query: string; from: number } | null {
|
||||||
|
const { selection } = editor.state;
|
||||||
|
if (!selection.empty) return null;
|
||||||
|
const $from = selection.$from;
|
||||||
|
if (!$from.parent.isTextblock) return null;
|
||||||
|
const start = Math.max(0, $from.parentOffset - 40);
|
||||||
|
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
|
||||||
|
const match = /(^|[^\w@.-])@([a-zA-Z0-9][\w-]*)$/.exec(before);
|
||||||
|
if (!match) return null;
|
||||||
|
const query = match[2] ?? '';
|
||||||
|
return { query, from: selection.from - query.length - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocomplete popup for `@` mentions (issue #150): instance-wide user
|
||||||
|
* search (min. two characters), Enter/click inserts the mention node with
|
||||||
|
* the stable user id. Keyboard handling mirrors the wikilink popup.
|
||||||
|
*/
|
||||||
|
export function MentionAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation('editor');
|
||||||
|
const [state, setState] = useState<QueryState | null>(null);
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
|
const search = useQuery({
|
||||||
|
queryKey: ['user-search', state?.query ?? ''],
|
||||||
|
queryFn: () => apiGet<UserBriefView[]>(`/users/search?q=${encodeURIComponent(state!.query)}`),
|
||||||
|
enabled: Boolean(state && state.query.length >= 2),
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
const suggestions = state && state.query.length >= 2 ? (search.data ?? []) : [];
|
||||||
|
|
||||||
|
const live = useRef({ state, suggestions, selected });
|
||||||
|
live.current = { state, suggestions, selected };
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
setState(null);
|
||||||
|
setSelected(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(item: UserBriefView | undefined): void {
|
||||||
|
const current = live.current.state;
|
||||||
|
if (!item || !current) return;
|
||||||
|
const range = { from: current.from, to: editor.state.selection.from };
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{ type: 'mention', attrs: { userId: item.id, username: item.username } },
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute the open query on every doc/selection change.
|
||||||
|
useEffect(() => {
|
||||||
|
const update = (): void => {
|
||||||
|
const found = detectQuery(editor);
|
||||||
|
if (!found) {
|
||||||
|
setState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||||
|
setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } });
|
||||||
|
setSelected(0);
|
||||||
|
};
|
||||||
|
editor.on('transaction', update);
|
||||||
|
return () => {
|
||||||
|
editor.off('transaction', update);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
// Keyboard navigation, intercepted before ProseMirror (capture phase).
|
||||||
|
useEffect(() => {
|
||||||
|
const dom = editor.view.dom;
|
||||||
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
const { state: s, suggestions: items, selected: sel } = live.current;
|
||||||
|
if (!s || items.length === 0) return;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((i) => (i + 1) % items.length);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((i) => (i - 1 + items.length) % items.length);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(items[sel]);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dom.addEventListener('keydown', onKeyDown, true);
|
||||||
|
return () => dom.removeEventListener('keydown', onKeyDown, true);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!state || suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className="wikilink-suggest"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t('mention.suggestLabel')}
|
||||||
|
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
||||||
|
>
|
||||||
|
{suggestions.map((user, index) => (
|
||||||
|
<li key={user.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selected}
|
||||||
|
className={
|
||||||
|
index === selected ? 'wikilink-suggest__item is-active' : 'wikilink-suggest__item'
|
||||||
|
}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dt-mention">@{user.username}</span> {user.displayName}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
|||||||
import { PluginBlock } from './nodes/plugin-block';
|
import { PluginBlock } from './nodes/plugin-block';
|
||||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
|
import { Mention } from './nodes/mention';
|
||||||
import { Transclusion } from './nodes/transclusion';
|
import { Transclusion } from './nodes/transclusion';
|
||||||
import { Wikilink } from './nodes/wikilink';
|
import { Wikilink } from './nodes/wikilink';
|
||||||
import {
|
import {
|
||||||
@ -44,6 +45,7 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
Image,
|
Image,
|
||||||
PluginBlock,
|
PluginBlock,
|
||||||
Wikilink,
|
Wikilink,
|
||||||
|
Mention,
|
||||||
Transclusion,
|
Transclusion,
|
||||||
Table,
|
Table,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
|||||||
61
apps/web/src/editor/nodes/mention.tsx
Normal file
61
apps/web/src/editor/nodes/mention.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { UserBriefView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from '@tiptap/core';
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import type { NodeViewProps } from '@tiptap/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../../lib/api';
|
||||||
|
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an `@username` mention (issue #150). The stable reference is the
|
||||||
|
* user id; the shown text is the user's *current* display name (a rename
|
||||||
|
* shows everywhere immediately), falling back to `@username`. A mention whose
|
||||||
|
* user no longer resolves — deleted account, or an unresolved Markdown
|
||||||
|
* import — renders as a muted "dead" chip and never notifies anyone.
|
||||||
|
*/
|
||||||
|
function MentionView({ node }: NodeViewProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('editor');
|
||||||
|
const userId = node.attrs.userId as string;
|
||||||
|
const username = node.attrs.username as string;
|
||||||
|
|
||||||
|
const brief = useQuery({
|
||||||
|
queryKey: ['user-brief', userId],
|
||||||
|
queryFn: () => apiGet<UserBriefView[]>(`/users/brief?ids=${encodeURIComponent(userId)}`),
|
||||||
|
enabled: Boolean(userId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = brief.data?.find((user) => user.id === userId);
|
||||||
|
const dead = Boolean(userId) && brief.isSuccess && !resolved;
|
||||||
|
const label = resolved ? `@${resolved.displayName}` : `@${username}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className="dt-mention-nodeview">
|
||||||
|
<span
|
||||||
|
className={dead || !userId ? 'dt-mention dt-mention--dead' : 'dt-mention'}
|
||||||
|
title={dead || !userId ? t('mention.unresolved') : `@${username}`}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionSpec = nodeSpec('mention');
|
||||||
|
export const Mention = Node.create({
|
||||||
|
name: 'mention',
|
||||||
|
group: mentionSpec.group,
|
||||||
|
inline: mentionSpec.inline,
|
||||||
|
atom: mentionSpec.atom,
|
||||||
|
addAttributes() {
|
||||||
|
return attributesFromSpec(mentionSpec);
|
||||||
|
},
|
||||||
|
parseHTML: () => mentionSpec.parseDOM,
|
||||||
|
renderHTML: ({ node }) => mentionSpec.toDOM!(node),
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(MentionView);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload';
|
|||||||
import { PresenceStrip } from '../editor/PresenceStrip';
|
import { PresenceStrip } from '../editor/PresenceStrip';
|
||||||
import { Toolbar } from '../editor/Toolbar';
|
import { Toolbar } from '../editor/Toolbar';
|
||||||
import { useCollabProvider } from '../editor/use-collab-provider';
|
import { useCollabProvider } from '../editor/use-collab-provider';
|
||||||
|
import { MentionAutocomplete } from '../editor/MentionAutocomplete';
|
||||||
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
||||||
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
||||||
import { usePageActionsSlot } from '../layout/page-actions';
|
import { usePageActionsSlot } from '../layout/page-actions';
|
||||||
@ -337,6 +338,7 @@ function PageEditor({
|
|||||||
)}
|
)}
|
||||||
<EditorContent editor={editor} className="editor-content" />
|
<EditorContent editor={editor} className="editor-content" />
|
||||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||||
|
{canEdit && <MentionAutocomplete editor={editor} />}
|
||||||
</div>
|
</div>
|
||||||
</PluginBlockContext.Provider>
|
</PluginBlockContext.Provider>
|
||||||
</WikilinkContext.Provider>
|
</WikilinkContext.Provider>
|
||||||
|
|||||||
@ -3808,3 +3808,19 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
border-bottom-color: var(--color-accent);
|
border-bottom-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `@username` mention chip (issue #150). */
|
||||||
|
.dt-mention {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.35em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-mention--dead {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|||||||
@ -181,5 +181,9 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
||||||
"create": "Seite „{{slug}}“ anlegen"
|
"create": "Seite „{{slug}}“ anlegen"
|
||||||
|
},
|
||||||
|
"mention": {
|
||||||
|
"unresolved": "Unbekannter Nutzer",
|
||||||
|
"suggestLabel": "Nutzer-Vorschläge"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,5 +181,9 @@
|
|||||||
"notFound": {
|
"notFound": {
|
||||||
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
||||||
"create": "Create the page “{{slug}}”"
|
"create": "Create the page “{{slug}}”"
|
||||||
|
},
|
||||||
|
"mention": {
|
||||||
|
"unresolved": "Unknown user",
|
||||||
|
"suggestLabel": "User suggestions"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,13 @@ function renderInline(node: Node): string {
|
|||||||
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
||||||
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
||||||
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
||||||
|
} else if (child.type.name === 'mention') {
|
||||||
|
// A user mention (issue #150): static HTML shows the @username; the
|
||||||
|
// stable user id rides along for consumers that can resolve it.
|
||||||
|
const username = escapeHtml(child.attrs.username as string);
|
||||||
|
const userId = child.attrs.userId ? escapeHtml(child.attrs.userId as string) : '';
|
||||||
|
const idAttr = userId ? ` data-mention-user-id="${userId}"` : '';
|
||||||
|
out += `<span class="dt-mention" data-mention="${username}"${idAttr}>@${username}</span>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@ -5,3 +5,4 @@ export * from './html';
|
|||||||
export * from './plain-text';
|
export * from './plain-text';
|
||||||
export * from './outline';
|
export * from './outline';
|
||||||
export * from './wikilinks';
|
export * from './wikilinks';
|
||||||
|
export * from './mentions';
|
||||||
|
|||||||
@ -192,6 +192,32 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
|
||||||
|
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
||||||
|
|
||||||
|
/** markdown-it inline rule for `@username` mentions (issue #150). Only fires
|
||||||
|
* at a word boundary — `stefan@example.org` stays plain text. The parser is
|
||||||
|
* database-less, so the user id stays empty (purely visual) until the editor
|
||||||
|
* or a resolver fills it in. */
|
||||||
|
function mentionRule(state: StateInline, silent: boolean): boolean {
|
||||||
|
const start = state.pos;
|
||||||
|
if (state.src.charCodeAt(start) !== 0x40 /* @ */) return false;
|
||||||
|
const before = start > 0 ? state.src[start - 1]! : '';
|
||||||
|
if (/[\w@.-]/.test(before)) return false;
|
||||||
|
const match = MENTION_NAME.exec(state.src.slice(start + 1));
|
||||||
|
if (!match || match[0].length < 3) return false;
|
||||||
|
const username = match[0];
|
||||||
|
// Trailing user-ish characters would make this an address, not a mention.
|
||||||
|
const after = state.src[start + 1 + username.length] ?? '';
|
||||||
|
if (after === '@') return false;
|
||||||
|
if (!silent) {
|
||||||
|
const token = state.push('mention', '', 0);
|
||||||
|
token.attrs = [['username', username]];
|
||||||
|
}
|
||||||
|
state.pos = start + 1 + username.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
|
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
|
||||||
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
|
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
|
||||||
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
|
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
|
||||||
@ -280,6 +306,8 @@ function createTokenizer(): MarkdownIt {
|
|||||||
const md = new MarkdownIt('default', { html: false });
|
const md = new MarkdownIt('default', { html: false });
|
||||||
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
||||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||||
|
// `@username` mentions (issue #150).
|
||||||
|
md.inline.ruler.before('link', 'mention', mentionRule);
|
||||||
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
||||||
// as plain text (issue #135).
|
// as plain text (issue #135).
|
||||||
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
||||||
@ -347,6 +375,14 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
displayText: tok.attrGet('display') || null,
|
displayText: tok.attrGet('display') || null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
mention: {
|
||||||
|
node: 'mention',
|
||||||
|
getAttrs: (tok) => ({
|
||||||
|
username: tok.attrGet('username') ?? '',
|
||||||
|
// A Markdown import cannot resolve users — the mention stays visual.
|
||||||
|
userId: tok.attrGet('userId') ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
transclusion: {
|
transclusion: {
|
||||||
node: 'transclusion',
|
node: 'transclusion',
|
||||||
getAttrs: (tok) => ({
|
getAttrs: (tok) => ({
|
||||||
@ -478,6 +514,9 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
const display = node.attrs.displayText as string | null;
|
const display = node.attrs.displayText as string | null;
|
||||||
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
||||||
},
|
},
|
||||||
|
mention(state, node) {
|
||||||
|
state.write(`@${node.attrs.username as string}`);
|
||||||
|
},
|
||||||
transclusion(state, node) {
|
transclusion(state, node) {
|
||||||
const slug = node.attrs.targetSlug as string;
|
const slug = node.attrs.targetSlug as string;
|
||||||
const display = node.attrs.displayText as string | null;
|
const display = node.attrs.displayText as string | null;
|
||||||
|
|||||||
62
packages/shared/src/editor-schema/mention.test.ts
Normal file
62
packages/shared/src/editor-schema/mention.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { docToHtml } from './html';
|
||||||
|
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||||
|
import { extractMentionUserIds } from './mentions';
|
||||||
|
import { docToPlainText } from './plain-text';
|
||||||
|
import { editorSchema } from './schema';
|
||||||
|
|
||||||
|
function firstMention(doc: Node): Node | null {
|
||||||
|
let found: Node | null = null;
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (!found && node.type.name === 'mention') found = node;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@username mention (issue #150)', () => {
|
||||||
|
it('parses @username to a mention node and round-trips', () => {
|
||||||
|
const doc = markdownToDoc('Hallo @nadia, schau mal.');
|
||||||
|
const node = firstMention(doc);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node!.attrs.username).toBe('nadia');
|
||||||
|
expect(node!.attrs.userId).toBe('');
|
||||||
|
expect(docToMarkdown(doc)).toContain('@nadia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves e-mail addresses untouched', () => {
|
||||||
|
const doc = markdownToDoc('Schreib an stefan@example.org bitte.');
|
||||||
|
expect(firstMention(doc)).toBeNull();
|
||||||
|
expect(docToMarkdown(doc)).toContain('stefan@example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HTML with the username and optional user id', () => {
|
||||||
|
const doc = markdownToDoc('Ping @zoraya-shahin!');
|
||||||
|
expect(docToHtml(doc)).toContain('class="dt-mention" data-mention="zoraya-shahin"');
|
||||||
|
expect(docToHtml(doc)).toContain('@zoraya-shahin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appears in the plain text for search', () => {
|
||||||
|
expect(docToPlainText(markdownToDoc('Frag @nadia.'))).toContain('@nadia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts only resolved user ids', () => {
|
||||||
|
const doc = Node.fromJSON(editorSchema, {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } },
|
||||||
|
{ type: 'text', text: ' und ' },
|
||||||
|
{ type: 'mention', attrs: { userId: '', username: 'import-only' } },
|
||||||
|
{ type: 'text', text: ' und nochmal ' },
|
||||||
|
{ type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(extractMentionUserIds(doc)).toEqual(['u-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
packages/shared/src/editor-schema/mentions.ts
Normal file
18
packages/shared/src/editor-schema/mentions.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct user ids mentioned in a document (issue #150) — the input for
|
||||||
|
* the derived `page_mentions` rows and the mention notifications (#151).
|
||||||
|
* Mentions without a resolved user id (e.g. from a Markdown import) are
|
||||||
|
* purely visual and excluded.
|
||||||
|
*/
|
||||||
|
export function extractMentionUserIds(doc: Node): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'mention') {
|
||||||
|
const userId = node.attrs.userId as string;
|
||||||
|
if (userId) ids.add(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ export function docToPlainText(doc: Node): string {
|
|||||||
if (leaf.type.name === 'wikilink') {
|
if (leaf.type.name === 'wikilink') {
|
||||||
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
|
return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string);
|
||||||
}
|
}
|
||||||
|
// A mention reads as `@username` (issue #150), so search finds it.
|
||||||
|
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
return text.replace(/\n{3,}/g, '\n\n').trim();
|
return text.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
|||||||
@ -256,6 +256,37 @@ export const editorSchema = new Schema({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// `@username` user mention (issue #150). An inline atom carrying the
|
||||||
|
// mentioned user's stable id plus the username as serialization/display
|
||||||
|
// fallback. Live display-name resolution happens in the editor where a
|
||||||
|
// user lookup is available; a Markdown import cannot resolve users, so it
|
||||||
|
// leaves `userId` empty — such a mention is purely visual.
|
||||||
|
mention: {
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
attrs: {
|
||||||
|
userId: { default: '' },
|
||||||
|
username: { validate: 'string' },
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: 'span[data-mention]',
|
||||||
|
getAttrs: (dom) => ({
|
||||||
|
username: dom.getAttribute('data-mention'),
|
||||||
|
userId: dom.getAttribute('data-mention-user-id') || '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => {
|
||||||
|
const username = node.attrs.username as string;
|
||||||
|
const userId = node.attrs.userId as string;
|
||||||
|
const attrs: Record<string, string> = { 'data-mention': username, class: 'dt-mention' };
|
||||||
|
if (userId) attrs['data-mention-user-id'] = userId;
|
||||||
|
return ['span', attrs, `@${username}`];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
|
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
|
||||||
// references another page by slug; the read view / public renderer expands
|
// references another page by slug; the read view / public renderer expands
|
||||||
// it to the target page's rendered HTML (permission-checked, recursion
|
// it to the target page's rendered HTML (permission-checked, recursion
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export * from './labels';
|
|||||||
export * from './legal';
|
export * from './legal';
|
||||||
export * from './links';
|
export * from './links';
|
||||||
export * from './members';
|
export * from './members';
|
||||||
|
export * from './users';
|
||||||
export * from './notifications';
|
export * from './notifications';
|
||||||
export * from './pages';
|
export * from './pages';
|
||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
|
|||||||
10
packages/shared/src/users.ts
Normal file
10
packages/shared/src/users.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Minimal public identity of a user (issue #150): what the instance-wide
|
||||||
|
* mention search and the mention rendering expose — never more than id,
|
||||||
|
* username, and display name.
|
||||||
|
*/
|
||||||
|
export interface UserBriefView {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user