diff --git a/fluxer_app/src/features/lexical/nodes/SearchFilterNode.tsx b/fluxer_app/src/features/lexical/nodes/SearchFilterNode.tsx deleted file mode 100644 index 38ee02300..000000000 --- a/fluxer_app/src/features/lexical/nodes/SearchFilterNode.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {SearchFilterPill} from '@app/features/lexical/search/SearchFilterPill'; -import { - DecoratorNode, - type DOMExportOutput, - type EditorConfig, - type NodeKey, - type SerializedLexicalNode, - type Spread, -} from 'lexical'; -import type {JSX} from 'react'; - -export type SerializedSearchFilterNode = Spread< - { - raw: string; - filterKey: string; - value: string; - exclude: boolean; - }, - SerializedLexicalNode ->; - -export interface SearchFilterNodeOptions { - readonly raw: string; - readonly filterKey: string; - readonly value: string; - readonly exclude: boolean; - readonly key?: NodeKey; -} - -export class SearchFilterNode extends DecoratorNode { - __raw: string; - __filterKey: string; - __value: string; - __exclude: boolean; - - static override getType(): string { - return 'search-filter'; - } - - static override clone(node: SearchFilterNode): SearchFilterNode { - return new SearchFilterNode({ - raw: node.__raw, - filterKey: node.__filterKey, - value: node.__value, - exclude: node.__exclude, - key: node.__key, - }); - } - - static override importJSON(serializedNode: SerializedSearchFilterNode): SearchFilterNode { - return $createSearchFilterNode({ - raw: serializedNode.raw, - filterKey: serializedNode.filterKey, - value: serializedNode.value, - exclude: serializedNode.exclude, - }); - } - - constructor({raw, filterKey, value, exclude, key}: SearchFilterNodeOptions) { - super(key); - this.__raw = raw; - this.__filterKey = filterKey; - this.__value = value; - this.__exclude = exclude; - } - - override exportJSON(): SerializedSearchFilterNode { - return { - ...super.exportJSON(), - raw: this.__raw, - filterKey: this.__filterKey, - value: this.__value, - exclude: this.__exclude, - }; - } - - override createDOM(config: EditorConfig): HTMLElement { - const span = document.createElement('span'); - const className = config.theme.searchFilter; - if (typeof className === 'string') { - span.className = className; - } - span.setAttribute('data-lexical-search-filter', 'true'); - span.spellcheck = false; - return span; - } - - override updateDOM(): boolean { - return false; - } - - override exportDOM(): DOMExportOutput { - const element = document.createElement('span'); - element.setAttribute('data-lexical-search-filter', 'true'); - element.textContent = this.__raw; - return {element}; - } - - override getTextContent(): string { - return this.getLatest().__raw; - } - - override isInline(): true { - return true; - } - - override isKeyboardSelectable(): false { - return false; - } - - override decorate(): JSX.Element { - return ( - - ); - } -} - -export function $createSearchFilterNode(options: SearchFilterNodeOptions): SearchFilterNode { - return new SearchFilterNode(options); -} diff --git a/fluxer_app/src/features/lexical/search/LexicalSearchInput.module.css b/fluxer_app/src/features/lexical/search/LexicalSearchInput.module.css index 04902bf38..bc5ac2ca8 100644 --- a/fluxer_app/src/features/lexical/search/LexicalSearchInput.module.css +++ b/fluxer_app/src/features/lexical/search/LexicalSearchInput.module.css @@ -39,10 +39,62 @@ min-width: 100%; } -.filterHost { +.tokenBase { + align-items: center; + background-color: var(--background-modifier-selected); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); display: inline-flex; + height: 1.375rem; + line-height: normal; + margin-inline-start: unset; + padding: 0.0625rem var(--spacing-1); + position: relative; + top: -0.0625rem; vertical-align: middle; - white-space: normal; +} + +.tokenKey { + border-end-end-radius: 0; + border-start-end-radius: 0; + font-weight: 600; +} + +.tokenValue { + border-end-start-radius: 0; + border-start-start-radius: 0; + margin-inline: -0.125rem 0.125rem; +} + +.tokenKey + .tokenKey { + padding-inline-start: 0.125rem; +} + +.tokenKey:has(+ .tokenValue), +.tokenKey:has(+ .tokenUnapplied) { + border-inline-end: none; +} + +.tokenKey + .tokenValue, +.tokenKey + .tokenUnapplied { + border-inline-start: none; + margin-inline-start: 0; + padding-inline-start: 0; +} + +.tokenUnapplied { + border-end-start-radius: 0; + border-start-start-radius: 0; + margin-inline: -0.125rem 0.125rem; + color: var(--text-warning); + text-decoration: underline wavy var(--status-warning); + text-underline-offset: 0.1875rem; +} + +.tokenExclude { + background-color: color-mix(in srgb, var(--button-danger-fill) 92%, var(--background-primary)); + color: var(--button-danger-text); } .placeholder { diff --git a/fluxer_app/src/features/lexical/search/LexicalSearchInput.tsx b/fluxer_app/src/features/lexical/search/LexicalSearchInput.tsx index 933f1a77e..3f998826a 100644 --- a/fluxer_app/src/features/lexical/search/LexicalSearchInput.tsx +++ b/fluxer_app/src/features/lexical/search/LexicalSearchInput.tsx @@ -1,22 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {registerContextMenuUndoRedo} from '@app/features/lexical/LexicalUndoRedoRegistry'; -import {SearchFilterNode} from '@app/features/lexical/nodes/SearchFilterNode'; import styles from '@app/features/lexical/search/LexicalSearchInput.module.css'; import { $applySearchSelectionRange, + $deleteSearchChipAtCaret, $getSearchQuery, $getSelectionRange, $insertSearchText, $replaceSearchDocumentFromQuery, $selectOffset, - $selectSearchFilterBoundary, - registerSearchPillTransform, - resolveSearchFilterPointerBoundary, - type SearchFilterPointerBoundary, + registerSearchChipTransform, SearchSelectionDirection, type SearchSelectionRange, } from '@app/features/lexical/search/SearchEditorModel'; +import {SearchTokenNode} from '@app/features/lexical/search/SearchTokenNode'; import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {flxElementClassName} from '@app/lib/react'; @@ -31,10 +29,10 @@ import {mergeRegister} from '@lexical/utils'; import {clsx} from 'clsx'; import { $addUpdateTag, - $getNearestNodeFromDOMNode, $setSelection, COMMAND_PRIORITY_HIGH, COMPOSITION_END_COMMAND, + DELETE_CHARACTER_COMMAND, type EditorState, HISTORIC_TAG, KEY_ARROW_DOWN_COMMAND, @@ -123,19 +121,6 @@ interface SearchExternalValueOwnerRequest { readonly focused: MutableCell; } -interface SearchFilterTargetRequest { - readonly root: HTMLElement; - readonly targetElement: Element | null; - readonly filterElement: HTMLElement | null; -} - -interface SearchFilterPlacementRequest { - readonly root: HTMLElement; - readonly view: Window & typeof globalThis; - readonly filterElement: HTMLElement; - readonly clientX: number; -} - function isMatchingTouchTap(origin: TouchPointerOrigin, event: React.PointerEvent): boolean { if (origin.pointerId !== event.pointerId) { return false; @@ -216,7 +201,7 @@ class SearchEditorUpdateOwner { public register(): () => void { return mergeRegister( - registerSearchPillTransform(this.#editor), + registerSearchChipTransform(this.#editor), this.#editor.registerUpdateListener(this.handleUpdate), this.#editor.registerCommand(PASTE_COMMAND, SearchPasteCommand.handle, COMMAND_PRIORITY_HIGH), ); @@ -252,7 +237,7 @@ class SearchEditorUpdateOwner { } } -class SearchKeyboardCommandOwner { +export class SearchKeyboardCommandOwner { readonly #editor: LexicalEditor; readonly #autocompleteOpen: MutableCell; @@ -266,9 +251,14 @@ class SearchKeyboardCommandOwner { this.#editor.registerCommand(KEY_ENTER_COMMAND, this.handleEnter, COMMAND_PRIORITY_HIGH), this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.handleArrow, COMMAND_PRIORITY_HIGH), this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.handleArrow, COMMAND_PRIORITY_HIGH), + this.#editor.registerCommand(DELETE_CHARACTER_COMMAND, this.handleDeleteCharacter, COMMAND_PRIORITY_HIGH), ); } + private readonly handleDeleteCharacter = (isBackward: boolean): boolean => { + return $deleteSearchChipAtCaret(!isBackward); + }; + private readonly handleEnter = (event: KeyboardEvent | null): boolean => { if (event == null) { return true; @@ -402,7 +392,7 @@ class SearchExternalValueOwner { class SearchPointerSelectionOwner { constructor(private readonly editor: LexicalEditor) {} - public place(target: EventTarget | null, clientX: number): boolean { + public place(target: EventTarget | null): boolean { const root = this.editor.getRootElement(); if (root == null) { return false; @@ -411,65 +401,12 @@ class SearchPointerSelectionOwner { if (view == null || !(target instanceof view.Node)) { return false; } - const targetNode = target as Node; - const targetElement = this.resolveTargetElement(view, targetNode); - let filterElement: HTMLElement | null = null; - if (targetElement != null) { - filterElement = targetElement.closest('[data-lexical-search-filter="true"]'); - } - const filterTarget = this.resolveFilterTarget({root, targetElement, filterElement}); - if (filterTarget != null) { - return this.placeAtFilter({root, view, filterElement: filterTarget, clientX}); - } - if (target === root || !root.contains(targetNode)) { + if (target === root || !root.contains(target as Node)) { return this.placeAtEnd(); } return false; } - private resolveTargetElement(view: Window & typeof globalThis, targetNode: Node): Element | null { - if (targetNode instanceof view.Element) { - return targetNode as Element; - } - return targetNode.parentElement; - } - - private resolveFilterTarget({root, targetElement, filterElement}: SearchFilterTargetRequest): HTMLElement | null { - if (filterElement == null || !root.contains(filterElement)) { - return null; - } - if (targetElement != null && targetElement.closest('button') != null) { - return null; - } - return filterElement; - } - - private placeAtFilter({root, view, filterElement, clientX}: SearchFilterPlacementRequest): boolean { - const filterRect = filterElement.getBoundingClientRect(); - let direction: 'ltr' | 'rtl' = 'ltr'; - if (view.getComputedStyle(root).direction === 'rtl') { - direction = 'rtl'; - } - const boundary = resolveSearchFilterPointerBoundary({ - direction, - pointerX: clientX, - left: filterRect.left, - width: filterRect.width, - }); - this.editor.update(() => this.selectFilterBoundary(filterElement, boundary), {discrete: true}); - this.editor.focus(); - return true; - } - - private selectFilterBoundary(filterElement: HTMLElement, boundary: SearchFilterPointerBoundary): void { - const node = $getNearestNodeFromDOMNode(filterElement); - if (node != null) { - $selectSearchFilterBoundary(node, boundary); - return; - } - $selectOffset($getSearchQuery().length); - } - private placeAtEnd(): boolean { this.editor.update(() => $selectOffset($getSearchQuery().length), {discrete: true}); this.editor.focus(); @@ -482,9 +419,13 @@ const EDITOR_CONFIG: Omit = { onError: (error: Error) => { throw error; }, - nodes: [SearchFilterNode], + nodes: [SearchTokenNode], theme: { - searchFilter: styles.filterHost, + searchTokenBase: styles.tokenBase, + searchTokenKey: styles.tokenKey, + searchTokenValue: styles.tokenValue, + searchTokenUnapplied: styles.tokenUnapplied, + searchTokenExclude: styles.tokenExclude, paragraph: styles.paragraph, }, }; @@ -700,7 +641,7 @@ const SearchEditorInner = ({ }; return; } - if (pointerSelectionOwner.place(event.target, event.clientX)) { + if (pointerSelectionOwner.place(event.target)) { event.preventDefault(); } }} @@ -709,7 +650,7 @@ const SearchEditorInner = ({ touchPointerRef.current = null; if (pending == null) return; if (!isMatchingTouchTap(pending, event)) return; - if (pointerSelectionOwner.place(event.target, event.clientX)) { + if (pointerSelectionOwner.place(event.target)) { event.preventDefault(); } }} diff --git a/fluxer_app/src/features/lexical/search/SearchEditorModel.ts b/fluxer_app/src/features/lexical/search/SearchEditorModel.ts index 93da4ed23..fd1310335 100644 --- a/fluxer_app/src/features/lexical/search/SearchEditorModel.ts +++ b/fluxer_app/src/features/lexical/search/SearchEditorModel.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {$createSearchFilterNode} from '@app/features/lexical/nodes/SearchFilterNode'; -import {tokenize} from '@app/features/search/utils/SearchQueryParser'; +import {$createSearchTokenNode, $isSearchTokenNode} from '@app/features/lexical/search/SearchTokenNode'; +import {resolveSearchChipDeletion, type SearchChipRole, tokenize} from '@app/features/search/utils/SearchQueryParser'; import { $createParagraphNode, $createRangeSelection, @@ -17,12 +17,12 @@ import { type LexicalEditor, type LexicalNode, LineBreakNode, + ParagraphNode, type PointType, + RootNode, TextNode, } from 'lexical'; -const SEARCH_QUERY_QUOTE_CHARS = new Set(['"', '\u201c', '\u201d', '\u201f', '\u2033', '\u00ab', '\u00bb']); - export const SearchSelectionDirection = Object.freeze({ BACKWARD: 'backward', FORWARD: 'forward', @@ -31,6 +31,18 @@ export const SearchSelectionDirection = Object.freeze({ export type SearchSelectionDirection = (typeof SearchSelectionDirection)[keyof typeof SearchSelectionDirection]; +export interface SearchSelectionRange { + start: number; + end: number; + direction: SearchSelectionDirection; +} + +interface SearchSegment { + readonly text: string; + readonly role: SearchChipRole | null; + readonly exclude: boolean; +} + function $ensureRootParagraph(): ElementNode { const root = $getRoot(); const first = root.getFirstChild(); @@ -43,44 +55,6 @@ function $ensureRootParagraph(): ElementNode { return paragraph; } -export interface SearchSelectionRange { - start: number; - end: number; - direction: SearchSelectionDirection; -} - -export interface ResolveSearchFilterPointerBoundaryRequest { - readonly direction: 'ltr' | 'rtl'; - readonly pointerX: number; - readonly left: number; - readonly width: number; -} - -interface SetNonTextPointAtOffsetRequest { - readonly point: PointType; - readonly paragraph: ElementNode; - readonly children: ReadonlyArray; - readonly index: number; - readonly offset: number; - readonly accumulated: number; - readonly end: number; -} - -interface SetPointAtChildBoundaryRequest { - readonly point: PointType; - readonly paragraph: ElementNode; - readonly children: ReadonlyArray; - readonly index: number; -} - -export const SearchFilterPointerBoundary = Object.freeze({ - BEFORE: 'before', - AFTER: 'after', -} as const); - -export type SearchFilterPointerBoundary = - (typeof SearchFilterPointerBoundary)[keyof typeof SearchFilterPointerBoundary]; - function resolveSearchSelectionDirection(anchor: number, focus: number): SearchSelectionDirection { if (anchor === focus) { return SearchSelectionDirection.NONE; @@ -104,27 +78,19 @@ function resolveSearchCaretOffset(selection: number | SearchSelectionRange | nul return selection.end; } -export function resolveSearchFilterPointerBoundary({ - direction, - pointerX, - left, - width, -}: ResolveSearchFilterPointerBoundaryRequest): SearchFilterPointerBoundary { - const isVisualLeft = pointerX < left + width / 2; - let isLogicalBefore: boolean; - if (direction === 'rtl') { - isLogicalBefore = !isVisualLeft; - } else { - isLogicalBefore = isVisualLeft; - } - if (isLogicalBefore) { - return SearchFilterPointerBoundary.BEFORE; - } - return SearchFilterPointerBoundary.AFTER; +export const SEARCH_INPUT_MAX_LENGTH = 2048; +const SEARCH_INPUT_COUNTER_HEADROOM = Math.floor(SEARCH_INPUT_MAX_LENGTH / 10); + +export function shouldShowSearchInputCounter(currentLength: number): boolean { + return SEARCH_INPUT_MAX_LENGTH - currentLength <= SEARCH_INPUT_COUNTER_HEADROOM; +} + +function clampSearchInputText(text: string): string { + return text.length > SEARCH_INPUT_MAX_LENGTH ? text.slice(0, SEARCH_INPUT_MAX_LENGTH) : text; } function normalizeSearchInputText(text: string): string { - return text.replace(/\r\n?|\n|\u2028|\u2029/g, ' '); + return text.replace(/[\r\n\u2028\u2029]/g, ' '); } export function $insertSearchText(text: string): boolean { @@ -132,128 +98,140 @@ export function $insertSearchText(text: string): boolean { if (!$isRangeSelection(selection)) { return false; } - selection.insertText(normalizeSearchInputText(text)); + const documentLength = $getRoot().getTextContent().length; + const replacedLength = selection.getTextContent().length; + const available = SEARCH_INPUT_MAX_LENGTH - (documentLength - replacedLength); + if (available <= 0) { + return true; + } + const normalized = normalizeSearchInputText(text); + selection.insertText(normalized.length > available ? normalized.slice(0, available) : normalized); return true; } -function stripQuotes(value: string): string { - const opening = value[0]; - if (opening == null || !SEARCH_QUERY_QUOTE_CHARS.has(opening)) { - return value; - } - const closing = value[value.length - 1]; - const hasClosing = value.length >= 2 && closing != null && SEARCH_QUERY_QUOTE_CHARS.has(closing); - if (hasClosing) { - return value.slice(1, -1); - } - return value.slice(1); -} - -function $buildParagraphChildren(query: string, selection: number | SearchSelectionRange | null): Array { - const {tokens} = tokenize(query); - const children: Array = []; - let pos = 0; - const pushText = (text: string) => { +function buildSearchSegments(query: string): Array { + const {chips} = tokenize(query); + const segments: Array = []; + let position = 0; + const pushPlain = (text: string) => { if (text.length > 0) { - children.push($createTextNode(text)); + segments.push({text, role: null, exclude: false}); } }; - for (const tok of tokens) { - if (tok.start > pos) { - pushText(query.slice(pos, tok.start)); - } - const raw = query.slice(tok.start, tok.end); - let selectionCutsToken: boolean; - if (typeof selection === 'number') { - selectionCutsToken = selection > tok.start && selection <= tok.end; - } else { - selectionCutsToken = - selection != null && - ((selection.start > tok.start && selection.start < tok.end) || - (selection.end > tok.start && selection.end < tok.end)); - } - const nextChar = query[tok.end]; - const terminated = nextChar == null || nextChar === ' '; - if (selectionCutsToken || !terminated) { - pushText(raw); - } else { - children.push( - $createSearchFilterNode({ - raw, - filterKey: tok.key, - value: stripQuotes(tok.value), - exclude: tok.exclude, - }), - ); - } - pos = tok.end; + for (const chip of chips) { + pushPlain(query.slice(position, chip.start)); + segments.push({text: query.slice(chip.start, chip.end), role: chip.role, exclude: chip.exclude}); + position = chip.end; } - if (pos < query.length) { - pushText(query.slice(pos)); + pushPlain(query.slice(position)); + return segments; +} + +function $createSegmentNode(segment: SearchSegment): TextNode { + if (segment.role == null) { + return $createTextNode(segment.text); + } + return $createSearchTokenNode(segment.text, segment.role, segment.exclude); +} + +function readSegmentRole(node: LexicalNode): SearchChipRole | null { + if ($isSearchTokenNode(node)) { + return node.getRole(); + } + return null; +} + +function readSegmentExclude(node: LexicalNode): boolean { + return $isSearchTokenNode(node) && node.getExclude(); +} + +function childrenMatchSegments(children: ReadonlyArray, segments: ReadonlyArray): boolean { + if (children.length !== segments.length) { + return false; + } + return segments.every((segment, index) => { + const child = children[index]!; + return ( + child instanceof TextNode && + child.getTextContent() === segment.text && + readSegmentRole(child) === segment.role && + readSegmentExclude(child) === segment.exclude + ); + }); +} + +function $retextChildren(children: ReadonlyArray, segments: ReadonlyArray): boolean { + if (children.length !== segments.length) { + return false; + } + const rolesMatch = segments.every((segment, index) => { + const child = children[index]!; + return ( + child instanceof TextNode && + readSegmentRole(child) === segment.role && + readSegmentExclude(child) === segment.exclude + ); + }); + if (!rolesMatch) { + return false; + } + for (const [index, segment] of segments.entries()) { + const child = children[index] as TextNode; + if (child.getTextContent() !== segment.text) { + child.setTextContent(segment.text); + } + } + return true; +} + +function $reconcileSearchChips(paragraph: ElementNode): void { + const normalized = normalizeSearchInputText(paragraph.getTextContent()); + const query = clampSearchInputText(normalized); + if (query.length === 0) { + return; + } + const segments = buildSearchSegments(query); + const children = paragraph.getChildren(); + if (childrenMatchSegments(children, segments)) { + return; + } + const selection = $getSelectionRange(); + if (!$retextChildren(children, segments)) { + paragraph.clear(); + paragraph.append(...segments.map($createSegmentNode)); + } + if (selection != null) { + $selectRange(selection.start, selection.end, selection.direction); } - return children; } export function $getSearchQuery(): string { - return $getRoot() - .getTextContent() - .replace(/[\r\n\u2028\u2029]/g, ' '); + return normalizeSearchInputText($getRoot().getTextContent()); } -function $pillifyTextNode(node: TextNode): void { - const text = node.getTextContent(); - if (!text.includes(':')) { - return; - } - const {tokens} = tokenize(text); - if (tokens.length === 0) { - return; - } - const selectionOffsets = readSearchTextNodeSelectionOffsets(node); - for (const tok of tokens) { - if (text[tok.end] !== ' ') { - continue; - } - if (selectionOffsets.some((offset) => offset > tok.start && offset <= tok.end)) { - continue; - } - const segments = node.splitText(tok.start, tok.end); - let tokenIndex = 0; - if (tok.start > 0) { - tokenIndex = 1; - } - const tokenNode = segments[tokenIndex]; - if (tokenNode == null) { - return; - } - const raw = text.slice(tok.start, tok.end); - tokenNode.replace( - $createSearchFilterNode({ - raw, - filterKey: tok.key, - value: stripQuotes(tok.value), - exclude: tok.exclude, - }), - ); - return; - } -} - -function readSearchTextNodeSelectionOffsets(node: TextNode): Array { +export function $deleteSearchChipAtCaret(isForward: boolean): boolean { const selection = $getSelection(); - const selectionOffsets: Array = []; - if (!$isRangeSelection(selection)) { - return selectionOffsets; + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; } - for (const point of [selection.anchor, selection.focus]) { - if (point.type === 'text' && point.getNode().getKey() === node.getKey()) { - selectionOffsets.push(point.offset); - } + const range = $getSelectionRange(); + if (range == null) { + return false; } - return selectionOffsets; + const chip = resolveSearchChipDeletion($getSearchQuery(), range.start, isForward); + if (chip == null) { + return false; + } + $selectRange(chip.start, chip.end); + const chipSelection = $getSelection(); + if (!$isRangeSelection(chipSelection)) { + return false; + } + chipSelection.removeText(); + return true; } -function refreshSearchPillsAfterComposition(editor: LexicalEditor): void { +function refreshSearchChipsAfterComposition(editor: LexicalEditor): void { queueMicrotask(() => { editor.update( () => { @@ -266,25 +244,16 @@ function refreshSearchPillsAfterComposition(editor: LexicalEditor): void { }); } -function $normalizeSearchTextNode(node: TextNode): boolean { - const text = node.getTextContent(); - const normalized = normalizeSearchInputText(text); - if (normalized === text) { - return false; - } - node.setTextContent(normalized); - return true; -} - -export function registerSearchPillTransform(editor: LexicalEditor): () => void { - const unregisterTransform = editor.registerNodeTransform(TextNode, (node) => { +export function registerSearchChipTransform(editor: LexicalEditor): () => void { + const unregisterTransform = editor.registerNodeTransform(RootNode, (root) => { if (editor.isComposing()) { return; } - if ($normalizeSearchTextNode(node)) { - return; + for (const child of root.getChildren()) { + if (child instanceof ParagraphNode) { + $reconcileSearchChips(child); + } } - $pillifyTextNode(node); }); const unregisterLineBreak = editor.registerNodeTransform(LineBreakNode, (node) => { node.replace($createTextNode(' ')); @@ -292,7 +261,7 @@ export function registerSearchPillTransform(editor: LexicalEditor): () => void { const unregisterCompositionEnd = editor.registerCommand( COMPOSITION_END_COMMAND, () => { - refreshSearchPillsAfterComposition(editor); + refreshSearchChipsAfterComposition(editor); return false; }, COMMAND_PRIORITY_HIGH, @@ -356,74 +325,21 @@ function $setElementCaret(paragraph: ElementNode, index: number): void { $setSelection(selection); } -function resolveElementPointOffset(index: number, offset: number, accumulated: number): number { - if (offset - accumulated === 0) { - return index; - } - return index + 1; -} - -function $setPointAtChildBoundary({point, paragraph, children, index}: SetPointAtChildBoundaryRequest): void { - const next = children[index + 1]; - if (next instanceof TextNode) { - point.set(next.getKey(), 0, 'text'); - return; - } - point.set(paragraph.getKey(), index + 1, 'element'); -} - function $setPointAtOffset(point: PointType, paragraph: ElementNode, rawOffset: number): void { const children = paragraph.getChildren(); const offset = Math.max(0, rawOffset); let acc = 0; - for (const [index, child] of children.entries()) { + for (const child of children) { const end = acc + child.getTextContent().length; if (child instanceof TextNode && offset <= end) { point.set(child.getKey(), Math.max(0, offset - acc), 'text'); return; } - if (child instanceof TextNode) { - acc = end; - continue; - } - if ( - $setNonTextPointAtOffset({ - point, - paragraph, - children, - index, - offset, - accumulated: acc, - end, - }) - ) { - return; - } acc = end; } point.set(paragraph.getKey(), children.length, 'element'); } -function $setNonTextPointAtOffset({ - point, - paragraph, - children, - index, - offset, - accumulated, - end, -}: SetNonTextPointAtOffsetRequest): boolean { - if (offset < end) { - point.set(paragraph.getKey(), resolveElementPointOffset(index, offset, accumulated), 'element'); - return true; - } - if (offset === end) { - $setPointAtChildBoundary({point, paragraph, children, index}); - return true; - } - return false; -} - function $selectRange( start: number, end: number, @@ -450,22 +366,6 @@ function $selectRange( } export function $applySearchSelectionRange(range: SearchSelectionRange): void { - const paragraph = $ensureRootParagraph(); - let endpoints: Array; - if (range.start === range.end) { - endpoints = [range.start]; - } else { - endpoints = [range.start, range.end]; - } - let offset = 0; - for (const child of paragraph.getChildren()) { - const end = offset + child.getTextContent().length; - if (!(child instanceof TextNode) && endpoints.some((endpoint) => endpoint > offset && endpoint < end)) { - $replaceSearchDocumentFromQuery($getSearchQuery(), range); - return; - } - offset = end; - } $selectRange(range.start, range.end, range.direction); } @@ -473,38 +373,24 @@ export function $selectOffset(offset: number): void { $selectRange(offset, offset); } -export function $selectSearchFilterBoundary(node: LexicalNode, boundary: SearchFilterPointerBoundary): void { - const paragraph = $ensureRootParagraph(); - let offset = 0; - for (const child of paragraph.getChildren()) { - if (!child.is(node)) { - offset += child.getTextContent().length; - continue; - } - if (boundary === SearchFilterPointerBoundary.AFTER) { - offset += child.getTextContent().length; - } - $selectOffset(offset); - return; - } - $selectOffset($getSearchQuery().length); -} - export function $replaceSearchDocumentFromQuery(query: string, selection: number | SearchSelectionRange | null): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); - const caretOffset = resolveSearchCaretOffset(selection); - const children = $buildParagraphChildren(query, selection); + const normalizedQuery = normalizeSearchInputText(query); + const children = buildSearchSegments(normalizedQuery).map($createSegmentNode); if (children.length > 0) { paragraph.append(...children); } + const caretOffset = resolveSearchCaretOffset(selection); if (typeof selection === 'object' && selection != null) { $selectRange(selection.start, selection.end, selection.direction); - } else if (caretOffset != null) { - $selectOffset(Math.min(caretOffset, query.length)); - } else { - $setElementCaret(paragraph, paragraph.getChildrenSize()); + return; } + if (caretOffset != null) { + $selectOffset(Math.min(caretOffset, normalizedQuery.length)); + return; + } + $setElementCaret(paragraph, paragraph.getChildrenSize()); } diff --git a/fluxer_app/src/features/lexical/search/SearchFilterPill.module.css b/fluxer_app/src/features/lexical/search/SearchFilterPill.module.css deleted file mode 100644 index 80d80e969..000000000 --- a/fluxer_app/src/features/lexical/search/SearchFilterPill.module.css +++ /dev/null @@ -1,72 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ - -.pill { - display: inline-flex; - align-items: center; - vertical-align: middle; - margin: 0 0.0625rem; - padding: 0 0.25rem; - border-radius: 0.25rem; - background-color: var(--background-modifier-selected); - border: 0.0625rem solid var(--border-color); - font-size: 0.8125rem; - line-height: 1.25rem; - white-space: nowrap; - user-select: none; - max-width: 13rem; - overflow: hidden; -} - -.visual { - display: inline-flex; - align-items: center; - gap: 0.1875rem; - min-width: 0; - overflow: hidden; -} - -.pillExclude { - background-color: var(--background-modifier-hover); - border-style: dashed; -} - -.pill.selected { - background-image: linear-gradient(var(--text-selection), var(--text-selection)); -} - -.notBadge { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--status-danger); -} - -.icon { - width: 0.75rem; - height: 0.75rem; - flex: 0 0 auto; - color: var(--text-secondary); -} - -.avatar { - flex: 0 0 auto; - border-radius: 50%; -} - -.operator { - font-weight: 600; - color: var(--text-secondary); -} - -.operator::after { - content: ':'; - color: var(--text-secondary); -} - -.value { - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - max-width: 9rem; -} diff --git a/fluxer_app/src/features/lexical/search/SearchFilterPill.tsx b/fluxer_app/src/features/lexical/search/SearchFilterPill.tsx deleted file mode 100644 index 269627101..000000000 --- a/fluxer_app/src/features/lexical/search/SearchFilterPill.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {resolveDisplayValue} from '@app/features/lexical/search/SearchFilterDisplay'; -import {getSearchFilterMeta, SearchFilterCategory} from '@app/features/lexical/search/SearchFilterMeta'; -import styles from '@app/features/lexical/search/SearchFilterPill.module.css'; -import {SearchFilterPillSelections} from '@app/features/lexical/search/SearchFilterPillSelections'; -import {Avatar} from '@app/features/ui/components/Avatar'; -import Users from '@app/features/user/state/Users'; -import * as DisplayNameUtils from '@app/features/user/utils/DisplayNameUtils'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {msg} from '@lingui/core/macro'; -import {useLingui} from '@lingui/react/macro'; -import {clsx} from 'clsx'; -import {observer} from 'mobx-react-lite'; -import {useCallback, useSyncExternalStore} from 'react'; - -const SEARCH_USER_TAG_PATTERN = /^([A-Za-z0-9_]+)#(\d{4})$/; - -const FILTER_DESCRIPTOR = msg({ - message: 'Search filter: {filter}', - comment: 'Accessible label for a search filter pill. Preserve {filter}; it contains the filter operator and value.', -}); -const EXCLUDED_FILTER_DESCRIPTOR = msg({ - message: 'Excluded search filter: {filter}', - comment: - 'Accessible label for a negative search filter pill. Preserve {filter}; it contains the filter operator and value.', -}); -const NOT_BADGE_DESCRIPTOR = msg({ - message: 'not', - comment: 'Tiny badge shown on an excluded/negative search filter pill (e.g. -from:user). Keep it very short.', -}); - -interface SearchFilterPillProps { - nodeKey: string; - filterKey: string; - value: string; - exclude: boolean; -} - -function resolvePillDisplayValue( - category: SearchFilterCategory, - value: string, - untruncatedResolvedName: string | null, -): string { - const resolvedDisplayValue = resolveDisplayValue(category, value, untruncatedResolvedName); - if (category === SearchFilterCategory.USER) { - return DisplayNameUtils.formatNameForStreamerMode(resolvedDisplayValue); - } - return resolvedDisplayValue; -} - -function useIsPillSelected(nodeKey: string): boolean { - const [editor] = useLexicalComposerContext(); - const subscribe = useCallback( - (listener: () => void) => SearchFilterPillSelections.subscribe({editor, nodeKey, listener}), - [editor, nodeKey], - ); - const getSnapshot = useCallback(() => SearchFilterPillSelections.isSelected({editor, nodeKey}), [editor, nodeKey]); - return useSyncExternalStore(subscribe, getSnapshot, () => false); -} - -const SearchFilterPillComponent = ({nodeKey, filterKey, value, exclude}: SearchFilterPillProps) => { - const {i18n} = useLingui(); - const isSelected = useIsPillSelected(nodeKey); - const meta = getSearchFilterMeta(filterKey); - const trimmedValue = value.trim(); - - function resolveResolvedUser() { - if (meta.category !== SearchFilterCategory.USER) { - return null; - } - if (trimmedValue.toLowerCase() === '@me') { - const currentUser = Users.getCurrentUser(); - if (currentUser == null) { - return null; - } - return currentUser; - } - return resolveUser(trimmedValue); - } - const resolvedUser = resolveResolvedUser(); - let untruncatedResolvedUserName: string | null = null; - if (resolvedUser != null) { - untruncatedResolvedUserName = resolvedUser.displayName; - } - const displayValue = resolvePillDisplayValue(meta.category, trimmedValue, untruncatedResolvedUserName); - const filterDescription = `${meta.operator}: ${displayValue}`; - let pillLabel = i18n._(FILTER_DESCRIPTOR, {filter: filterDescription}); - if (exclude) { - pillLabel = i18n._(EXCLUDED_FILTER_DESCRIPTOR, {filter: filterDescription}); - } - const renderFilterIcon = (): React.ReactNode => { - if (resolvedUser != null) { - return ( - - ); - } - return ( - - ); - }; - - return ( - - - - ); -}; - -function resolveUser(value: string) { - if (!SEARCH_USER_TAG_PATTERN.test(value)) { - return null; - } - const user = Users.getUserByTag(value); - if (user == null) { - return null; - } - return user; -} - -export const SearchFilterPill = observer(SearchFilterPillComponent); diff --git a/fluxer_app/src/features/lexical/search/SearchFilterPillSelections.ts b/fluxer_app/src/features/lexical/search/SearchFilterPillSelections.ts deleted file mode 100644 index 5b8773844..000000000 --- a/fluxer_app/src/features/lexical/search/SearchFilterPillSelections.ts +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {$getSelection, $isNodeSelection, $isRangeSelection, type EditorState, type LexicalEditor} from 'lexical'; - -const MAX_SEARCH_FILTER_PILL_SELECTION_SUBSCRIBERS_PER_EDITOR = 2048; - -interface SearchFilterPillSelectionSubscription { - readonly editor: LexicalEditor; - readonly nodeKey: string; - readonly listener: () => void; -} - -interface SearchFilterPillSelectionQuery { - readonly editor: LexicalEditor; - readonly nodeKey: string; -} - -interface SearchFilterPillSelectionRelease { - readonly editor: LexicalEditor; - readonly selections: SearchFilterPillEditorSelections; - readonly unsubscribe: () => void; -} - -class DuplicateSearchFilterPillSelectionListenerError extends Error { - constructor(nodeKey: string) { - super(`Search filter pill selection listener is already registered for node ${nodeKey}`); - this.name = 'DuplicateSearchFilterPillSelectionListenerError'; - } -} - -class SearchFilterPillSelectionSubscriberCapacityError extends Error { - constructor() { - const message = [ - 'Cannot register more than ', - String(MAX_SEARCH_FILTER_PILL_SELECTION_SUBSCRIBERS_PER_EDITOR), - ' search filter pill selection subscribers per editor', - ].join(''); - super(message); - this.name = 'SearchFilterPillSelectionSubscriberCapacityError'; - } -} - -class SearchFilterPillEditorSelections { - readonly #editor: LexicalEditor; - readonly #listenersByNodeKey = new Map void>>(); - #selectedNodeKeys: ReadonlySet; - #subscriberCount = 0; - #unregister: (() => void) | null = null; - - constructor(editor: LexicalEditor) { - this.#editor = editor; - this.#selectedNodeKeys = readSelectedNodeKeys(editor.getEditorState()); - } - - public get hasSubscribers(): boolean { - return this.#subscriberCount > 0; - } - - public isSelected(nodeKey: string): boolean { - return this.#selectedNodeKeys.has(nodeKey); - } - - public subscribe(nodeKey: string, listener: () => void): () => void { - let listeners = this.#listenersByNodeKey.get(nodeKey); - if (listeners == null) { - listeners = new Set(); - this.#listenersByNodeKey.set(nodeKey, listeners); - } - if (listeners.has(listener)) { - throw new DuplicateSearchFilterPillSelectionListenerError(nodeKey); - } - if (this.#subscriberCount >= MAX_SEARCH_FILTER_PILL_SELECTION_SUBSCRIBERS_PER_EDITOR) { - throw new SearchFilterPillSelectionSubscriberCapacityError(); - } - listeners.add(listener); - this.#subscriberCount += 1; - try { - if (this.#subscriberCount === 1) { - this.start(); - } - } catch (error) { - listeners.delete(listener); - this.#subscriberCount -= 1; - if (listeners.size === 0) { - this.#listenersByNodeKey.delete(nodeKey); - } - throw error; - } - let disposed = false; - return () => { - if (disposed) return; - disposed = true; - listeners.delete(listener); - this.#subscriberCount -= 1; - if (listeners.size === 0) { - this.#listenersByNodeKey.delete(nodeKey); - } - if (this.#subscriberCount === 0) { - this.stop(); - } - }; - } - - private start(): void { - this.#selectedNodeKeys = readSelectedNodeKeys(this.#editor.getEditorState()); - this.#unregister = this.#editor.registerUpdateListener(({editorState}) => { - this.handleEditorUpdate(editorState); - }); - } - - private stop(): void { - const unregister = this.#unregister; - this.#unregister = null; - if (unregister != null) { - unregister(); - } - } - - private handleEditorUpdate(editorState: EditorState): void { - const nextSelectedNodeKeys = readSelectedNodeKeys(editorState); - const previousSelectedNodeKeys = this.#selectedNodeKeys; - if ( - nextSelectedNodeKeys.size === previousSelectedNodeKeys.size && - Array.from(nextSelectedNodeKeys).every((nodeKey) => previousSelectedNodeKeys.has(nodeKey)) - ) { - return; - } - this.#selectedNodeKeys = nextSelectedNodeKeys; - const changedNodeKeys = new Set([...previousSelectedNodeKeys, ...nextSelectedNodeKeys]); - const failures: Array = []; - for (const nodeKey of changedNodeKeys) { - if (previousSelectedNodeKeys.has(nodeKey) === nextSelectedNodeKeys.has(nodeKey)) { - continue; - } - const listeners = this.#listenersByNodeKey.get(nodeKey); - if (listeners == null) { - continue; - } - this.notifyListeners(listeners, failures); - } - if (failures.length > 0) { - throw new AggregateError(failures, 'Search filter pill selection listener notification failed'); - } - } - - private notifyListeners(listeners: ReadonlySet<() => void>, failures: Array): void { - for (const listener of [...listeners]) { - try { - listener(); - } catch (error) { - failures.push(error); - } - } - } -} - -class SearchFilterPillSelectionOwner { - readonly #editorSelections = new WeakMap(); - - public subscribe({editor, nodeKey, listener}: SearchFilterPillSelectionSubscription): () => void { - const selections = this.getOrCreate(editor); - const unsubscribe = selections.subscribe(nodeKey, listener); - let disposed = false; - return () => { - if (disposed) return; - disposed = true; - this.releaseSubscription({editor, selections, unsubscribe}); - }; - } - - public isSelected({editor, nodeKey}: SearchFilterPillSelectionQuery): boolean { - const selections = this.#editorSelections.get(editor); - if (selections != null) { - return selections.isSelected(nodeKey); - } - return readSelectedNodeKeys(editor.getEditorState()).has(nodeKey); - } - - private getOrCreate(editor: LexicalEditor): SearchFilterPillEditorSelections { - const existing = this.#editorSelections.get(editor); - if (existing != null) { - return existing; - } - const selections = new SearchFilterPillEditorSelections(editor); - this.#editorSelections.set(editor, selections); - return selections; - } - - private releaseSubscription({editor, selections, unsubscribe}: SearchFilterPillSelectionRelease): void { - try { - unsubscribe(); - } finally { - if (!selections.hasSubscribers) { - this.#editorSelections.delete(editor); - } - } - } -} - -function readSelectedNodeKeys(editorState: EditorState): ReadonlySet { - const selectedNodeKeys = new Set(); - editorState.read(() => { - $readSelectedNodeKeys(selectedNodeKeys); - }); - return selectedNodeKeys; -} - -function $readSelectedNodeKeys(selectedNodeKeys: Set): void { - const selection = $getSelection(); - if (!$isNodeSelection(selection) && (!$isRangeSelection(selection) || selection.isCollapsed())) { - return; - } - for (const node of selection.getNodes()) { - selectedNodeKeys.add(node.getKey()); - } -} - -export const SearchFilterPillSelections = Object.freeze(new SearchFilterPillSelectionOwner()); diff --git a/fluxer_app/src/features/lexical/search/SearchTokenNode.ts b/fluxer_app/src/features/lexical/search/SearchTokenNode.ts new file mode 100644 index 000000000..12adcc6bf --- /dev/null +++ b/fluxer_app/src/features/lexical/search/SearchTokenNode.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SearchChipRole} from '@app/features/search/utils/SearchQueryParser'; +import { + type EditorConfig, + type LexicalNode, + type NodeKey, + type SerializedTextNode, + type Spread, + TextNode, +} from 'lexical'; + +export type SerializedSearchTokenNode = Spread<{role: SearchChipRole; exclude: boolean}, SerializedTextNode>; + +function resolveRoleThemeClass(role: SearchChipRole, config: EditorConfig): unknown { + switch (role) { + case SearchChipRole.KEY: + return config.theme.searchTokenKey; + case SearchChipRole.UNAPPLIED_VALUE: + return config.theme.searchTokenUnapplied; + default: + return config.theme.searchTokenValue; + } +} + +export class SearchTokenNode extends TextNode { + __role: SearchChipRole; + __exclude: boolean; + + static override getType(): string { + return 'search-token'; + } + + static override clone(node: SearchTokenNode): SearchTokenNode { + return new SearchTokenNode(node.__text, node.__role, node.__exclude, node.__key); + } + + static override importJSON(serializedNode: SerializedSearchTokenNode): SearchTokenNode { + return $createSearchTokenNode(serializedNode.text, serializedNode.role, serializedNode.exclude).updateFromJSON( + serializedNode, + ); + } + + constructor(text: string, role: SearchChipRole, exclude: boolean, key?: NodeKey) { + super(text, key); + this.__role = role; + this.__exclude = exclude; + } + + override exportJSON(): SerializedSearchTokenNode { + return {...super.exportJSON(), role: this.getRole(), exclude: this.getExclude()}; + } + + getRole(): SearchChipRole { + return this.getLatest().__role; + } + + getExclude(): boolean { + return this.getLatest().__exclude; + } + + override createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + const baseClassName = config.theme.searchTokenBase; + if (typeof baseClassName === 'string') { + dom.classList.add(baseClassName); + } + const roleClassName = resolveRoleThemeClass(this.__role, config); + if (typeof roleClassName === 'string') { + dom.classList.add(roleClassName); + } + const excludeClassName = config.theme.searchTokenExclude; + if (this.__exclude && typeof excludeClassName === 'string') { + dom.classList.add(excludeClassName); + } + dom.setAttribute('data-search-token', this.__role); + if (this.__exclude) { + dom.setAttribute('data-search-token-exclude', 'true'); + } + return dom; + } + + override updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { + if (prevNode.__role !== this.__role || prevNode.__exclude !== this.__exclude) { + return true; + } + return super.updateDOM(prevNode, dom, config); + } +} + +export function $createSearchTokenNode(text: string, role: SearchChipRole, exclude: boolean): SearchTokenNode { + return new SearchTokenNode(text, role, exclude); +} + +export function $isSearchTokenNode(node: LexicalNode | null | undefined): node is SearchTokenNode { + return node instanceof SearchTokenNode; +}