feat(app): render search filters as inline tokens you can type through (#1887)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:39:45 +02:00
1 parent 202856c0f7
commit 3a064dd728
8 files changed
+331 -934

No files matched your search

@@ -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<JSX.Element> {
__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 (
<SearchFilterPill
nodeKey={this.getKey()}
filterKey={this.__filterKey}
value={this.__value}
exclude={this.__exclude}
data-flx="lexical.nodes.search-filter-node.search-filter-pill"
/>
);
}
}
export function $createSearchFilterNode(options: SearchFilterNodeOptions): SearchFilterNode {
return new SearchFilterNode(options);
}
@@ -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 {
@@ -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<boolean>;
}
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<HTMLElement>): 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<boolean>;
@@ -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<HTMLElement>('[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<InitialConfigType, 'editorState'> = {
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();
}
}}
@@ -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<LexicalNode>;
readonly index: number;
readonly offset: number;
readonly accumulated: number;
readonly end: number;
}
interface SetPointAtChildBoundaryRequest {
readonly point: PointType;
readonly paragraph: ElementNode;
readonly children: ReadonlyArray<LexicalNode>;
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<LexicalNode> {
const {tokens} = tokenize(query);
const children: Array<LexicalNode> = [];
let pos = 0;
const pushText = (text: string) => {
function buildSearchSegments(query: string): Array<SearchSegment> {
const {chips} = tokenize(query);
const segments: Array<SearchSegment> = [];
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<LexicalNode>, segments: ReadonlyArray<SearchSegment>): 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<LexicalNode>, segments: ReadonlyArray<SearchSegment>): 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<number> {
export function $deleteSearchChipAtCaret(isForward: boolean): boolean {
const selection = $getSelection();
const selectionOffsets: Array<number> = [];
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<number>;
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());
}
@@ -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;
}
@@ -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 (
<Avatar
user={resolvedUser}
size={14}
className={styles.avatar}
disableStatusTooltip
showOffline={false}
data-flx="lexical.search.search-filter-pill.render-filter-icon.avatar"
/>
);
}
return (
<meta.Icon
weight="bold"
className={styles.icon}
data-flx="lexical.search.search-filter-pill.render-filter-icon.icon"
/>
);
};
return (
<span
role="img"
className={clsx(styles.pill, exclude && styles.pillExclude, isSelected && styles.selected)}
contentEditable={false}
aria-label={pillLabel}
data-flx="lexical.search.search-filter-pill.search-filter-pill-component.pill"
>
<span
className={styles.visual}
aria-hidden="true"
data-flx="lexical.search.search-filter-pill.search-filter-pill-component.visual"
>
{exclude && (
<span
className={styles.notBadge}
data-flx="lexical.search.search-filter-pill.search-filter-pill-component.not-badge"
>
{i18n._(NOT_BADGE_DESCRIPTOR)}
</span>
)}
{renderFilterIcon()}
<span
className={styles.operator}
data-flx="lexical.search.search-filter-pill.search-filter-pill-component.operator"
>
{meta.operator}
</span>
<span className={styles.value} data-flx="lexical.search.search-filter-pill.search-filter-pill-component.value">
{displayValue}
</span>
</span>
</span>
);
};
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);
@@ -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<string, Set<() => void>>();
#selectedNodeKeys: ReadonlySet<string>;
#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<unknown> = [];
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<unknown>): void {
for (const listener of [...listeners]) {
try {
listener();
} catch (error) {
failures.push(error);
}
}
}
}
class SearchFilterPillSelectionOwner {
readonly #editorSelections = new WeakMap<LexicalEditor, SearchFilterPillEditorSelections>();
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<string> {
const selectedNodeKeys = new Set<string>();
editorState.read(() => {
$readSelectedNodeKeys(selectedNodeKeys);
});
return selectedNodeKeys;
}
function $readSelectedNodeKeys(selectedNodeKeys: Set<string>): 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());
@@ -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;
}