// @-tagging (ADR-063) -- ren logikk, ingen React her, så den kan // enhetstestes uavhengig av komponentene som bruker den (se // mentions.test.ts). UI-delen (dropdown, tekstfelt, rendering av taggede // utsnitt) ligger i components/mention-input.tsx. export type Tag = { user_id: string display_name: string start_index: number end_index: number } export type TaggablePerson = { user_id: string display_name: string } /** Henter kandidater fra det nye søkeendepunktet (kun faktiske relasjoner * -- rundedeltakere + avsenderens venner, ALDRI fritekstsøk i hele * brukerbasen, se ADR-063). */ export async function fetchTaggablePeople(roundId: string, query: string): Promise { const res = await fetch(`/rounds/${roundId}/taggable-people?q=${encodeURIComponent(query)}`, { credentials: "include", }) if (!res.ok) return [] return (await res.json()) as TaggablePerson[] } /** Er markøren midt i et "@søketekst"-forsøk? Søker bakover fra markøren * gjennom sammenhengende ikke-mellomrom-tegn etter en "@" som enten står * helt i starten av teksten eller rett etter et mellomrom (så en e-post- * adresse skrevet inn et sted i teksten ikke feilaktig trigger et * forslag). Returnerer `null` når det ikke finnes noe aktivt @-forsøk. */ export function detectActiveMention( body: string, cursor: number, ): { start: number; query: string } | null { let i = cursor - 1 while (i >= 0 && !/\s/.test(body[i])) { if (body[i] === "@") { const precedingChar = i > 0 ? body[i - 1] : undefined if (precedingChar === undefined || /\s/.test(precedingChar)) { return { start: i, query: body.slice(i + 1, cursor) } } return null } i-- } return null } /** Setter inn `@Fornavn Etternavn` der `@søketekst` sto (fra `start` til * `cursor`), og returnerer den nye teksten + den ferdige taggen + hvor * markøren skal stå etterpå (rett etter navnet). */ export function insertMention( body: string, start: number, cursor: number, person: TaggablePerson, ): { body: string; tag: Tag; cursor: number } { const inserted = `@${person.display_name}` const newBody = body.slice(0, start) + inserted + body.slice(cursor) return { body: newBody, tag: { user_id: person.user_id, display_name: person.display_name, start_index: start, end_index: start + inserted.length }, cursor: start + inserted.length, } } /** Justerer eksisterende tags etter en tekstredigering -- differ * oldBody/newBody via felles prefiks/suffiks (samme prinsipp som en enkel * tekst-diff) i stedet for å anta HVOR endringen skjedde. Tags som ligger * HELT før eller HELT etter det redigerte området beholdes (sistnevnte * med forskjøvet offset); en tag hvis eget tegn-utsnitt ble RØRT av * redigeringen droppes stille -- samme "ikke prøv å reparere en tag som * ikke lenger stemmer"-filosofi som backend-valideringen allerede har * (se ADR-063 Beslutning C). */ export function adjustTagsForEdit(oldBody: string, newBody: string, tags: Tag[]): Tag[] { const maxCommon = Math.min(oldBody.length, newBody.length) let prefix = 0 while (prefix < maxCommon && oldBody[prefix] === newBody[prefix]) prefix++ let suffix = 0 const maxSuffix = maxCommon - prefix while ( suffix < maxSuffix && oldBody[oldBody.length - 1 - suffix] === newBody[newBody.length - 1 - suffix] ) { suffix++ } const oldEditEnd = oldBody.length - suffix const delta = newBody.length - oldBody.length const result: Tag[] = [] for (const t of tags) { if (t.end_index <= prefix) { result.push(t) } else if (t.start_index >= oldEditEnd) { result.push({ ...t, start_index: t.start_index + delta, end_index: t.end_index + delta }) } // else: redigeringen overlapper taggens eget utsnitt -- droppes. } return result } /** Deler en tekst i vanlige og taggede segmenter, sortert og ikke- * overlappende (overlappende tags -- skal aldri skje siden de kommer fra * enten insertMention eller server-validerte data, men filtreres bort * defensivt om det likevel skjer, fremfor å krasje rendringen). Brukt av * (components/mention-input.tsx). */ export type TextSegment = { text: string; tag: Tag | null } export function segmentTaggedText(body: string, tags: Tag[]): TextSegment[] { const sorted = [...tags].sort((a, b) => a.start_index - b.start_index) const segments: TextSegment[] = [] let cursor = 0 for (const t of sorted) { if (t.start_index < cursor || t.end_index > body.length || t.start_index >= t.end_index) continue if (t.start_index > cursor) segments.push({ text: body.slice(cursor, t.start_index), tag: null }) segments.push({ text: body.slice(t.start_index, t.end_index), tag: t }) cursor = t.end_index } if (cursor < body.length) segments.push({ text: body.slice(cursor), tag: null }) return segments }