refactor: blocks plugins (#1956)
This commit is contained in:
@@ -9,13 +9,13 @@
|
||||
"dependencies": {
|
||||
"@lexical/react": "^0.6.0",
|
||||
"@standardnotes/icons": "workspace:*",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"lexical": "^0.6.0",
|
||||
"react": "link:../web/node_modules/react",
|
||||
"react-dom": "link:../web/node_modules/react-dom"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "link:../web/node_modules/@types/react",
|
||||
"@types/react-dom": "link:../web/node_modules/@types/react-dom",
|
||||
"eslint": "*",
|
||||
"prettier": "*",
|
||||
"typescript": "*"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {FunctionComponent, useCallback, useState} from 'react';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
||||
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
|
||||
@@ -20,27 +19,25 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
|
||||
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
|
||||
import {EditorState, LexicalEditor} from 'lexical';
|
||||
|
||||
import ComponentPickerMenuPlugin from '../Lexical/Plugins/ComponentPickerPlugin';
|
||||
import BlocksEditorTheme from '../Lexical/Theme/Theme';
|
||||
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
|
||||
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
|
||||
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
|
||||
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
|
||||
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin';
|
||||
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
|
||||
// import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
|
||||
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
|
||||
|
||||
const BlockDragEnabled = false;
|
||||
|
||||
type BlocksEditorProps = {
|
||||
initialValue: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
initialValue,
|
||||
onChange,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const handleChange = useCallback(
|
||||
(editorState: EditorState, _editor: LexicalEditor) => {
|
||||
@@ -60,56 +57,46 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'BlocksEditor',
|
||||
theme: BlocksEditorTheme,
|
||||
onError: (error: Error) => console.error(error),
|
||||
editorState:
|
||||
initialValue && initialValue.length > 0 ? initialValue : undefined,
|
||||
nodes: BlockEditorNodes,
|
||||
}}>
|
||||
<>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div id="blocks-editor" className="editor-scroller">
|
||||
<div className="editor" ref={onRef}>
|
||||
<ContentEditable
|
||||
className={`ContentEditable__root ${className}`}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{children}
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div id="blocks-editor" className="editor-scroller">
|
||||
<div className="editor" ref={onRef}>
|
||||
<ContentEditable
|
||||
className={`ContentEditable__root ${className}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placeholder=""
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ListPlugin />
|
||||
<MarkdownShortcutPlugin
|
||||
transformers={[
|
||||
CHECK_LIST,
|
||||
...ELEMENT_TRANSFORMERS,
|
||||
...TEXT_FORMAT_TRANSFORMERS,
|
||||
...TEXT_MATCH_TRANSFORMERS,
|
||||
]}
|
||||
/>
|
||||
<TablePlugin />
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
<HistoryPlugin />
|
||||
<HorizontalRulePlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ComponentPickerMenuPlugin />
|
||||
<ClearEditorPlugin />
|
||||
<CheckListPlugin />
|
||||
<LinkPlugin />
|
||||
<HashtagPlugin />
|
||||
<AutoEmbedPlugin />
|
||||
<TwitterPlugin />
|
||||
<YouTubePlugin />
|
||||
<CollapsiblePlugin />
|
||||
{floatingAnchorElem && (
|
||||
<>{/* <DraggableBlockPlugin anchorElem={floatingAnchorElem} /> */}</>
|
||||
)}
|
||||
</>
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
}
|
||||
placeholder=""
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ListPlugin />
|
||||
<MarkdownShortcutPlugin
|
||||
transformers={[
|
||||
CHECK_LIST,
|
||||
...ELEMENT_TRANSFORMERS,
|
||||
...TEXT_FORMAT_TRANSFORMERS,
|
||||
...TEXT_MATCH_TRANSFORMERS,
|
||||
]}
|
||||
/>
|
||||
<TablePlugin />
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
<HistoryPlugin />
|
||||
<HorizontalRulePlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ClearEditorPlugin />
|
||||
<CheckListPlugin />
|
||||
<LinkPlugin />
|
||||
<HashtagPlugin />
|
||||
<AutoEmbedPlugin />
|
||||
<TwitterPlugin />
|
||||
<YouTubePlugin />
|
||||
<CollapsiblePlugin />
|
||||
{floatingAnchorElem && BlockDragEnabled && (
|
||||
<>{<DraggableBlockPlugin anchorElem={floatingAnchorElem} />}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
29
packages/blocks-editor/src/Editor/BlocksEditorComposer.tsx
Normal file
29
packages/blocks-editor/src/Editor/BlocksEditorComposer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {FunctionComponent} from 'react';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import BlocksEditorTheme from '../Lexical/Theme/Theme';
|
||||
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
|
||||
import {Klass, LexicalNode} from 'lexical';
|
||||
|
||||
type BlocksEditorComposerProps = {
|
||||
initialValue: string;
|
||||
children: React.ReactNode;
|
||||
nodes: Array<Klass<LexicalNode>>;
|
||||
};
|
||||
|
||||
export const BlocksEditorComposer: FunctionComponent<
|
||||
BlocksEditorComposerProps
|
||||
> = ({initialValue, children, nodes}) => {
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: 'BlocksEditor',
|
||||
theme: BlocksEditorTheme,
|
||||
onError: (error: Error) => console.error(error),
|
||||
editorState:
|
||||
initialValue && initialValue.length > 0 ? initialValue : undefined,
|
||||
nodes: [...nodes, ...BlockEditorNodes],
|
||||
}}>
|
||||
<>{children}</>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
19
packages/blocks-editor/src/Editor/ClassNames.ts
Normal file
19
packages/blocks-editor/src/Editor/ClassNames.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const classNames = (...values: (string | boolean | undefined)[]): string => {
|
||||
return values
|
||||
.map((value) => (typeof value === 'string' ? value : null))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const PopoverClassNames = classNames(
|
||||
'typeahead-popover file-picker-menu absolute z-dropdown-menu flex w-full min-w-80',
|
||||
'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto md:max-w-xs h-auto overflow-y-scroll',
|
||||
);
|
||||
|
||||
export const PopoverItemClassNames = classNames(
|
||||
'flex w-full items-center text-base gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground',
|
||||
'focus:bg-info-backdrop cursor-pointer m-0 focus:bg-contrast focus:text-foreground',
|
||||
);
|
||||
|
||||
export const PopoverItemSelectedClassNames = classNames(
|
||||
'bg-contrast text-foreground',
|
||||
);
|
||||
5
packages/blocks-editor/src/Editor/Commands.ts
Normal file
5
packages/blocks-editor/src/Editor/Commands.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {createCommand, LexicalCommand} from 'lexical';
|
||||
|
||||
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand(
|
||||
'INSERT_FILE_COMMAND',
|
||||
);
|
||||
@@ -31,6 +31,7 @@ interface PlaygroundEmbedConfig extends EmbedConfig {
|
||||
|
||||
// Icon for display.
|
||||
icon?: JSX.Element;
|
||||
iconName: string;
|
||||
|
||||
// An example of a matching url https://twitter.com/jack/status/20
|
||||
exampleUrl: string;
|
||||
@@ -49,6 +50,7 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon youtube" />,
|
||||
iconName: 'youtube',
|
||||
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
|
||||
@@ -84,6 +86,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon tweet" />,
|
||||
iconName: 'tweet',
|
||||
|
||||
// Create the Lexical embed node from the url data.
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {$createCodeNode} from '@lexical/code';
|
||||
import {
|
||||
INSERT_CHECK_LIST_COMMAND,
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
} from '@lexical/list';
|
||||
import {INSERT_EMBED_COMMAND} from '@lexical/react/LexicalAutoEmbedPlugin';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {INSERT_HORIZONTAL_RULE_COMMAND} from '@lexical/react/LexicalHorizontalRuleNode';
|
||||
import {
|
||||
LexicalTypeaheadMenuPlugin,
|
||||
TypeaheadOption,
|
||||
useBasicTypeaheadTriggerMatch,
|
||||
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||||
import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
|
||||
import {$wrapNodes} from '@lexical/selection';
|
||||
import {INSERT_TABLE_COMMAND} from '@lexical/table';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import useModal from '../../Hooks/useModal';
|
||||
import {EmbedConfigs} from '../AutoEmbedPlugin';
|
||||
import {INSERT_COLLAPSIBLE_COMMAND} from '../CollapsiblePlugin';
|
||||
import {InsertTableDialog} from '../TablePlugin';
|
||||
|
||||
class ComponentPickerOption extends TypeaheadOption {
|
||||
// What shows up in the editor
|
||||
title: string;
|
||||
// Icon for display
|
||||
icon?: JSX.Element;
|
||||
// For extra searching.
|
||||
keywords: Array<string>;
|
||||
// TBD
|
||||
keyboardShortcut?: string;
|
||||
// What happens when you select this option?
|
||||
onSelect: (queryString: string) => void;
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
options: {
|
||||
icon?: JSX.Element;
|
||||
keywords?: Array<string>;
|
||||
keyboardShortcut?: string;
|
||||
onSelect: (queryString: string) => void;
|
||||
},
|
||||
) {
|
||||
super(title);
|
||||
this.title = title;
|
||||
this.keywords = options.keywords || [];
|
||||
this.icon = options.icon;
|
||||
this.keyboardShortcut = options.keyboardShortcut;
|
||||
this.onSelect = options.onSelect.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
function ComponentPickerMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
option: ComponentPickerOption;
|
||||
}) {
|
||||
let className = 'item';
|
||||
if (isSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}>
|
||||
{option.icon}
|
||||
<span className="text">{option.title}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentPickerMenuPlugin(): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [modal, showModal] = useModal();
|
||||
const [queryString, setQueryString] = useState<string | null>(null);
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
});
|
||||
|
||||
const getDynamicOptions = useCallback(() => {
|
||||
const options: Array<ComponentPickerOption> = [];
|
||||
|
||||
if (queryString == null) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const fullTableRegex = new RegExp(/^([1-9]|10)x([1-9]|10)$/);
|
||||
const partialTableRegex = new RegExp(/^([1-9]|10)x?$/);
|
||||
|
||||
const fullTableMatch = fullTableRegex.exec(queryString);
|
||||
const partialTableMatch = partialTableRegex.exec(queryString);
|
||||
|
||||
if (fullTableMatch) {
|
||||
const [rows, columns] = fullTableMatch[0]
|
||||
.split('x')
|
||||
.map((n: string) => parseInt(n, 10));
|
||||
|
||||
options.push(
|
||||
new ComponentPickerOption(`${rows}x${columns} Table`, {
|
||||
icon: <i className="icon table" />,
|
||||
keywords: ['table'],
|
||||
onSelect: () =>
|
||||
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
|
||||
editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}),
|
||||
}),
|
||||
);
|
||||
} else if (partialTableMatch) {
|
||||
const rows = parseInt(partialTableMatch[0], 10);
|
||||
|
||||
options.push(
|
||||
...Array.from({length: 5}, (_, i) => i + 1).map(
|
||||
(columns) =>
|
||||
new ComponentPickerOption(`${rows}x${columns} Table`, {
|
||||
icon: <i className="icon table" />,
|
||||
keywords: ['table'],
|
||||
onSelect: () =>
|
||||
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
|
||||
editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [editor, queryString]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const baseOptions = [
|
||||
new ComponentPickerOption('Paragraph', {
|
||||
icon: <i className="icon paragraph" />,
|
||||
keywords: ['normal', 'paragraph', 'p', 'text'],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createParagraphNode());
|
||||
}
|
||||
}),
|
||||
}),
|
||||
...Array.from({length: 3}, (_, i) => i + 1).map(
|
||||
(n) =>
|
||||
new ComponentPickerOption(`Heading ${n}`, {
|
||||
icon: <i className={`icon h${n}`} />,
|
||||
keywords: ['heading', 'header', `h${n}`],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () =>
|
||||
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
|
||||
$createHeadingNode(`h${n}`),
|
||||
);
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
new ComponentPickerOption('Table', {
|
||||
icon: <i className="icon table" />,
|
||||
keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
|
||||
onSelect: () =>
|
||||
showModal('Insert Table', (onClose) => (
|
||||
<InsertTableDialog activeEditor={editor} onClose={onClose} />
|
||||
)),
|
||||
}),
|
||||
new ComponentPickerOption('Numbered List', {
|
||||
icon: <i className="icon number" />,
|
||||
keywords: ['numbered list', 'ordered list', 'ol'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
||||
}),
|
||||
new ComponentPickerOption('Bulleted List', {
|
||||
icon: <i className="icon bullet" />,
|
||||
keywords: ['bulleted list', 'unordered list', 'ul'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
||||
}),
|
||||
new ComponentPickerOption('Check List', {
|
||||
icon: <i className="icon check" />,
|
||||
keywords: ['check list', 'todo list'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
|
||||
}),
|
||||
new ComponentPickerOption('Quote', {
|
||||
icon: <i className="icon quote" />,
|
||||
keywords: ['block quote'],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createQuoteNode());
|
||||
}
|
||||
}),
|
||||
}),
|
||||
new ComponentPickerOption('Code', {
|
||||
icon: <i className="icon code" />,
|
||||
keywords: ['javascript', 'python', 'js', 'codeblock'],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
if (selection.isCollapsed()) {
|
||||
$wrapNodes(selection, () => $createCodeNode());
|
||||
} else {
|
||||
// Will this ever happen?
|
||||
const textContent = selection.getTextContent();
|
||||
const codeNode = $createCodeNode();
|
||||
selection.insertNodes([codeNode]);
|
||||
selection.insertRawText(textContent);
|
||||
}
|
||||
}
|
||||
}),
|
||||
}),
|
||||
new ComponentPickerOption('Divider', {
|
||||
icon: <i className="icon horizontal-rule" />,
|
||||
keywords: ['horizontal rule', 'divider', 'hr'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
|
||||
}),
|
||||
...EmbedConfigs.map(
|
||||
(embedConfig) =>
|
||||
new ComponentPickerOption(`Embed ${embedConfig.contentName}`, {
|
||||
icon: embedConfig.icon,
|
||||
keywords: [...embedConfig.keywords, 'embed'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
|
||||
}),
|
||||
),
|
||||
|
||||
new ComponentPickerOption('Collapsible', {
|
||||
icon: <i className="icon caret-right" />,
|
||||
keywords: ['collapse', 'collapsible', 'toggle'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
|
||||
}),
|
||||
...['left', 'center', 'right', 'justify'].map(
|
||||
(alignment) =>
|
||||
new ComponentPickerOption(`Align ${alignment}`, {
|
||||
icon: <i className={`icon ${alignment}-align`} />,
|
||||
keywords: ['align', 'justify', alignment],
|
||||
onSelect: () =>
|
||||
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const dynamicOptions = getDynamicOptions();
|
||||
|
||||
return queryString
|
||||
? [
|
||||
...dynamicOptions,
|
||||
...baseOptions.filter((option) => {
|
||||
return new RegExp(queryString, 'gi').exec(option.title) ||
|
||||
option.keywords != null
|
||||
? option.keywords.some((keyword) =>
|
||||
new RegExp(queryString, 'gi').exec(keyword),
|
||||
)
|
||||
: false;
|
||||
}),
|
||||
]
|
||||
: baseOptions;
|
||||
}, [editor, getDynamicOptions, queryString, showModal]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: ComponentPickerOption,
|
||||
nodeToRemove: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
matchingString: string,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove();
|
||||
}
|
||||
selectedOption.onSelect(matchingString);
|
||||
closeMenu();
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<LexicalTypeaheadMenuPlugin<ComponentPickerOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={options}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
|
||||
) =>
|
||||
anchorElementRef.current && options.length
|
||||
? ReactDOM.createPortal(
|
||||
<div className="typeahead-popover component-picker-menu">
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<ComponentPickerMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i);
|
||||
selectOptionAndCleanUp(option);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i);
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
.typeahead-popover {
|
||||
background: #fff;
|
||||
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.typeahead-popover ul {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.typeahead-popover ul::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typeahead-popover ul {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.typeahead-popover ul li {
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.typeahead-popover ul li.selected {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.typeahead-popover li {
|
||||
margin: 0 8px 0 8px;
|
||||
padding: 8px;
|
||||
color: #050505;
|
||||
cursor: pointer;
|
||||
line-height: 16px;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.typeahead-popover li.active {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.typeahead-popover li:first-child {
|
||||
border-radius: 8px 8px 0px 0px;
|
||||
}
|
||||
|
||||
.typeahead-popover li:last-child {
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
}
|
||||
|
||||
.typeahead-popover li:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.typeahead-popover li .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
flex-grow: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.typeahead-popover li .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.component-picker-menu {
|
||||
width: 200px;
|
||||
}
|
||||
@@ -21,13 +21,13 @@
|
||||
}
|
||||
.Lexical__h1 {
|
||||
font-size: 24px;
|
||||
color: rgb(5, 5, 5);
|
||||
color: var(--sn-stylekit-editor-foreground-color);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
.Lexical__h2 {
|
||||
font-size: 15px;
|
||||
color: rgb(101, 103, 107);
|
||||
color: var(--sn-stylekit-editor-foreground-color);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import 'base';
|
||||
@import 'component_picker';
|
||||
@import 'custom';
|
||||
@import 'editor';
|
||||
@import 'icons';
|
||||
@@ -1 +1,4 @@
|
||||
export * from './Editor/BlocksEditor';
|
||||
export * from './Editor/BlocksEditorComposer';
|
||||
export * from './Editor/Commands';
|
||||
export * from './Editor/ClassNames';
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "../web/src/javascripts/Components/BlockEditor/EncryptedFileNode.tsx"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { IconsController } from './IconsController'
|
||||
|
||||
describe('IconsController', () => {
|
||||
let iconsController: IconsController
|
||||
|
||||
beforeEach(() => {
|
||||
iconsController = new IconsController()
|
||||
})
|
||||
|
||||
describe('getIconForFileType', () => {
|
||||
it('should return correct icon type for supported mimetypes', () => {
|
||||
const iconTypeForPdf = iconsController.getIconForFileType('application/pdf')
|
||||
expect(iconTypeForPdf).toBe('file-pdf')
|
||||
|
||||
const iconTypeForDoc = iconsController.getIconForFileType('application/msword')
|
||||
const iconTypeForDocx = iconsController.getIconForFileType(
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
)
|
||||
expect(iconTypeForDoc).toBe('file-doc')
|
||||
expect(iconTypeForDocx).toBe('file-doc')
|
||||
|
||||
const iconTypeForPpt = iconsController.getIconForFileType('application/vnd.ms-powerpoint')
|
||||
const iconTypeForPptx = iconsController.getIconForFileType(
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
)
|
||||
expect(iconTypeForPpt).toBe('file-ppt')
|
||||
expect(iconTypeForPptx).toBe('file-ppt')
|
||||
|
||||
const iconTypeForXls = iconsController.getIconForFileType('application/vnd.ms-excel')
|
||||
const iconTypeForXlsx = iconsController.getIconForFileType(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.spreadsheet',
|
||||
)
|
||||
expect(iconTypeForXls).toBe('file-xls')
|
||||
expect(iconTypeForXlsx).toBe('file-xls')
|
||||
|
||||
const iconTypeForJpg = iconsController.getIconForFileType('image/jpeg')
|
||||
const iconTypeForPng = iconsController.getIconForFileType('image/png')
|
||||
expect(iconTypeForJpg).toBe('file-image')
|
||||
expect(iconTypeForPng).toBe('file-image')
|
||||
|
||||
const iconTypeForMpeg = iconsController.getIconForFileType('video/mpeg')
|
||||
const iconTypeForMp4 = iconsController.getIconForFileType('video/mp4')
|
||||
expect(iconTypeForMpeg).toBe('file-mov')
|
||||
expect(iconTypeForMp4).toBe('file-mov')
|
||||
|
||||
const iconTypeForWav = iconsController.getIconForFileType('audio/wav')
|
||||
const iconTypeForMp3 = iconsController.getIconForFileType('audio/mp3')
|
||||
expect(iconTypeForWav).toBe('file-music')
|
||||
expect(iconTypeForMp3).toBe('file-music')
|
||||
|
||||
const iconTypeForZip = iconsController.getIconForFileType('application/zip')
|
||||
const iconTypeForRar = iconsController.getIconForFileType('application/vnd.rar')
|
||||
const iconTypeForTar = iconsController.getIconForFileType('application/x-tar')
|
||||
const iconTypeFor7z = iconsController.getIconForFileType('application/x-7z-compressed')
|
||||
expect(iconTypeForZip).toBe('file-zip')
|
||||
expect(iconTypeForRar).toBe('file-zip')
|
||||
expect(iconTypeForTar).toBe('file-zip')
|
||||
expect(iconTypeFor7z).toBe('file-zip')
|
||||
})
|
||||
|
||||
it('should return fallback icon type for unsupported mimetypes', () => {
|
||||
const iconForBin = iconsController.getIconForFileType('application/octet-stream')
|
||||
expect(iconForBin).toBe('file-other')
|
||||
|
||||
const iconForNoType = iconsController.getIconForFileType('')
|
||||
expect(iconForNoType).toBe('file-other')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NoteType } from '@standardnotes/features'
|
||||
import { IconType } from '@standardnotes/models'
|
||||
|
||||
export class IconsController {
|
||||
getIconForFileType(type: string): IconType {
|
||||
let iconType: IconType = 'file-other'
|
||||
|
||||
if (type === 'application/pdf') {
|
||||
iconType = 'file-pdf'
|
||||
}
|
||||
|
||||
if (/word/.test(type)) {
|
||||
iconType = 'file-doc'
|
||||
}
|
||||
|
||||
if (/powerpoint|presentation/.test(type)) {
|
||||
iconType = 'file-ppt'
|
||||
}
|
||||
|
||||
if (/excel|spreadsheet/.test(type)) {
|
||||
iconType = 'file-xls'
|
||||
}
|
||||
|
||||
if (/^image\//.test(type)) {
|
||||
iconType = 'file-image'
|
||||
}
|
||||
|
||||
if (/^video\//.test(type)) {
|
||||
iconType = 'file-mov'
|
||||
}
|
||||
|
||||
if (/^audio\//.test(type)) {
|
||||
iconType = 'file-music'
|
||||
}
|
||||
|
||||
if (/(zip)|([tr]ar)|(7z)/.test(type)) {
|
||||
iconType = 'file-zip'
|
||||
}
|
||||
|
||||
return iconType
|
||||
}
|
||||
|
||||
getIconAndTintForNoteType(noteType?: NoteType): [IconType, number] {
|
||||
switch (noteType) {
|
||||
case NoteType.RichText:
|
||||
return ['rich-text', 1]
|
||||
case NoteType.Markdown:
|
||||
return ['markdown', 2]
|
||||
case NoteType.Authentication:
|
||||
return ['authenticator', 6]
|
||||
case NoteType.Spreadsheet:
|
||||
return ['spreadsheets', 5]
|
||||
case NoteType.Task:
|
||||
return ['tasks', 3]
|
||||
case NoteType.Code:
|
||||
return ['code', 4]
|
||||
default:
|
||||
return ['plain-text', 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './IconsController'
|
||||
export * from './NoteViewController'
|
||||
export * from './FileViewController'
|
||||
export * from './ItemGroupController'
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@babel/plugin-transform-react-jsx": "^7.19.0",
|
||||
"@babel/preset-env": "*",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@lexical/react": "^0.6.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
|
||||
"@reach/alert": "^0.17.0",
|
||||
"@reach/alert-dialog": "^0.17.0",
|
||||
@@ -84,6 +85,7 @@
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-environment-jsdom": "^29.2.1",
|
||||
"lexical": "0.6.0",
|
||||
"lint-staged": ">=12",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"minimatch": "^5.1.0",
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
SNApplication,
|
||||
ItemGroupController,
|
||||
removeFromArray,
|
||||
IconsController,
|
||||
DesktopDeviceInterface,
|
||||
isDesktopDevice,
|
||||
DeinitMode,
|
||||
@@ -47,7 +46,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
private webServices!: WebServices
|
||||
private webEventObservers: WebEventObserver[] = []
|
||||
public itemControllerGroup: ItemGroupController
|
||||
public iconsController: IconsController
|
||||
private onVisibilityChange: () => void
|
||||
private mobileWebReceiver?: MobileWebReceiver
|
||||
private androidBackHandler?: AndroidBackHandler
|
||||
@@ -81,7 +79,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
const internalEventBus = new InternalEventBus()
|
||||
|
||||
this.itemControllerGroup = new ItemGroupController(this)
|
||||
this.iconsController = new IconsController()
|
||||
this.routeService = new RouteService(this, internalEventBus)
|
||||
|
||||
const viewControllerManager = new ViewControllerManager(this, deviceInterface)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactNode, createContext, useContext, memo } from 'react'
|
||||
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
|
||||
const ApplicationContext = createContext<WebApplication | undefined>(undefined)
|
||||
|
||||
export const useApplication = () => {
|
||||
const value = useContext(ApplicationContext)
|
||||
|
||||
if (!value) {
|
||||
throw new Error('Component must be a child of <ApplicationProvider />')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
type ChildrenProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type ProviderProps = {
|
||||
application: WebApplication
|
||||
} & ChildrenProps
|
||||
|
||||
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
|
||||
|
||||
const ApplicationProvider = ({ application, children }: ProviderProps) => {
|
||||
return (
|
||||
<ApplicationContext.Provider value={application}>
|
||||
<MemoizedChildren children={children} />
|
||||
</ApplicationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ApplicationProvider)
|
||||
@@ -28,6 +28,7 @@ import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider'
|
||||
import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler'
|
||||
import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal'
|
||||
import DarkModeHandler from '../DarkModeHandler/DarkModeHandler'
|
||||
import ApplicationProvider from './ApplicationProvider'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -193,80 +194,85 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
||||
}
|
||||
|
||||
return (
|
||||
<AndroidBackHandlerProvider application={application}>
|
||||
<DarkModeHandler application={application} />
|
||||
<ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}>
|
||||
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
|
||||
<div className={platformString + ' main-ui-view sn-component h-full'}>
|
||||
<div id="app" className="app app-column-container" ref={appColumnContainerRef}>
|
||||
<FileDragNDropProvider
|
||||
application={application}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
filesController={viewControllerManager.filesController}
|
||||
>
|
||||
<Navigation application={application} />
|
||||
<ContentListView
|
||||
<ApplicationProvider application={application}>
|
||||
<AndroidBackHandlerProvider application={application}>
|
||||
<DarkModeHandler application={application} />
|
||||
<ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}>
|
||||
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
|
||||
<div className={platformString + ' main-ui-view sn-component h-full'}>
|
||||
<div id="app" className="app app-column-container" ref={appColumnContainerRef}>
|
||||
<FileDragNDropProvider
|
||||
application={application}
|
||||
accountMenuController={viewControllerManager.accountMenuController}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
filesController={viewControllerManager.filesController}
|
||||
itemListController={viewControllerManager.itemListController}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
noAccountWarningController={viewControllerManager.noAccountWarningController}
|
||||
>
|
||||
<Navigation application={application} />
|
||||
<ContentListView
|
||||
application={application}
|
||||
accountMenuController={viewControllerManager.accountMenuController}
|
||||
filesController={viewControllerManager.filesController}
|
||||
itemListController={viewControllerManager.itemListController}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
noAccountWarningController={viewControllerManager.noAccountWarningController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
searchOptionsController={viewControllerManager.searchOptionsController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
/>
|
||||
<NoteGroupView application={application} />
|
||||
</FileDragNDropProvider>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Footer application={application} applicationGroup={mainApplicationGroup} />
|
||||
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
|
||||
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
|
||||
<RevisionHistoryModal
|
||||
application={application}
|
||||
historyModalController={viewControllerManager.historyModalController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
searchOptionsController={viewControllerManager.searchOptionsController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
subscriptionController={viewControllerManager.subscriptionController}
|
||||
/>
|
||||
<NoteGroupView application={application} />
|
||||
</FileDragNDropProvider>
|
||||
</>
|
||||
|
||||
{renderChallenges()}
|
||||
|
||||
<>
|
||||
<NotesContextMenu
|
||||
application={application}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
historyModalController={viewControllerManager.historyModalController}
|
||||
/>
|
||||
<TagContextMenuWrapper
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
/>
|
||||
<FileContextMenuWrapper
|
||||
filesController={viewControllerManager.filesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
/>
|
||||
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<ConfirmSignoutContainer
|
||||
applicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
/>
|
||||
<ToastContainer />
|
||||
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<PermissionsModalWrapper application={application} />
|
||||
<ConfirmDeleteAccountContainer
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Footer application={application} applicationGroup={mainApplicationGroup} />
|
||||
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
|
||||
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
|
||||
<RevisionHistoryModal
|
||||
application={application}
|
||||
historyModalController={viewControllerManager.historyModalController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
subscriptionController={viewControllerManager.subscriptionController}
|
||||
/>
|
||||
</>
|
||||
|
||||
{renderChallenges()}
|
||||
|
||||
<>
|
||||
<NotesContextMenu
|
||||
application={application}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
historyModalController={viewControllerManager.historyModalController}
|
||||
/>
|
||||
<TagContextMenuWrapper
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
/>
|
||||
<FileContextMenuWrapper
|
||||
filesController={viewControllerManager.filesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
/>
|
||||
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<ConfirmSignoutContainer
|
||||
applicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
/>
|
||||
<ToastContainer />
|
||||
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<PermissionsModalWrapper application={application} />
|
||||
<ConfirmDeleteAccountContainer application={application} viewControllerManager={viewControllerManager} />
|
||||
</>
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
</ResponsivePaneProvider>
|
||||
</AndroidBackHandlerProvider>
|
||||
</PremiumModalProvider>
|
||||
</ResponsivePaneProvider>
|
||||
</AndroidBackHandlerProvider>
|
||||
</ApplicationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { WebApplication } from '@/Application/Application'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useCallback, useRef } from 'react'
|
||||
import { BlockEditorController } from './BlockEditorController'
|
||||
import { BlocksEditor } from '@standardnotes/blocks-editor'
|
||||
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
|
||||
import { ItemSelectionPlugin } from './Plugins/ItemSelectionPlugin/ItemSelectionPlugin'
|
||||
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
||||
import FilePlugin from './Plugins/EncryptedFilePlugin/FilePlugin'
|
||||
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
||||
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
|
||||
|
||||
const StringEllipses = '...'
|
||||
@@ -30,11 +34,16 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application }) =>
|
||||
return (
|
||||
<div className="relative h-full w-full p-5">
|
||||
<ErrorBoundary>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
initialValue={note.content.text}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
/>
|
||||
<BlocksEditorComposer initialValue={note.text} nodes={[FileNode]}>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note} />
|
||||
<FilePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '@standardnotes/blocks-editor'
|
||||
import { BlockPickerOption } from './BlockPickerOption'
|
||||
|
||||
export function BlockPickerMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: BlockPickerOption
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={`${PopoverItemClassNames} ${isSelected ? PopoverItemSelectedClassNames : ''}`}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<i className={`icon ${option.iconName} mr-[8px] flex h-5 w-5 bg-contain fill-current text-center`} />
|
||||
<div className="">{option.title}</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
|
||||
export class BlockPickerOption extends TypeaheadOption {
|
||||
// What shows up in the editor
|
||||
title: string
|
||||
// Icon for display
|
||||
iconName?: string
|
||||
// For extra searching.
|
||||
keywords: Array<string>
|
||||
// TBD
|
||||
keyboardShortcut?: string
|
||||
// What happens when you select this option?
|
||||
onSelect: (queryString: string) => void
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
options: {
|
||||
iconName?: string
|
||||
keywords?: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
},
|
||||
) {
|
||||
super(title)
|
||||
this.title = title
|
||||
this.keywords = options.keywords || []
|
||||
this.iconName = options.iconName
|
||||
this.keyboardShortcut = options.keyboardShortcut
|
||||
this.onSelect = options.onSelect.bind(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { TextNode } from 'lexical'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { PopoverClassNames } from '@standardnotes/blocks-editor'
|
||||
import useModal from '@standardnotes/blocks-editor/src/Lexical/Hooks/useModal'
|
||||
import { InsertTableDialog } from '@standardnotes/blocks-editor/src/Lexical/Plugins/TablePlugin'
|
||||
import { BlockPickerOption } from './BlockPickerOption'
|
||||
import { BlockPickerMenuItem } from './BlockPickerMenuItem'
|
||||
import { GetNumberedListBlock } from './Blocks/NumberedList'
|
||||
import { GetBulletedListBlock } from './Blocks/BulletedList'
|
||||
import { GetChecklistBlock } from './Blocks/Checklist'
|
||||
import { GetDividerBlock } from './Blocks/Divider'
|
||||
import { GetCollapsibleBlock } from './Blocks/Collapsible'
|
||||
import { GetParagraphBlock } from './Blocks/Paragraph'
|
||||
import { GetHeadingsBlocks } from './Blocks/Headings'
|
||||
import { GetQuoteBlock } from './Blocks/Quote'
|
||||
import { GetAlignmentBlocks } from './Blocks/Alignment'
|
||||
import { GetCodeBlock } from './Blocks/Code'
|
||||
import { GetEmbedsBlocks } from './Blocks/Embeds'
|
||||
import { GetDynamicTableBlocks, GetTableBlock } from './Blocks/Table'
|
||||
import Popover from '@/Components/Popover/Popover'
|
||||
|
||||
export default function BlockPickerMenuPlugin(): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [modal, showModal] = useModal()
|
||||
const [queryString, setQueryString] = useState<string | null>(null)
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = useState(true)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const baseOptions = [
|
||||
GetParagraphBlock(editor),
|
||||
...GetHeadingsBlocks(editor),
|
||||
GetTableBlock(() =>
|
||||
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
|
||||
),
|
||||
GetNumberedListBlock(editor),
|
||||
GetBulletedListBlock(editor),
|
||||
GetChecklistBlock(editor),
|
||||
GetQuoteBlock(editor),
|
||||
GetCodeBlock(editor),
|
||||
GetDividerBlock(editor),
|
||||
...GetAlignmentBlocks(editor),
|
||||
GetCollapsibleBlock(editor),
|
||||
...GetEmbedsBlocks(editor),
|
||||
]
|
||||
|
||||
const dynamicOptions = GetDynamicTableBlocks(editor, queryString || '')
|
||||
|
||||
return queryString
|
||||
? [
|
||||
...dynamicOptions,
|
||||
...baseOptions.filter((option) => {
|
||||
return new RegExp(queryString, 'gi').exec(option.title) || option.keywords != null
|
||||
? option.keywords.some((keyword) => new RegExp(queryString, 'gi').exec(keyword))
|
||||
: false
|
||||
}),
|
||||
]
|
||||
: baseOptions
|
||||
}, [editor, queryString, showModal])
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: BlockPickerOption,
|
||||
nodeToRemove: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
matchingString: string,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove()
|
||||
}
|
||||
selectedOption.onSelect(matchingString)
|
||||
setPopoverOpen(false)
|
||||
closeMenu()
|
||||
})
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<LexicalTypeaheadMenuPlugin<BlockPickerOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={options}
|
||||
onClose={() => {
|
||||
setPopoverOpen(false)
|
||||
}}
|
||||
onOpen={() => {
|
||||
setPopoverOpen(true)
|
||||
}}
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
if (!anchorElementRef.current || !options.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
align="start"
|
||||
anchorPoint={{
|
||||
x: anchorElementRef.current.offsetLeft,
|
||||
y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight,
|
||||
}}
|
||||
className={'min-h-80 h-80'}
|
||||
open={popoverOpen}
|
||||
togglePopover={() => {
|
||||
setPopoverOpen((prevValue) => !prevValue)
|
||||
}}
|
||||
>
|
||||
<div className={PopoverClassNames}>
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<BlockPickerMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i)
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { FORMAT_ELEMENT_COMMAND, LexicalEditor, ElementFormatType } from 'lexical'
|
||||
|
||||
export function GetAlignmentBlocks(editor: LexicalEditor) {
|
||||
return ['left', 'center', 'right', 'justify'].map(
|
||||
(alignment) =>
|
||||
new BlockPickerOption(`Align ${alignment}`, {
|
||||
iconName: `${alignment}-align`,
|
||||
keywords: ['align', 'justify', alignment],
|
||||
onSelect: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment as ElementFormatType),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
|
||||
|
||||
export function GetBulletedListBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Bulleted List', {
|
||||
iconName: 'bullet',
|
||||
keywords: ['bulleted list', 'unordered list', 'ul'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_CHECK_LIST_COMMAND } from '@lexical/list'
|
||||
|
||||
export function GetChecklistBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Check List', {
|
||||
iconName: 'check',
|
||||
keywords: ['check list', 'todo list'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { $createCodeNode } from '@lexical/code'
|
||||
|
||||
export function GetCodeBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Code', {
|
||||
iconName: 'code',
|
||||
keywords: ['javascript', 'python', 'js', 'codeblock'],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
if (selection.isCollapsed()) {
|
||||
$wrapNodes(selection, () => $createCodeNode())
|
||||
} else {
|
||||
const textContent = selection.getTextContent()
|
||||
const codeNode = $createCodeNode()
|
||||
selection.insertNodes([codeNode])
|
||||
selection.insertRawText(textContent)
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_COLLAPSIBLE_COMMAND } from '@standardnotes/blocks-editor/src/Lexical/Plugins/CollapsiblePlugin'
|
||||
|
||||
export function GetCollapsibleBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Collapsible', {
|
||||
iconName: 'caret-right',
|
||||
keywords: ['collapse', 'collapsible', 'toggle'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
|
||||
export function GetDividerBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Divider', {
|
||||
iconName: 'horizontal-rule',
|
||||
keywords: ['horizontal rule', 'divider', 'hr'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPlugin'
|
||||
import { EmbedConfigs } from '@standardnotes/blocks-editor/src/Lexical/Plugins/AutoEmbedPlugin'
|
||||
|
||||
export function GetEmbedsBlocks(editor: LexicalEditor) {
|
||||
return EmbedConfigs.map(
|
||||
(embedConfig) =>
|
||||
new BlockPickerOption(`Embed ${embedConfig.contentName}`, {
|
||||
iconName: embedConfig.iconName,
|
||||
keywords: [...embedConfig.keywords, 'embed'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { $createHeadingNode, HeadingTagType } from '@lexical/rich-text'
|
||||
|
||||
export function GetHeadingsBlocks(editor: LexicalEditor) {
|
||||
return Array.from({ length: 3 }, (_, i) => i + 1).map(
|
||||
(n) =>
|
||||
new BlockPickerOption(`Heading ${n}`, {
|
||||
iconName: `icon h${n}`,
|
||||
keywords: ['heading', 'header', `h${n}`],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createHeadingNode(`h${n}` as HeadingTagType))
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
|
||||
|
||||
export function GetNumberedListBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Numbered List', {
|
||||
iconName: 'number',
|
||||
keywords: ['numbered list', 'ordered list', 'ol'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $createParagraphNode, $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
|
||||
export function GetParagraphBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Paragraph', {
|
||||
iconName: 'paragraph',
|
||||
keywords: ['normal', 'paragraph', 'p', 'text'],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createParagraphNode())
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { $createQuoteNode } from '@lexical/rich-text'
|
||||
|
||||
export function GetQuoteBlock(editor: LexicalEditor) {
|
||||
return new BlockPickerOption('Quote', {
|
||||
iconName: 'quote',
|
||||
keywords: ['block quote'],
|
||||
onSelect: () =>
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createQuoteNode())
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BlockPickerOption } from '../BlockPickerOption'
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_TABLE_COMMAND } from '@lexical/table'
|
||||
|
||||
export function GetTableBlock(onSelect: () => void) {
|
||||
return new BlockPickerOption('Table', {
|
||||
iconName: 'table',
|
||||
keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
|
||||
onSelect,
|
||||
})
|
||||
}
|
||||
|
||||
export function GetDynamicTableBlocks(editor: LexicalEditor, queryString: string) {
|
||||
const options: Array<BlockPickerOption> = []
|
||||
|
||||
if (queryString == null) {
|
||||
return options
|
||||
}
|
||||
|
||||
const fullTableRegex = new RegExp(/^([1-9]|10)x([1-9]|10)$/)
|
||||
const partialTableRegex = new RegExp(/^([1-9]|10)x?$/)
|
||||
|
||||
const fullTableMatch = fullTableRegex.exec(queryString)
|
||||
const partialTableMatch = partialTableRegex.exec(queryString)
|
||||
|
||||
if (fullTableMatch) {
|
||||
const [rows, columns] = fullTableMatch[0].split('x').map((n: string) => parseInt(n, 10))
|
||||
|
||||
options.push(
|
||||
new BlockPickerOption(`${rows}x${columns} Table`, {
|
||||
iconName: 'table',
|
||||
keywords: ['table'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
|
||||
}),
|
||||
)
|
||||
} else if (partialTableMatch) {
|
||||
const rows = parseInt(partialTableMatch[0], 10)
|
||||
|
||||
options.push(
|
||||
...Array.from({ length: 5 }, (_, i) => i + 1).map(
|
||||
(columns) =>
|
||||
new BlockPickerOption(`${rows}x${columns} Table`, {
|
||||
iconName: 'table',
|
||||
keywords: ['table'],
|
||||
onSelect: () =>
|
||||
editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { FileNode } from './Nodes/FileNode'
|
||||
import { $createFileNode } from './Nodes/FileUtils'
|
||||
import { INSERT_FILE_COMMAND } from '@standardnotes/blocks-editor'
|
||||
|
||||
export default function FilePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([FileNode])) {
|
||||
throw new Error('FilePlugin: FileNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_FILE_COMMAND,
|
||||
(payload) => {
|
||||
const fileNode = $createFileNode(payload)
|
||||
$insertNodeToNearestRoot(fileNode)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { useMemo } from 'react'
|
||||
import { ElementFormatType, NodeKey } from 'lexical'
|
||||
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
|
||||
import FilePreview from '@/Components/FilePreview/FilePreview'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
|
||||
export type FileComponentProps = Readonly<{
|
||||
className: Readonly<{
|
||||
base: string
|
||||
focus: string
|
||||
}>
|
||||
format: ElementFormatType | null
|
||||
nodeKey: NodeKey
|
||||
fileUuid: string
|
||||
}>
|
||||
|
||||
export function FileComponent({ className, format, nodeKey, fileUuid }: FileComponentProps) {
|
||||
const application = useApplication()
|
||||
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
|
||||
|
||||
if (!file) {
|
||||
return <div>Unable to find file {fileUuid}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
<FilePreview file={file} application={application} />
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DOMConversionMap, DOMExportOutput, EditorConfig, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
|
||||
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
import { $createFileNode, convertToFileElement } from './FileUtils'
|
||||
import { FileComponent } from './FileComponent'
|
||||
import { SerializedFileNode } from './SerializedFileNode'
|
||||
|
||||
export class FileNode extends DecoratorBlockNode {
|
||||
__id: string
|
||||
|
||||
static getType(): string {
|
||||
return 'snfile'
|
||||
}
|
||||
|
||||
static clone(node: FileNode): FileNode {
|
||||
return new FileNode(node.__id, node.__format, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedFileNode): FileNode {
|
||||
const node = $createFileNode(serializedNode.fileUuid)
|
||||
node.setFormat(serializedNode.format)
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedFileNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
fileUuid: this.getId(),
|
||||
version: 1,
|
||||
type: 'snfile',
|
||||
}
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
|
||||
return {
|
||||
div: (domNode: HTMLDivElement) => {
|
||||
if (!domNode.hasAttribute('data-lexical-file-uuid')) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: convertToFileElement,
|
||||
priority: 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute('data-lexical-file-uuid', this.__id)
|
||||
const text = document.createTextNode(this.getTextContent())
|
||||
element.append(text)
|
||||
return { element }
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
super(format, key)
|
||||
this.__id = id
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.__id
|
||||
}
|
||||
|
||||
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
|
||||
return `[File: ${this.__id}]`
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
}
|
||||
|
||||
return <FileComponent className={className} format={this.__format} nodeKey={this.getKey()} fileUuid={this.__id} />
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DOMConversionOutput, LexicalNode } from 'lexical'
|
||||
|
||||
import { FileNode } from './FileNode'
|
||||
|
||||
export function convertToFileElement(domNode: HTMLDivElement): DOMConversionOutput | null {
|
||||
const fileUuid = domNode.getAttribute('data-lexical-file-uuid')
|
||||
if (fileUuid) {
|
||||
const node = $createFileNode(fileUuid)
|
||||
return { node }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function $createFileNode(fileUuid: string): FileNode {
|
||||
return new FileNode(fileUuid)
|
||||
}
|
||||
|
||||
export function $isFileNode(node: FileNode | LexicalNode | null | undefined): node is FileNode {
|
||||
return node instanceof FileNode
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Spread } from 'lexical'
|
||||
import { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
|
||||
export type SerializedFileNode = Spread<
|
||||
{
|
||||
fileUuid: string
|
||||
version: 1
|
||||
type: 'snfile'
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
|
||||
export class ItemOption extends TypeaheadOption {
|
||||
icon?: JSX.Element
|
||||
|
||||
constructor(
|
||||
public item: FileItem,
|
||||
public options: {
|
||||
keywords?: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
},
|
||||
) {
|
||||
super(item.title)
|
||||
this.key = item.uuid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '@standardnotes/blocks-editor'
|
||||
import { ItemOption } from './ItemOption'
|
||||
|
||||
type Props = {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: ItemOption
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export function ItemSelectionItemComponent({ index, isSelected, onClick, onMouseEnter, option, searchQuery }: Props) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={`${PopoverItemClassNames} ${isSelected ? PopoverItemSelectedClassNames : ''}`}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<LinkedItemMeta item={option.item} searchQuery={searchQuery} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { INSERT_FILE_COMMAND, PopoverClassNames } from '@standardnotes/blocks-editor'
|
||||
import { TextNode } from 'lexical'
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
|
||||
import { ItemSelectionItemComponent } from './ItemSelectionItemComponent'
|
||||
import { ItemOption } from './ItemOption'
|
||||
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
|
||||
import { ContentType, FileItem, SNNote } from '@standardnotes/snjs'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
import Popover from '@/Components/Popover/Popover'
|
||||
|
||||
type Props = {
|
||||
currentNote: SNNote
|
||||
}
|
||||
|
||||
export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>('')
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', {
|
||||
minLength: 0,
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = useState(true)
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(selectedOption: ItemOption, nodeToRemove: TextNode | null, closeMenu: () => void, matchingString: string) => {
|
||||
editor.update(() => {
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove()
|
||||
}
|
||||
selectedOption.options.onSelect(matchingString)
|
||||
setPopoverOpen(false)
|
||||
closeMenu()
|
||||
})
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const results = getLinkingSearchResults(queryString || '', application, currentNote, {
|
||||
contentType: ContentType.File,
|
||||
returnEmptyIfQueryEmpty: false,
|
||||
})
|
||||
const files = [...results.linkedItems, ...results.unlinkedItems] as FileItem[]
|
||||
return files.map((file) => {
|
||||
return new ItemOption(file, {
|
||||
onSelect: (_queryString: string) => {
|
||||
editor.dispatchCommand(INSERT_FILE_COMMAND, file.uuid)
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [application, editor, currentNote, queryString])
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<ItemOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={options}
|
||||
onClose={() => {
|
||||
setPopoverOpen(false)
|
||||
}}
|
||||
onOpen={() => {
|
||||
setPopoverOpen(true)
|
||||
}}
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
if (!anchorElementRef.current || !options.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
align="start"
|
||||
anchorPoint={{
|
||||
x: anchorElementRef.current.offsetLeft,
|
||||
y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight,
|
||||
}}
|
||||
className={'min-h-80 h-80'}
|
||||
open={popoverOpen}
|
||||
togglePopover={() => {
|
||||
setPopoverOpen((prevValue) => !prevValue)
|
||||
}}
|
||||
>
|
||||
<div className={PopoverClassNames}>
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<ItemSelectionItemComponent
|
||||
searchQuery={queryString || ''}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i)
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { FunctionComponent, useCallback, useRef, useState } from 'react'
|
||||
import ChangeEditorMenu from './ChangeEditorMenu'
|
||||
import Popover from '../Popover/Popover'
|
||||
import RoundIconButton from '../Button/RoundIconButton'
|
||||
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -24,9 +25,7 @@ const ChangeEditorButton: FunctionComponent<Props> = ({
|
||||
const [selectedEditor, setSelectedEditor] = useState(() => {
|
||||
return note ? application.componentManager.editorForNote(note) : undefined
|
||||
})
|
||||
const [selectedEditorIcon, selectedEditorIconTint] = application.iconsController.getIconAndTintForNoteType(
|
||||
selectedEditor?.package_info.note_type,
|
||||
)
|
||||
const [selectedEditorIcon, selectedEditorIconTint] = getIconAndTintForNoteType(selectedEditor?.package_info.note_type)
|
||||
|
||||
const toggleMenu = useCallback(async () => {
|
||||
const willMenuOpen = !isOpen
|
||||
|
||||
@@ -11,9 +11,9 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||
import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
|
||||
|
||||
const FileListItem: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
|
||||
application,
|
||||
filesController,
|
||||
hideDate,
|
||||
hideIcon,
|
||||
@@ -66,10 +66,7 @@ const FileListItem: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
|
||||
}, [item, onSelect, toggleAppPane])
|
||||
|
||||
const IconComponent = () =>
|
||||
getFileIconComponent(
|
||||
application.iconsController.getIconForFileType((item as FileItem).mimeType),
|
||||
'w-10 h-10 flex-shrink-0',
|
||||
)
|
||||
getFileIconComponent(getIconForFileType((item as FileItem).mimeType), 'w-10 h-10 flex-shrink-0')
|
||||
|
||||
useContextMenuEvent(listItemRef, openContextMenu)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import ListItemNotePreviewText from './ListItemNotePreviewText'
|
||||
import { ListItemTitle } from './ListItemTitle'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
|
||||
|
||||
const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
||||
application,
|
||||
@@ -37,7 +38,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
||||
|
||||
const editorForNote = application.componentManager.editorForNote(item as SNNote)
|
||||
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
|
||||
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
|
||||
const [icon, tint] = getIconAndTintForNoteType(editorForNote?.package_info.note_type)
|
||||
const hasFiles = application.items.itemsReferencingItem(item).filter(isFile).length > 0
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import FilePreview from './FilePreview'
|
||||
import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -67,12 +68,8 @@ const FilePreviewModal: FunctionComponent<Props> = observer(({ application, view
|
||||
)
|
||||
|
||||
const IconComponent = useMemo(
|
||||
() =>
|
||||
getFileIconComponent(
|
||||
application.iconsController.getIconForFileType(currentFile.mimeType),
|
||||
'w-6 h-6 flex-shrink-0',
|
||||
),
|
||||
[application.iconsController, currentFile.mimeType],
|
||||
() => getFileIconComponent(getIconForFileType(currentFile.mimeType), 'w-6 h-6 flex-shrink-0'),
|
||||
[currentFile.mimeType],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,8 @@ import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import Menu from '../Menu/Menu'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
|
||||
type Props = {
|
||||
linkingController: LinkingController
|
||||
@@ -27,18 +29,11 @@ type Props = {
|
||||
}
|
||||
|
||||
const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focusedId, setFocusedId }: Props) => {
|
||||
const {
|
||||
tags,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon,
|
||||
getSearchResults,
|
||||
linkItemToSelectedItem,
|
||||
createAndAddNewTag,
|
||||
isEntitledToNoteLinking,
|
||||
} = linkingController
|
||||
const application = useApplication()
|
||||
const { tags, linkItemToSelectedItem, createAndAddNewTag, isEntitledToNoteLinking, activeItem } = linkingController
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const { unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery)
|
||||
const { unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, activeItem)
|
||||
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false)
|
||||
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
|
||||
@@ -105,7 +100,7 @@ const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focus
|
||||
}
|
||||
}, [focusedId])
|
||||
|
||||
const areSearchResultsVisible = dropdownVisible && (unlinkedResults.length > 0 || shouldShowCreateTag)
|
||||
const areSearchResultsVisible = dropdownVisible && (unlinkedItems.length > 0 || shouldShowCreateTag)
|
||||
|
||||
const handleMenuKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback((event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
@@ -155,10 +150,8 @@ const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focus
|
||||
>
|
||||
<LinkedItemSearchResults
|
||||
createAndAddNewTag={createAndAddNewTag}
|
||||
getLinkedItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
linkItemToSelectedItem={linkItemToSelectedItem}
|
||||
results={unlinkedResults}
|
||||
results={unlinkedItems}
|
||||
searchQuery={searchQuery}
|
||||
shouldShowCreateTag={shouldShowCreateTag}
|
||||
onClickCallback={() => setSearchQuery('')}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
|
||||
import { ContentType } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag'
|
||||
|
||||
type Props = {
|
||||
link: ItemLink
|
||||
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
activateItem: (item: LinkableItem) => Promise<void>
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
focusPreviousItem: () => void
|
||||
@@ -21,8 +24,6 @@ type Props = {
|
||||
|
||||
const LinkedItemBubble = ({
|
||||
link,
|
||||
getItemIcon,
|
||||
getTitleForLinkedTag,
|
||||
activateItem,
|
||||
unlinkItem,
|
||||
focusPreviousItem,
|
||||
@@ -32,6 +33,7 @@ const LinkedItemBubble = ({
|
||||
isBidirectional,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const application = useApplication()
|
||||
|
||||
const [showUnlinkButton, setShowUnlinkButton] = useState(false)
|
||||
const unlinkButtonRef = useRef<HTMLAnchorElement | null>(null)
|
||||
@@ -80,8 +82,8 @@ const LinkedItemBubble = ({
|
||||
}
|
||||
}
|
||||
|
||||
const [icon, iconClassName] = getItemIcon(link.item)
|
||||
const tagTitle = getTitleForLinkedTag(link.item)
|
||||
const [icon, iconClassName] = getIconForItem(link.item, application)
|
||||
const tagTitle = getTitleForLinkedTag(link.item, application)
|
||||
|
||||
useEffect(() => {
|
||||
if (link.id === focusedId) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
|
||||
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import LinkedItemBubble from './LinkedItemBubble'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { ContentType } from '@standardnotes/snjs'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
|
||||
|
||||
type Props = {
|
||||
linkingController: LinkingController
|
||||
@@ -19,8 +21,6 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
notesLinkingToActiveItem,
|
||||
filesLinkingToActiveItem,
|
||||
unlinkItemFromSelectedItem: unlinkItem,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon: getItemIcon,
|
||||
activateItem,
|
||||
} = linkingController
|
||||
|
||||
@@ -86,8 +86,6 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
<LinkedItemBubble
|
||||
link={link}
|
||||
key={link.id}
|
||||
getItemIcon={getItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
activateItem={activateItemAndTogglePane}
|
||||
unlinkItem={unlinkItem}
|
||||
focusPreviousItem={focusPreviousItem}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { splitQueryInString } from '@/Utils'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag'
|
||||
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
const LinkedItemMeta = ({
|
||||
item,
|
||||
getItemIcon,
|
||||
getTitleForLinkedTag,
|
||||
searchQuery,
|
||||
}: {
|
||||
type Props = {
|
||||
item: LinkableItem
|
||||
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
searchQuery?: string
|
||||
}) => {
|
||||
const [icon, className] = getItemIcon(item)
|
||||
const tagTitle = getTitleForLinkedTag(item)
|
||||
}
|
||||
|
||||
const LinkedItemMeta = ({ item, searchQuery }: Props) => {
|
||||
const application = useApplication()
|
||||
const [icon, className] = getIconForItem(item, application)
|
||||
const tagTitle = getTitleForLinkedTag(item, application)
|
||||
const title = item.title ?? ''
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||
import LinkedItemMeta from './LinkedItemMeta'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
|
||||
type Props = {
|
||||
createAndAddNewTag: LinkingController['createAndAddNewTag']
|
||||
getLinkedItemIcon: LinkingController['getLinkedItemIcon']
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
linkItemToSelectedItem: LinkingController['linkItemToSelectedItem']
|
||||
results: LinkableItem[]
|
||||
searchQuery: string
|
||||
@@ -20,8 +19,6 @@ type Props = {
|
||||
|
||||
const LinkedItemSearchResults = ({
|
||||
createAndAddNewTag,
|
||||
getLinkedItemIcon,
|
||||
getTitleForLinkedTag,
|
||||
linkItemToSelectedItem,
|
||||
results,
|
||||
searchQuery,
|
||||
@@ -48,12 +45,7 @@ const LinkedItemSearchResults = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LinkedItemMeta
|
||||
item={result}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
<LinkedItemMeta item={result} searchQuery={searchQuery} />
|
||||
{cannotLinkItem && <Icon type={PremiumFeatureIconName} className="ml-auto flex-shrink-0 text-info" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ChangeEventHandler, useEffect, useRef, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import ClearInputButton from '../ClearInputButton/ClearInputButton'
|
||||
import Icon from '../Icon/Icon'
|
||||
@@ -22,29 +26,26 @@ import LinkedItemSearchResults from './LinkedItemSearchResults'
|
||||
|
||||
const LinkedItemsSectionItem = ({
|
||||
activateItem,
|
||||
getItemIcon,
|
||||
getTitleForLinkedTag,
|
||||
item,
|
||||
searchQuery,
|
||||
unlinkItem,
|
||||
handleFileAction,
|
||||
}: {
|
||||
activateItem: LinkingController['activateItem']
|
||||
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
item: LinkableItem
|
||||
searchQuery?: string
|
||||
unlinkItem: () => void
|
||||
handleFileAction: FilesController['handleFileAction']
|
||||
}) => {
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const application = useApplication()
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const toggleMenu = () => setIsMenuOpen((open) => !open)
|
||||
|
||||
const [isRenamingFile, setIsRenamingFile] = useState(false)
|
||||
|
||||
const [icon, className] = getItemIcon(item)
|
||||
const [icon, className] = getIconForItem(item, application)
|
||||
const title = item.title ?? ''
|
||||
|
||||
const renameFile = async (name: string) => {
|
||||
@@ -93,12 +94,7 @@ const LinkedItemsSectionItem = ({
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
<LinkedItemMeta
|
||||
item={item}
|
||||
getItemIcon={getItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
<LinkedItemMeta item={item} searchQuery={searchQuery} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -174,23 +170,26 @@ const LinkedItemsPanel = ({
|
||||
notesLinkedToItem,
|
||||
notesLinkingToActiveItem,
|
||||
allItemLinks: allLinkedItems,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon,
|
||||
getSearchResults,
|
||||
linkItemToSelectedItem,
|
||||
unlinkItemFromSelectedItem,
|
||||
activateItem,
|
||||
createAndAddNewTag,
|
||||
isEntitledToNoteLinking,
|
||||
activeItem,
|
||||
} = linkingController
|
||||
|
||||
const { hasFiles } = featuresController
|
||||
const application = useApplication()
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const isSearching = !!searchQuery.length
|
||||
const { linkedResults, unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery)
|
||||
const { linkedResults, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(
|
||||
searchQuery,
|
||||
application,
|
||||
activeItem,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
@@ -227,7 +226,7 @@ const LinkedItemsPanel = ({
|
||||
<form
|
||||
className={classNames(
|
||||
'sticky top-0 z-10 bg-default px-2.5 pt-2.5',
|
||||
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToActiveItem.length
|
||||
allLinkedItems.length || linkedResults.length || unlinkedItems.length || notesLinkingToActiveItem.length
|
||||
? 'border-b border-border pb-2.5'
|
||||
: 'pb-1',
|
||||
)}
|
||||
@@ -254,15 +253,13 @@ const LinkedItemsPanel = ({
|
||||
<div className="divide-y divide-border">
|
||||
{isSearching ? (
|
||||
<>
|
||||
{(!!unlinkedResults.length || shouldShowCreateTag) && (
|
||||
{(!!unlinkedItems.length || shouldShowCreateTag) && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Unlinked</div>
|
||||
<LinkedItemSearchResults
|
||||
createAndAddNewTag={createAndAddNewTag}
|
||||
getLinkedItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
linkItemToSelectedItem={linkItemToSelectedItem}
|
||||
results={unlinkedResults}
|
||||
results={unlinkedItems}
|
||||
searchQuery={searchQuery}
|
||||
shouldShowCreateTag={shouldShowCreateTag}
|
||||
isEntitledToNoteLinking={isEntitledToNoteLinking}
|
||||
@@ -281,8 +278,6 @@ const LinkedItemsPanel = ({
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
@@ -303,8 +298,6 @@ const LinkedItemsPanel = ({
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
@@ -336,8 +329,6 @@ const LinkedItemsPanel = ({
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
@@ -357,8 +348,6 @@ const LinkedItemsPanel = ({
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
@@ -376,8 +365,6 @@ const LinkedItemsPanel = ({
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
@@ -397,8 +384,6 @@ const LinkedItemsPanel = ({
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
|
||||
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
|
||||
import { EditorEventSource } from '../../Types/EditorEventSource'
|
||||
import { BlockEditor } from '../BlockEditor/BlockEditor'
|
||||
import { BlockEditor } from '../BlockEditor/BlockEditorComponent'
|
||||
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
|
||||
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
|
||||
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
|
||||
|
||||
@@ -5,13 +5,13 @@ import { NavigationController } from '@/Controllers/Navigation/NavigationControl
|
||||
import { NotesController } from '@/Controllers/NotesController'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import Popover from '../Popover/Popover'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag'
|
||||
import { useApplication } from '../ApplicationView/ApplicationProvider'
|
||||
|
||||
type Props = {
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
linkingController: LinkingController
|
||||
className: string
|
||||
iconClassName: string
|
||||
}
|
||||
@@ -19,10 +19,10 @@ type Props = {
|
||||
const AddTagOption: FunctionComponent<Props> = ({
|
||||
navigationController,
|
||||
notesController,
|
||||
linkingController,
|
||||
className,
|
||||
iconClassName,
|
||||
}) => {
|
||||
const application = useApplication()
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -79,7 +79,7 @@ const AddTagOption: FunctionComponent<Props> = ({
|
||||
className={`overflow-hidden overflow-ellipsis whitespace-nowrap
|
||||
${notesController.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
|
||||
>
|
||||
{linkingController.getTitleForLinkedTag(tag)?.longTitle}
|
||||
{getTitleForLinkedTag(tag, application)?.longTitle}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -28,7 +28,10 @@ type DeletePermanentlyButtonProps = {
|
||||
|
||||
const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-menu-item"
|
||||
className={classNames(
|
||||
'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item',
|
||||
'text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-menu-item',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon type="close" className="mr-2 text-danger" />
|
||||
@@ -183,7 +186,6 @@ const NotesOptions = ({
|
||||
application,
|
||||
navigationController,
|
||||
notesController,
|
||||
linkingController,
|
||||
historyModalController,
|
||||
closeMenu,
|
||||
}: NotesOptionsProps) => {
|
||||
@@ -353,7 +355,6 @@ const NotesOptions = ({
|
||||
className={switchClassNames}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
linkingController={linkingController}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isSearchResultAlreadyLinkedToItem } from '@/Utils/Items/Search/isSearchResultAlreadyLinkedToItem'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { doesItemMatchSearchQuery } from '@/Utils/Items/Search/doesItemMatchSearchQuery'
|
||||
import {
|
||||
AnonymousReference,
|
||||
ContentReferenceType,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
FileToNoteReference,
|
||||
InternalEventBus,
|
||||
SNNote,
|
||||
ItemsClientInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FilesController } from './FilesController'
|
||||
import { ItemListController } from './ItemList/ItemListController'
|
||||
@@ -14,6 +17,7 @@ import { LinkingController } from './LinkingController'
|
||||
import { NavigationController } from './Navigation/NavigationController'
|
||||
import { SelectedItemsController } from './SelectedItemsController'
|
||||
import { SubscriptionController } from './Subscription/SubscriptionController'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
|
||||
const createNote = (name: string, options?: Partial<SNNote>) => {
|
||||
return {
|
||||
@@ -54,6 +58,8 @@ describe('LinkingController', () => {
|
||||
application.addSingleEventObserver = jest.fn()
|
||||
application.streamItems = jest.fn()
|
||||
|
||||
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemsClientInterface> })
|
||||
|
||||
navigationController = {} as jest.Mocked<NavigationController>
|
||||
|
||||
selectionController = {} as jest.Mocked<SelectedItemsController>
|
||||
@@ -74,7 +80,7 @@ describe('LinkingController', () => {
|
||||
|
||||
const file = createFile('anotherFile')
|
||||
|
||||
const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery)
|
||||
const isFileValidResult = doesItemMatchSearchQuery(file, searchQuery, application)
|
||||
|
||||
expect(isFileValidResult).toBeFalsy()
|
||||
})
|
||||
@@ -86,10 +92,10 @@ describe('LinkingController', () => {
|
||||
|
||||
const trashed = createFile('test', { trashed: true })
|
||||
|
||||
const isArchivedFileValidResult = linkingController.isValidSearchResult(archived, searchQuery)
|
||||
const isArchivedFileValidResult = doesItemMatchSearchQuery(archived, searchQuery, application)
|
||||
expect(isArchivedFileValidResult).toBeFalsy()
|
||||
|
||||
const isTrashedFileValidResult = linkingController.isValidSearchResult(trashed, searchQuery)
|
||||
const isTrashedFileValidResult = doesItemMatchSearchQuery(trashed, searchQuery, application)
|
||||
expect(isTrashedFileValidResult).toBeFalsy()
|
||||
})
|
||||
|
||||
@@ -98,12 +104,11 @@ describe('LinkingController', () => {
|
||||
|
||||
const activeItem = createFile('test', { uuid: 'same-uuid' })
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })
|
||||
application.items.getItems = jest.fn().mockReturnValue([activeItem])
|
||||
|
||||
const result = createFile('test', { uuid: 'same-uuid' })
|
||||
const results = getLinkingSearchResults(searchQuery, application, activeItem)
|
||||
|
||||
const isFileValidResult = linkingController.isValidSearchResult(result, searchQuery)
|
||||
expect(isFileValidResult).toBeFalsy()
|
||||
expect([...results.unlinkedItems, ...results.linkedItems]).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should be valid result if it matches query even case insensitive', () => {
|
||||
@@ -111,13 +116,15 @@ describe('LinkingController', () => {
|
||||
|
||||
const file = createFile('TeSt')
|
||||
|
||||
const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery)
|
||||
application.items.getItems = jest.fn().mockReturnValue([file])
|
||||
|
||||
const isFileValidResult = doesItemMatchSearchQuery(file, searchQuery, application)
|
||||
|
||||
expect(isFileValidResult).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSearchResultAlreadyLinked', () => {
|
||||
describe('isSearchResultAlreadyLinkedToItem', () => {
|
||||
it('should be true if active item & result are same content type & active item references result', () => {
|
||||
const activeItem = createFile('test', {
|
||||
uuid: 'active-item',
|
||||
@@ -130,9 +137,7 @@ describe('LinkingController', () => {
|
||||
})
|
||||
const result = createFile('test', { uuid: 'result', references: [] })
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })
|
||||
|
||||
const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
|
||||
const isFileAlreadyLinked = isSearchResultAlreadyLinkedToItem(result, activeItem)
|
||||
expect(isFileAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -151,9 +156,7 @@ describe('LinkingController', () => {
|
||||
],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })
|
||||
|
||||
const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
|
||||
const isFileAlreadyLinked = isSearchResultAlreadyLinkedToItem(result, activeItem)
|
||||
expect(isFileAlreadyLinked).toBeFalsy()
|
||||
})
|
||||
|
||||
@@ -173,9 +176,7 @@ describe('LinkingController', () => {
|
||||
],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeNote })
|
||||
|
||||
const isFileResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(fileResult)
|
||||
const isFileResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(fileResult, activeNote)
|
||||
expect(isFileResultAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -195,9 +196,7 @@ describe('LinkingController', () => {
|
||||
references: [],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile })
|
||||
|
||||
const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
|
||||
const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(noteResult, activeFile)
|
||||
expect(isNoteResultAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -212,9 +211,7 @@ describe('LinkingController', () => {
|
||||
references: [],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile })
|
||||
|
||||
const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
|
||||
const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(noteResult, activeFile)
|
||||
expect(isNoteResultAlreadyLinked).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,14 +2,14 @@ import { WebApplication } from '@/Application/Application'
|
||||
import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { AppPaneId } from '@/Components/ResponsivePane/AppPaneMetadata'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
|
||||
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
DecryptedItemInterface,
|
||||
FileItem,
|
||||
IconType,
|
||||
InternalEventBus,
|
||||
ItemContent,
|
||||
naturalSort,
|
||||
NoteViewController,
|
||||
PrefKey,
|
||||
@@ -26,14 +26,6 @@ import { NavigationController } from './Navigation/NavigationController'
|
||||
import { SelectedItemsController } from './SelectedItemsController'
|
||||
import { SubscriptionController } from './Subscription/SubscriptionController'
|
||||
|
||||
export type LinkableItem = DecryptedItemInterface<ItemContent>
|
||||
|
||||
export type ItemLink<ItemType extends LinkableItem = LinkableItem> = {
|
||||
id: string
|
||||
item: ItemType
|
||||
type: 'linked' | 'linked-by'
|
||||
}
|
||||
|
||||
export class LinkingController extends AbstractViewController {
|
||||
tags: ItemLink<SNTag>[] = []
|
||||
linkedFiles: ItemLink<FileItem>[] = []
|
||||
@@ -142,14 +134,6 @@ export class LinkingController extends AbstractViewController {
|
||||
this.reloadNotesLinkingToItem()
|
||||
}
|
||||
|
||||
createLinkFromItem = <I extends LinkableItem = LinkableItem>(itemA: I, type: 'linked' | 'linked-by'): ItemLink<I> => {
|
||||
return {
|
||||
id: `${itemA.uuid}-${type}`,
|
||||
item: itemA,
|
||||
type,
|
||||
}
|
||||
}
|
||||
|
||||
reloadLinkedFiles() {
|
||||
if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) {
|
||||
this.linkedFiles = []
|
||||
@@ -168,11 +152,11 @@ export class LinkingController extends AbstractViewController {
|
||||
)
|
||||
|
||||
if (this.activeItem.content_type === ContentType.File) {
|
||||
this.linkedFiles = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked'))
|
||||
this.filesLinkingToActiveItem = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by'))
|
||||
this.linkedFiles = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked'))
|
||||
this.filesLinkingToActiveItem = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked-by'))
|
||||
} else {
|
||||
this.linkedFiles = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked'))
|
||||
this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by'))
|
||||
this.linkedFiles = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked'))
|
||||
this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked-by'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +167,7 @@ export class LinkingController extends AbstractViewController {
|
||||
|
||||
this.tags = this.application.items
|
||||
.getSortedTagsForItem(this.activeItem)
|
||||
.map((item) => this.createLinkFromItem(item, 'linked'))
|
||||
.map((item) => createLinkFromItem(item, 'linked'))
|
||||
}
|
||||
|
||||
reloadLinkedNotes() {
|
||||
@@ -195,7 +179,7 @@ export class LinkingController extends AbstractViewController {
|
||||
this.notesLinkedToItem = naturalSort(
|
||||
this.application.items.referencesForItem(this.activeItem).filter(isNote),
|
||||
'title',
|
||||
).map((item) => this.createLinkFromItem(item, 'linked'))
|
||||
).map((item) => createLinkFromItem(item, 'linked'))
|
||||
}
|
||||
|
||||
reloadNotesLinkingToItem() {
|
||||
@@ -207,40 +191,7 @@ export class LinkingController extends AbstractViewController {
|
||||
this.notesLinkingToActiveItem = naturalSort(
|
||||
this.application.items.itemsReferencingItem(this.activeItem).filter(isNote),
|
||||
'title',
|
||||
).map((item) => this.createLinkFromItem(item, 'linked-by'))
|
||||
}
|
||||
|
||||
getTitleForLinkedTag = (item: LinkableItem) => {
|
||||
const isTag = item instanceof SNTag
|
||||
|
||||
if (!isTag) {
|
||||
return
|
||||
}
|
||||
|
||||
const titlePrefix = this.application.items.getTagPrefixTitle(item)
|
||||
const longTitle = this.application.items.getTagLongTitle(item)
|
||||
return {
|
||||
titlePrefix,
|
||||
longTitle,
|
||||
}
|
||||
}
|
||||
|
||||
getLinkedItemIcon = (item: LinkableItem): [IconType, string] => {
|
||||
if (item instanceof SNNote) {
|
||||
const editorForNote = this.application.componentManager.editorForNote(item)
|
||||
const [icon, tint] = this.application.iconsController.getIconAndTintForNoteType(
|
||||
editorForNote?.package_info.note_type,
|
||||
)
|
||||
const className = `text-accessory-tint-${tint}`
|
||||
return [icon, className]
|
||||
} else if (item instanceof FileItem) {
|
||||
const icon = this.application.iconsController.getIconForFileType(item.mimeType)
|
||||
return [icon, 'text-info']
|
||||
} else if (item instanceof SNTag) {
|
||||
return [item.iconString as IconType, 'text-info']
|
||||
}
|
||||
|
||||
throw new Error('Unhandled case in getLinkedItemIcon')
|
||||
).map((item) => createLinkFromItem(item, 'linked-by'))
|
||||
}
|
||||
|
||||
activateItem = async (item: LinkableItem): Promise<AppPaneId | undefined> => {
|
||||
@@ -341,110 +292,4 @@ export class LinkingController extends AbstractViewController {
|
||||
this.reloadLinkedTags()
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
isValidSearchResult = (item: LinkableItem, searchQuery: string) => {
|
||||
const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title ?? ''
|
||||
|
||||
const matchesQuery = title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const isActiveItem = this.activeItem?.uuid === item.uuid
|
||||
const isArchivedOrTrashed = item.archived || item.trashed
|
||||
|
||||
const isValidSearchResult = matchesQuery && !isActiveItem && !isArchivedOrTrashed
|
||||
|
||||
return isValidSearchResult
|
||||
}
|
||||
|
||||
isSearchResultAlreadyLinked = (item: LinkableItem) => {
|
||||
if (!this.activeItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
let isAlreadyLinked = false
|
||||
|
||||
const isItemReferencedByActiveItem = this.activeItem.references.some((ref) => ref.uuid === item.uuid)
|
||||
const isActiveItemReferencedByItem = item.references.some((ref) => ref.uuid === this.activeItem?.uuid)
|
||||
|
||||
if (this.activeItem.content_type === item.content_type) {
|
||||
isAlreadyLinked = isItemReferencedByActiveItem
|
||||
} else {
|
||||
isAlreadyLinked = isActiveItemReferencedByItem || isItemReferencedByActiveItem
|
||||
}
|
||||
|
||||
return isAlreadyLinked
|
||||
}
|
||||
|
||||
isSearchResultExistingTag = (result: DecryptedItemInterface<ItemContent>, searchQuery: string) =>
|
||||
result.content_type === ContentType.Tag && result.title === searchQuery
|
||||
|
||||
getSearchResults = (searchQuery: string) => {
|
||||
let unlinkedResults: LinkableItem[] = []
|
||||
const linkedResults: ItemLink<LinkableItem>[] = []
|
||||
let shouldShowCreateTag = false
|
||||
|
||||
const defaultReturnValue = {
|
||||
linkedResults,
|
||||
unlinkedResults,
|
||||
shouldShowCreateTag,
|
||||
}
|
||||
|
||||
if (!searchQuery.length) {
|
||||
return defaultReturnValue
|
||||
}
|
||||
|
||||
if (!this.activeItem) {
|
||||
return defaultReturnValue
|
||||
}
|
||||
|
||||
const searchableItems = naturalSort(
|
||||
this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]),
|
||||
'title',
|
||||
)
|
||||
|
||||
const unlinkedTags: LinkableItem[] = []
|
||||
const unlinkedNotes: LinkableItem[] = []
|
||||
const unlinkedFiles: LinkableItem[] = []
|
||||
|
||||
for (let index = 0; index < searchableItems.length; index++) {
|
||||
const item = searchableItems[index]
|
||||
|
||||
if (!this.isValidSearchResult(item, searchQuery)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.isSearchResultAlreadyLinked(item)) {
|
||||
if (linkedResults.length < 20) {
|
||||
linkedResults.push(this.createLinkFromItem(item, 'linked'))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (unlinkedTags.length < 5 && item.content_type === ContentType.Tag) {
|
||||
unlinkedTags.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (unlinkedNotes.length < 5 && item.content_type === ContentType.Note) {
|
||||
unlinkedNotes.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (unlinkedFiles.length < 5 && item.content_type === ContentType.File) {
|
||||
unlinkedFiles.push(item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
unlinkedResults = unlinkedTags.concat(unlinkedNotes).concat(unlinkedFiles)
|
||||
|
||||
shouldShowCreateTag =
|
||||
!linkedResults.find((link) => this.isSearchResultExistingTag(link.item, searchQuery)) &&
|
||||
!unlinkedResults.find((item) => this.isSearchResultExistingTag(item, searchQuery))
|
||||
|
||||
return {
|
||||
unlinkedResults,
|
||||
linkedResults,
|
||||
shouldShowCreateTag,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
|
||||
import { getIconAndTintForNoteType } from './Items/Icons/getIconAndTintForNoteType'
|
||||
|
||||
export const PlainEditorType = 'plain-editor'
|
||||
export const BlocksType = 'blocks-editor'
|
||||
@@ -21,7 +22,7 @@ export function getDropdownItemsForAllEditors(application: WebApplication) {
|
||||
|
||||
const options = application.componentManager.componentsForArea(ComponentArea.Editor).map((editor): EditorOption => {
|
||||
const identifier = editor.package_info.identifier
|
||||
const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type)
|
||||
const [iconType, tint] = getIconAndTintForNoteType(editor.package_info.note_type)
|
||||
|
||||
return {
|
||||
label: editor.displayName,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DecryptedItem, SNTag, WebApplicationInterface } from '@standardnotes/snjs'
|
||||
|
||||
type ReturnType =
|
||||
| {
|
||||
titlePrefix: string | undefined
|
||||
longTitle: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
export function getTitleForLinkedTag(item: DecryptedItem, application: WebApplicationInterface): ReturnType {
|
||||
const isTag = item instanceof SNTag
|
||||
|
||||
if (!isTag) {
|
||||
return
|
||||
}
|
||||
|
||||
const titlePrefix = application.items.getTagPrefixTitle(item)
|
||||
const longTitle = application.items.getTagLongTitle(item)
|
||||
return {
|
||||
titlePrefix,
|
||||
longTitle,
|
||||
}
|
||||
}
|
||||
63
packages/web/src/javascripts/Utils/Items/Icons/Icons.spec.ts
Normal file
63
packages/web/src/javascripts/Utils/Items/Icons/Icons.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getIconForFileType } from "./getIconForFileType"
|
||||
|
||||
describe('icons utils', () => {
|
||||
describe('getIconForFileType', () => {
|
||||
it('should return correct icon type for supported mimetypes', () => {
|
||||
const iconTypeForPdf = getIconForFileType('application/pdf')
|
||||
expect(iconTypeForPdf).toBe('file-pdf')
|
||||
|
||||
const iconTypeForDoc = getIconForFileType('application/msword')
|
||||
const iconTypeForDocx = getIconForFileType(
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
)
|
||||
expect(iconTypeForDoc).toBe('file-doc')
|
||||
expect(iconTypeForDocx).toBe('file-doc')
|
||||
|
||||
const iconTypeForPpt = getIconForFileType('application/vnd.ms-powerpoint')
|
||||
const iconTypeForPptx = getIconForFileType(
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
)
|
||||
expect(iconTypeForPpt).toBe('file-ppt')
|
||||
expect(iconTypeForPptx).toBe('file-ppt')
|
||||
|
||||
const iconTypeForXls = getIconForFileType('application/vnd.ms-excel')
|
||||
const iconTypeForXlsx = getIconForFileType(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.spreadsheet',
|
||||
)
|
||||
expect(iconTypeForXls).toBe('file-xls')
|
||||
expect(iconTypeForXlsx).toBe('file-xls')
|
||||
|
||||
const iconTypeForJpg = getIconForFileType('image/jpeg')
|
||||
const iconTypeForPng = getIconForFileType('image/png')
|
||||
expect(iconTypeForJpg).toBe('file-image')
|
||||
expect(iconTypeForPng).toBe('file-image')
|
||||
|
||||
const iconTypeForMpeg = getIconForFileType('video/mpeg')
|
||||
const iconTypeForMp4 = getIconForFileType('video/mp4')
|
||||
expect(iconTypeForMpeg).toBe('file-mov')
|
||||
expect(iconTypeForMp4).toBe('file-mov')
|
||||
|
||||
const iconTypeForWav = getIconForFileType('audio/wav')
|
||||
const iconTypeForMp3 = getIconForFileType('audio/mp3')
|
||||
expect(iconTypeForWav).toBe('file-music')
|
||||
expect(iconTypeForMp3).toBe('file-music')
|
||||
|
||||
const iconTypeForZip = getIconForFileType('application/zip')
|
||||
const iconTypeForRar = getIconForFileType('application/vnd.rar')
|
||||
const iconTypeForTar = getIconForFileType('application/x-tar')
|
||||
const iconTypeFor7z = getIconForFileType('application/x-7z-compressed')
|
||||
expect(iconTypeForZip).toBe('file-zip')
|
||||
expect(iconTypeForRar).toBe('file-zip')
|
||||
expect(iconTypeForTar).toBe('file-zip')
|
||||
expect(iconTypeFor7z).toBe('file-zip')
|
||||
})
|
||||
|
||||
it('should return fallback icon type for unsupported mimetypes', () => {
|
||||
const iconForBin = getIconForFileType('application/octet-stream')
|
||||
expect(iconForBin).toBe('file-other')
|
||||
|
||||
const iconForNoType = getIconForFileType('')
|
||||
expect(iconForNoType).toBe('file-other')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NoteType } from '@standardnotes/features'
|
||||
import { IconType } from '@standardnotes/models'
|
||||
|
||||
export function getIconAndTintForNoteType(noteType?: NoteType): [IconType, number] {
|
||||
switch (noteType) {
|
||||
case NoteType.RichText:
|
||||
return ['rich-text', 1]
|
||||
case NoteType.Markdown:
|
||||
return ['markdown', 2]
|
||||
case NoteType.Authentication:
|
||||
return ['authenticator', 6]
|
||||
case NoteType.Spreadsheet:
|
||||
return ['spreadsheets', 5]
|
||||
case NoteType.Task:
|
||||
return ['tasks', 3]
|
||||
case NoteType.Code:
|
||||
return ['code', 4]
|
||||
default:
|
||||
return ['plain-text', 1]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IconType } from '@standardnotes/models'
|
||||
|
||||
export function getIconForFileType(type: string): IconType {
|
||||
let iconType: IconType = 'file-other'
|
||||
|
||||
if (type === 'application/pdf') {
|
||||
iconType = 'file-pdf'
|
||||
}
|
||||
|
||||
if (/word/.test(type)) {
|
||||
iconType = 'file-doc'
|
||||
}
|
||||
|
||||
if (/powerpoint|presentation/.test(type)) {
|
||||
iconType = 'file-ppt'
|
||||
}
|
||||
|
||||
if (/excel|spreadsheet/.test(type)) {
|
||||
iconType = 'file-xls'
|
||||
}
|
||||
|
||||
if (/^image\//.test(type)) {
|
||||
iconType = 'file-image'
|
||||
}
|
||||
|
||||
if (/^video\//.test(type)) {
|
||||
iconType = 'file-mov'
|
||||
}
|
||||
|
||||
if (/^audio\//.test(type)) {
|
||||
iconType = 'file-music'
|
||||
}
|
||||
|
||||
if (/(zip)|([tr]ar)|(7z)/.test(type)) {
|
||||
iconType = 'file-zip'
|
||||
}
|
||||
|
||||
return iconType
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IconType, FileItem, SNNote, DecryptedItem, SNTag, WebApplicationInterface } from '@standardnotes/snjs'
|
||||
import { getIconAndTintForNoteType } from './getIconAndTintForNoteType'
|
||||
import { getIconForFileType } from './getIconForFileType'
|
||||
|
||||
export function getIconForItem(item: DecryptedItem, application: WebApplicationInterface): [IconType, string] {
|
||||
if (item instanceof SNNote) {
|
||||
const editorForNote = application.componentManager.editorForNote(item)
|
||||
const [icon, tint] = getIconAndTintForNoteType(editorForNote?.package_info.note_type)
|
||||
const className = `text-accessory-tint-${tint}`
|
||||
return [icon, className]
|
||||
} else if (item instanceof FileItem) {
|
||||
const icon = getIconForFileType(item.mimeType)
|
||||
return [icon, 'text-info']
|
||||
} else if (item instanceof SNTag) {
|
||||
return [item.iconString as IconType, 'text-info']
|
||||
}
|
||||
|
||||
throw new Error('Unhandled case in getItemIcon')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { LinkableItem } from './LinkableItem'
|
||||
|
||||
export type ItemLink<ItemType extends LinkableItem = LinkableItem> = {
|
||||
id: string
|
||||
item: ItemType
|
||||
type: 'linked' | 'linked-by'
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DecryptedItemInterface, ItemContent } from '@standardnotes/snjs'
|
||||
|
||||
export type LinkableItem = DecryptedItemInterface<ItemContent>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ItemLink } from './ItemLink'
|
||||
import { LinkableItem } from './LinkableItem'
|
||||
|
||||
export function createLinkFromItem<I extends LinkableItem = LinkableItem>(
|
||||
itemA: I,
|
||||
type: 'linked' | 'linked-by',
|
||||
): ItemLink<I> {
|
||||
return {
|
||||
id: `${itemA.uuid}-${type}`,
|
||||
item: itemA,
|
||||
type,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SNTag, WebApplicationInterface, DecryptedItemInterface, ItemContent } from '@standardnotes/snjs'
|
||||
|
||||
export function doesItemMatchSearchQuery(
|
||||
item: DecryptedItemInterface<ItemContent>,
|
||||
searchQuery: string,
|
||||
application: WebApplicationInterface,
|
||||
) {
|
||||
const title = item instanceof SNTag ? application.items.getTagLongTitle(item) : item.title ?? ''
|
||||
const matchesQuery = title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const isArchivedOrTrashed = item.archived || item.trashed
|
||||
const isValidSearchResult = matchesQuery && !isArchivedOrTrashed
|
||||
|
||||
return isValidSearchResult
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { WebApplicationInterface, naturalSort, ContentType } from '@standardnotes/snjs'
|
||||
import { createLinkFromItem } from './createLinkFromItem'
|
||||
import { doesItemMatchSearchQuery } from './doesItemMatchSearchQuery'
|
||||
import { isSearchResultAlreadyLinkedToItem } from './isSearchResultAlreadyLinkedToItem'
|
||||
import { isSearchResultExistingTag } from './isSearchResultExistingTag'
|
||||
import { ItemLink } from './ItemLink'
|
||||
import { LinkableItem } from './LinkableItem'
|
||||
|
||||
const ResultLimitPerContentType = 5
|
||||
const MaxLinkedResults = 20
|
||||
|
||||
export function getLinkingSearchResults(
|
||||
searchQuery: string,
|
||||
application: WebApplicationInterface,
|
||||
activeItem?: LinkableItem,
|
||||
options: {
|
||||
contentType?: ContentType
|
||||
returnEmptyIfQueryEmpty?: boolean
|
||||
} = { returnEmptyIfQueryEmpty: true },
|
||||
): {
|
||||
linkedResults: ItemLink<LinkableItem>[]
|
||||
linkedItems: LinkableItem[]
|
||||
unlinkedItems: LinkableItem[]
|
||||
shouldShowCreateTag: boolean
|
||||
} {
|
||||
let unlinkedItems: LinkableItem[] = []
|
||||
const linkedItems: LinkableItem[] = []
|
||||
const linkedResults: ItemLink<LinkableItem>[] = []
|
||||
let shouldShowCreateTag = false
|
||||
|
||||
const defaultReturnValue = {
|
||||
linkedResults,
|
||||
unlinkedItems,
|
||||
linkedItems,
|
||||
shouldShowCreateTag,
|
||||
}
|
||||
|
||||
if (!activeItem) {
|
||||
return defaultReturnValue
|
||||
}
|
||||
|
||||
if (!searchQuery.length && options.returnEmptyIfQueryEmpty) {
|
||||
return defaultReturnValue
|
||||
}
|
||||
|
||||
const searchableItems = naturalSort(
|
||||
application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]),
|
||||
'title',
|
||||
)
|
||||
|
||||
const unlinkedTags: LinkableItem[] = []
|
||||
const unlinkedNotes: LinkableItem[] = []
|
||||
const unlinkedFiles: LinkableItem[] = []
|
||||
|
||||
for (let index = 0; index < searchableItems.length; index++) {
|
||||
const item = searchableItems[index]
|
||||
|
||||
if (activeItem.uuid === item.uuid) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (options.contentType && item.content_type !== options.contentType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (searchQuery.length && !doesItemMatchSearchQuery(item, searchQuery, application)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchResultAlreadyLinkedToItem(item, activeItem)) {
|
||||
if (linkedResults.length < MaxLinkedResults) {
|
||||
linkedResults.push(createLinkFromItem(item, 'linked'))
|
||||
}
|
||||
linkedItems.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const enforceResultLimit = options.contentType == null
|
||||
|
||||
if (
|
||||
item.content_type === ContentType.Tag &&
|
||||
(!enforceResultLimit ||
|
||||
(unlinkedTags.length < ResultLimitPerContentType && item.content_type === ContentType.Tag))
|
||||
) {
|
||||
unlinkedTags.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
item.content_type === ContentType.Note &&
|
||||
(!enforceResultLimit || unlinkedNotes.length < ResultLimitPerContentType)
|
||||
) {
|
||||
unlinkedNotes.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
item.content_type === ContentType.File &&
|
||||
(!enforceResultLimit || unlinkedFiles.length < ResultLimitPerContentType)
|
||||
) {
|
||||
unlinkedFiles.push(item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
unlinkedItems = [...unlinkedTags, ...unlinkedNotes, ...unlinkedFiles]
|
||||
|
||||
shouldShowCreateTag =
|
||||
!linkedResults.find((link) => isSearchResultExistingTag(link.item, searchQuery)) &&
|
||||
!unlinkedItems.find((item) => isSearchResultExistingTag(item, searchQuery))
|
||||
|
||||
return {
|
||||
linkedResults,
|
||||
linkedItems,
|
||||
unlinkedItems,
|
||||
shouldShowCreateTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LinkableItem } from './LinkableItem'
|
||||
|
||||
export function isSearchResultAlreadyLinkedToItem(searchResult: LinkableItem, item: LinkableItem): boolean {
|
||||
let isAlreadyLinked = false
|
||||
|
||||
const isItemReferencedByActiveItem = item.references.some((ref) => ref.uuid === searchResult.uuid)
|
||||
const isActiveItemReferencedByItem = searchResult.references.some((ref) => ref.uuid === item?.uuid)
|
||||
|
||||
if (item.content_type === searchResult.content_type) {
|
||||
isAlreadyLinked = isItemReferencedByActiveItem
|
||||
} else {
|
||||
isAlreadyLinked = isActiveItemReferencedByItem || isItemReferencedByActiveItem
|
||||
}
|
||||
|
||||
return isAlreadyLinked
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DecryptedItemInterface, ItemContent, ContentType } from '@standardnotes/snjs'
|
||||
|
||||
export function isSearchResultExistingTag(result: DecryptedItemInterface<ItemContent>, searchQuery: string) {
|
||||
return result.content_type === ContentType.Tag && result.title === searchQuery
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
const plugin = require('tailwindcss/plugin')
|
||||
|
||||
module.exports = {
|
||||
content: ['./src/javascripts/**/*.tsx', '../toast/src/**/*.tsx'],
|
||||
content: ['./src/javascripts/**/*.tsx', '../toast/src/**/*.tsx', '../blocks-editor/src/Editor/**/*'],
|
||||
theme: {
|
||||
extend: {
|
||||
spacing: {
|
||||
|
||||
36
yarn.lock
36
yarn.lock
@@ -5334,13 +5334,13 @@ __metadata:
|
||||
dependencies:
|
||||
"@lexical/react": ^0.6.0
|
||||
"@standardnotes/icons": "workspace:*"
|
||||
"@types/react": "link:../web/node_modules/@types/react"
|
||||
"@types/react-dom": "link:../web/node_modules/@types/react-dom"
|
||||
"@types/react": ^18.0.20
|
||||
"@types/react-dom": ^18.0.6
|
||||
eslint: "*"
|
||||
lexical: ^0.6.0
|
||||
prettier: "*"
|
||||
react: "link:../web/node_modules/react"
|
||||
react-dom: "link:../web/node_modules/react-dom"
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
typescript: "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -6109,6 +6109,7 @@ __metadata:
|
||||
"@babel/plugin-transform-react-jsx": ^7.19.0
|
||||
"@babel/preset-env": "*"
|
||||
"@babel/preset-typescript": ^7.18.6
|
||||
"@lexical/react": ^0.6.0
|
||||
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.7
|
||||
"@reach/alert": ^0.17.0
|
||||
"@reach/alert-dialog": ^0.17.0
|
||||
@@ -6165,6 +6166,7 @@ __metadata:
|
||||
identity-obj-proxy: ^3.0.0
|
||||
jest: ^29.2.1
|
||||
jest-environment-jsdom: ^29.2.1
|
||||
lexical: 0.6.0
|
||||
lint-staged: ">=12"
|
||||
mini-css-extract-plugin: ^2.6.1
|
||||
minimatch: ^5.1.0
|
||||
@@ -7064,12 +7066,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
|
||||
@@ -7094,12 +7090,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
|
||||
@@ -19677,7 +19667,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lexical@npm:^0.6.0":
|
||||
"lexical@npm:0.6.0, lexical@npm:^0.6.0":
|
||||
version: 0.6.0
|
||||
resolution: "lexical@npm:0.6.0"
|
||||
checksum: cac65609c231d9494b5f183fd50fc579d0df8af9c0075ef9e293d2fff21af8b84f66d97bebb7714edddd365d4f4d019a151d24838ffdce422d53815c7b1a0519
|
||||
@@ -25397,12 +25387,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
|
||||
@@ -25863,12 +25847,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@link:../web/node_modules/react::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "react@link:../web/node_modules/react::locator=%40standardnotes%2Fblocks-editor%40workspace%3Apackages%2Fblocks-editor"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"react@link:../web/node_modules/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "react@link:../web/node_modules/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
|
||||
|
||||
Reference in New Issue
Block a user