mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(app): render search filters as inline tokens you can type through (#1887)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user