Compare commits
8 Commits
392af640c4
...
49443ad16a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49443ad16a | ||
|
|
e6844c403c | ||
|
|
f1932fd5f6 | ||
|
|
ba88febc77 | ||
|
|
fa7b97acdc | ||
|
|
6ef888a128 | ||
|
|
94d0733412 | ||
|
|
4ac94b2feb |
@@ -551,7 +551,6 @@
|
|||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||||
"error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.",
|
|
||||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
"error_invalid_person_type": "The specified person type is not valid.",
|
"error_invalid_person_type": "The specified person type is not valid.",
|
||||||
"error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.",
|
|
||||||
"validation_last_name_required": "Last name is required.",
|
"validation_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||||
"error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.",
|
|
||||||
"validation_last_name_required": "El apellido es obligatorio.",
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
|
|||||||
@@ -122,12 +122,31 @@ function selectItem(item: Person) {
|
|||||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||||
{m.person_mention_popup_empty()}
|
{m.person_mention_popup_empty()}
|
||||||
</p>
|
</p>
|
||||||
|
<!--
|
||||||
|
Empty-state escape hatch — without it the transcriber has to close
|
||||||
|
the dropdown, navigate to /persons/new, come back, and re-type the
|
||||||
|
query. target=_blank keeps the document and editor state intact;
|
||||||
|
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
||||||
|
-->
|
||||||
|
<a
|
||||||
|
href="/persons/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{m.person_mention_create_new()}
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
{#each model.items as person, i (person.id)}
|
{#each model.items as person, i (person.id)}
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
||||||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
|
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
||||||
|
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
||||||
|
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
||||||
|
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
||||||
]}
|
]}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === highlightedIndex}
|
aria-selected={i === highlightedIndex}
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ onMount(() => {
|
|||||||
|
|
||||||
editor = new Editor({
|
editor = new Editor({
|
||||||
element: editorEl,
|
element: editorEl,
|
||||||
|
// Initial editable state honors the `disabled` prop. The reactive
|
||||||
|
// $effect below keeps it in sync if the prop flips after mount —
|
||||||
|
// without this, a keyboard user can tab into the contenteditable
|
||||||
|
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
||||||
|
editable: !disabled,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
@@ -98,6 +103,9 @@ onMount(() => {
|
|||||||
}),
|
}),
|
||||||
CustomMention.configure({
|
CustomMention.configure({
|
||||||
renderHTML({ node }) {
|
renderHTML({ node }) {
|
||||||
|
// Underline color matches the read-mode .person-mention rule
|
||||||
|
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
|
||||||
|
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
|
||||||
return [
|
return [
|
||||||
'span',
|
'span',
|
||||||
{
|
{
|
||||||
@@ -105,7 +113,7 @@ onMount(() => {
|
|||||||
'data-person-id': node.attrs.personId,
|
'data-person-id': node.attrs.personId,
|
||||||
'data-display-name': node.attrs.displayName,
|
'data-display-name': node.attrs.displayName,
|
||||||
class:
|
class:
|
||||||
'mention-token underline decoration-brand-mint underline-offset-2 text-brand-navy font-medium'
|
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
|
||||||
},
|
},
|
||||||
`@${node.attrs.displayName}`
|
`@${node.attrs.displayName}`
|
||||||
];
|
];
|
||||||
@@ -115,6 +123,20 @@ onMount(() => {
|
|||||||
},
|
},
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: '@',
|
char: '@',
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
||||||
|
// Tiptap's suggestion plugin lives entirely on the client and
|
||||||
|
// fires on every keystroke after `@`. Routing each query through
|
||||||
|
// a SvelteKit form action would round-trip through SSR for a
|
||||||
|
// dropdown that needs to feel instantaneous, and a +server.ts
|
||||||
|
// endpoint would only proxy the same call. Auth flows through
|
||||||
|
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
|
||||||
|
// network surface is identical to a server-driven call.
|
||||||
|
// Markus #5616: an ADR will formalise this. Open follow-up:
|
||||||
|
// "ADR: client-side fetch exception for editor suggestion plugins."
|
||||||
|
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
||||||
|
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
items: async ({ query }: { query: string }) => {
|
items: async ({ query }: { query: string }) => {
|
||||||
if (!query) return [];
|
if (!query) return [];
|
||||||
try {
|
try {
|
||||||
@@ -232,11 +254,26 @@ onMount(() => {
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||||
|
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||||
|
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||||
|
//
|
||||||
|
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
|
||||||
|
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
|
||||||
|
// idempotence check, the effect would loop on every prop pass-through.
|
||||||
|
$effect(() => {
|
||||||
|
const shouldBeEditable = !disabled;
|
||||||
|
if (editor && editor.isEditable !== shouldBeEditable) {
|
||||||
|
editor.setEditable(shouldBeEditable);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||||
class:opacity-50={disabled}
|
class:opacity-50={disabled}
|
||||||
class:pointer-events-none={disabled}
|
class:pointer-events-none={disabled}
|
||||||
|
aria-disabled={disabled ? 'true' : undefined}
|
||||||
bind:this={editorEl}
|
bind:this={editorEl}
|
||||||
></div>
|
></div>
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ function mockFetchEmpty() {
|
|||||||
|
|
||||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||||
|
|
||||||
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
|
function renderHost(
|
||||||
|
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
||||||
|
) {
|
||||||
let snapshot: Snapshot = {
|
let snapshot: Snapshot = {
|
||||||
value: initial.value ?? '',
|
value: initial.value ?? '',
|
||||||
mentionedPersons: initial.mentionedPersons ?? []
|
mentionedPersons: initial.mentionedPersons ?? []
|
||||||
@@ -55,6 +57,7 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
|
|||||||
render(PersonMentionEditorHost, {
|
render(PersonMentionEditorHost, {
|
||||||
initialValue: initial.value ?? '',
|
initialValue: initial.value ?? '',
|
||||||
initialMentions: initial.mentionedPersons ?? [],
|
initialMentions: initial.mentionedPersons ?? [],
|
||||||
|
disabled: initial.disabled ?? false,
|
||||||
onChange: (snap: Snapshot) => {
|
onChange: (snap: Snapshot) => {
|
||||||
snapshot = snap;
|
snapshot = snap;
|
||||||
}
|
}
|
||||||
@@ -142,6 +145,19 @@ describe('PersonMentionEditor — typeahead', () => {
|
|||||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers a "create new person" link in the empty state', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
|
||||||
|
await expect.element(link).toBeVisible();
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/persons/new');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||||
@@ -280,6 +296,73 @@ describe('PersonMentionEditor — keyboard navigation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — disabled state', () => {
|
||||||
|
it('sets contenteditable=false on the editor when disabled', async () => {
|
||||||
|
renderHost({ value: 'Bestehender Text', disabled: true });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
||||||
|
renderHost({ disabled: true });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const wrapper = document.querySelector('[aria-disabled="true"]');
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
||||||
|
renderHost({ disabled: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — XSS resistance', () => {
|
||||||
|
it('renders a malicious displayName as text, not as HTML elements', async () => {
|
||||||
|
// A historical sidecar entry whose displayName contains an HTML payload
|
||||||
|
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
|
||||||
|
// returns the @-prefixed string as the third tuple entry, which
|
||||||
|
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
|
||||||
|
const maliciousMention: PersonMention = {
|
||||||
|
personId: '00000000-0000-0000-0000-000000000001',
|
||||||
|
displayName: '<img src=x onerror=alert(1)>'
|
||||||
|
};
|
||||||
|
|
||||||
|
renderHost({
|
||||||
|
value: '@<img src=x onerror=alert(1)>',
|
||||||
|
mentionedPersons: [maliciousMention]
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
// No element from the malicious payload should have appeared as a real
|
||||||
|
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
|
||||||
|
// paragraphs — that is internal markup and never carries user attrs;
|
||||||
|
// guard against the injection by checking the user-controlled attrs.)
|
||||||
|
expect(textbox!.querySelector('img[onerror]')).toBeNull();
|
||||||
|
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
|
||||||
|
expect(textbox!.querySelector('script')).toBeNull();
|
||||||
|
// The payload should appear as visible text content instead.
|
||||||
|
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — touch target', () => {
|
describe('PersonMentionEditor — touch target', () => {
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ type Props = {
|
|||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
initialMentions?: PersonMention[];
|
initialMentions?: PersonMention[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
|
let {
|
||||||
|
initialValue = '',
|
||||||
|
initialMentions = [],
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
onChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// initial* props seed mount-time state; reading them inside untrack signals
|
// initial* props seed mount-time state; reading them inside untrack signals
|
||||||
// the intentional one-shot capture and silences state_referenced_locally.
|
// the intentional one-shot capture and silences state_referenced_locally.
|
||||||
@@ -28,4 +35,5 @@ $effect(() => {
|
|||||||
bind:value={value}
|
bind:value={value}
|
||||||
bind:mentionedPersons={mentionedPersons}
|
bind:mentionedPersons={mentionedPersons}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { detectPersonMention } from './personMention';
|
|
||||||
|
|
||||||
describe('detectPersonMention', () => {
|
|
||||||
it('returns null when text has no @', () => {
|
|
||||||
expect(detectPersonMention('hello world', 11)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => {
|
|
||||||
expect(detectPersonMention('user@example', 12)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns query for @ at the very start of string', () => {
|
|
||||||
expect(detectPersonMention('@Aug', 4)).toBe('Aug');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty string immediately after @', () => {
|
|
||||||
expect(detectPersonMention('@', 1)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns single-word query', () => {
|
|
||||||
expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the trigger active when the query has a trailing space', () => {
|
|
||||||
expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns multi-word query (spaces allowed)', () => {
|
|
||||||
expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns single-character query', () => {
|
|
||||||
expect(detectPersonMention('@M', 2)).toBe('M');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when the query crosses a newline', () => {
|
|
||||||
expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when a second @ appears in the query (next mention starts)', () => {
|
|
||||||
expect(detectPersonMention('@Aug@bar', 8)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the most recent @ when separated by whitespace', () => {
|
|
||||||
// '@Aug @Bert' with cursor at end — the second @ is the trigger.
|
|
||||||
expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the query when the cursor sits exactly at a newline boundary', () => {
|
|
||||||
// '@Aug\nfoo' with cursor at index 4 — right at the newline before it
|
|
||||||
// is consumed. The query is still 'Aug' because nothing past the cursor
|
|
||||||
// counts.
|
|
||||||
expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when cursor is before the @', () => {
|
|
||||||
expect(detectPersonMention('@Hans', 0)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the most recent @ in the text', () => {
|
|
||||||
// cursor is just after the second @ + a few chars
|
|
||||||
expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Given the current textarea value and cursor position, returns the
|
|
||||||
* @-person-mention query being typed (the text after the last triggering @),
|
|
||||||
* or null if no person-mention is active.
|
|
||||||
*
|
|
||||||
* Rules — distinct from comment-mentions in `mention.ts`:
|
|
||||||
* - @ must be at the start of the string or preceded by whitespace
|
|
||||||
* - The query may contain spaces (historical persons commonly have multi-word
|
|
||||||
* display names — "Auguste Raddatz", "Maria von Müller-Schultz")
|
|
||||||
* - The query stops at a newline or at a second @ (the next mention starts)
|
|
||||||
*/
|
|
||||||
export function detectPersonMention(text: string, cursorPos: number): string | null {
|
|
||||||
const before = text.slice(0, cursorPos);
|
|
||||||
const atIndex = before.lastIndexOf('@');
|
|
||||||
if (atIndex === -1) return null;
|
|
||||||
|
|
||||||
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
|
|
||||||
|
|
||||||
const query = before.slice(atIndex + 1);
|
|
||||||
if (query.includes('\n') || query.includes('@')) return null;
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user