refactor: blocks plugins (#1956)

This commit is contained in:
Mo
2022-11-08 13:31:48 -06:00
committed by GitHub
parent bfca244061
commit 70a9dbcea6
73 changed files with 1448 additions and 1049 deletions

View File

@@ -9,13 +9,13 @@
"dependencies": { "dependencies": {
"@lexical/react": "^0.6.0", "@lexical/react": "^0.6.0",
"@standardnotes/icons": "workspace:*", "@standardnotes/icons": "workspace:*",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"lexical": "^0.6.0", "lexical": "^0.6.0",
"react": "link:../web/node_modules/react", "react": "^18.2.0",
"react-dom": "link:../web/node_modules/react-dom" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "link:../web/node_modules/@types/react",
"@types/react-dom": "link:../web/node_modules/@types/react-dom",
"eslint": "*", "eslint": "*",
"prettier": "*", "prettier": "*",
"typescript": "*" "typescript": "*"

View File

@@ -1,5 +1,4 @@
import {FunctionComponent, useCallback, useState} from 'react'; import {FunctionComponent, useCallback, useState} from 'react';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable'; import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
@@ -20,27 +19,25 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin'; import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin'; import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {EditorState, LexicalEditor} from 'lexical'; import {EditorState, LexicalEditor} from 'lexical';
import ComponentPickerMenuPlugin from '../Lexical/Plugins/ComponentPickerPlugin';
import BlocksEditorTheme from '../Lexical/Theme/Theme';
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin'; import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin'; import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'; import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin'; import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin'; 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 = { type BlocksEditorProps = {
initialValue: string;
onChange: (value: string) => void; onChange: (value: string) => void;
className?: string; className?: string;
children: React.ReactNode;
}; };
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
initialValue,
onChange, onChange,
className, className,
children,
}) => { }) => {
const handleChange = useCallback( const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => { (editorState: EditorState, _editor: LexicalEditor) => {
@@ -60,56 +57,46 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
}; };
return ( return (
<LexicalComposer <>
initialConfig={{ {children}
namespace: 'BlocksEditor', <RichTextPlugin
theme: BlocksEditorTheme, contentEditable={
onError: (error: Error) => console.error(error), <div id="blocks-editor" className="editor-scroller">
editorState: <div className="editor" ref={onRef}>
initialValue && initialValue.length > 0 ? initialValue : undefined, <ContentEditable
nodes: BlockEditorNodes, className={`ContentEditable__root ${className}`}
}}> />
<>
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller">
<div className="editor" ref={onRef}>
<ContentEditable
className={`ContentEditable__root ${className}`}
/>
</div>
</div> </div>
} </div>
placeholder="" }
ErrorBoundary={LexicalErrorBoundary} placeholder=""
/> ErrorBoundary={LexicalErrorBoundary}
<ListPlugin /> />
<MarkdownShortcutPlugin <ListPlugin />
transformers={[ <MarkdownShortcutPlugin
CHECK_LIST, transformers={[
...ELEMENT_TRANSFORMERS, CHECK_LIST,
...TEXT_FORMAT_TRANSFORMERS, ...ELEMENT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS,
]} ...TEXT_MATCH_TRANSFORMERS,
/> ]}
<TablePlugin /> />
<OnChangePlugin onChange={handleChange} /> <TablePlugin />
<HistoryPlugin /> <OnChangePlugin onChange={handleChange} />
<HorizontalRulePlugin /> <HistoryPlugin />
<AutoFocusPlugin /> <HorizontalRulePlugin />
<ComponentPickerMenuPlugin /> <AutoFocusPlugin />
<ClearEditorPlugin /> <ClearEditorPlugin />
<CheckListPlugin /> <CheckListPlugin />
<LinkPlugin /> <LinkPlugin />
<HashtagPlugin /> <HashtagPlugin />
<AutoEmbedPlugin /> <AutoEmbedPlugin />
<TwitterPlugin /> <TwitterPlugin />
<YouTubePlugin /> <YouTubePlugin />
<CollapsiblePlugin /> <CollapsiblePlugin />
{floatingAnchorElem && ( {floatingAnchorElem && BlockDragEnabled && (
<>{/* <DraggableBlockPlugin anchorElem={floatingAnchorElem} /> */}</> <>{<DraggableBlockPlugin anchorElem={floatingAnchorElem} />}</>
)} )}
</> </>
</LexicalComposer>
); );
}; };

View 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>
);
};

View 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',
);

View File

@@ -0,0 +1,5 @@
import {createCommand, LexicalCommand} from 'lexical';
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_FILE_COMMAND',
);

View File

@@ -31,6 +31,7 @@ interface PlaygroundEmbedConfig extends EmbedConfig {
// Icon for display. // Icon for display.
icon?: JSX.Element; icon?: JSX.Element;
iconName: string;
// An example of a matching url https://twitter.com/jack/status/20 // An example of a matching url https://twitter.com/jack/status/20
exampleUrl: string; exampleUrl: string;
@@ -49,6 +50,7 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
// Icon for display. // Icon for display.
icon: <i className="icon youtube" />, icon: <i className="icon youtube" />,
iconName: 'youtube',
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => { insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id); editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
@@ -84,6 +86,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Icon for display. // Icon for display.
icon: <i className="icon tweet" />, icon: <i className="icon tweet" />,
iconName: 'tweet',
// Create the Lexical embed node from the url data. // Create the Lexical embed node from the url data.
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => { insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {

View File

@@ -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
}
/>
</>
);
}

View File

@@ -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;
}

View File

@@ -21,13 +21,13 @@
} }
.Lexical__h1 { .Lexical__h1 {
font-size: 24px; font-size: 24px;
color: rgb(5, 5, 5); color: var(--sn-stylekit-editor-foreground-color);
font-weight: 400; font-weight: 400;
margin: 0; margin: 0;
} }
.Lexical__h2 { .Lexical__h2 {
font-size: 15px; font-size: 15px;
color: rgb(101, 103, 107); color: var(--sn-stylekit-editor-foreground-color);
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
text-transform: uppercase; text-transform: uppercase;

View File

@@ -1,5 +1,4 @@
@import 'base'; @import 'base';
@import 'component_picker';
@import 'custom'; @import 'custom';
@import 'editor'; @import 'editor';
@import 'icons'; @import 'icons';

View File

@@ -1 +1,4 @@
export * from './Editor/BlocksEditor'; export * from './Editor/BlocksEditor';
export * from './Editor/BlocksEditorComposer';
export * from './Editor/Commands';
export * from './Editor/ClassNames';

View File

@@ -5,6 +5,6 @@
"rootDir": "src", "rootDir": "src",
"outDir": "dist" "outDir": "dist"
}, },
"include": ["src"], "include": ["src", "../web/src/javascripts/Components/BlockEditor/EncryptedFileNode.tsx"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]
} }

View File

@@ -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')
})
})
})

View File

@@ -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]
}
}
}

View File

@@ -1,4 +1,3 @@
export * from './IconsController'
export * from './NoteViewController' export * from './NoteViewController'
export * from './FileViewController' export * from './FileViewController'
export * from './ItemGroupController' export * from './ItemGroupController'

View File

@@ -28,6 +28,7 @@
"@babel/plugin-transform-react-jsx": "^7.19.0", "@babel/plugin-transform-react-jsx": "^7.19.0",
"@babel/preset-env": "*", "@babel/preset-env": "*",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.18.6",
"@lexical/react": "^0.6.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
"@reach/alert": "^0.17.0", "@reach/alert": "^0.17.0",
"@reach/alert-dialog": "^0.17.0", "@reach/alert-dialog": "^0.17.0",
@@ -84,6 +85,7 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^29.2.1", "jest": "^29.2.1",
"jest-environment-jsdom": "^29.2.1", "jest-environment-jsdom": "^29.2.1",
"lexical": "0.6.0",
"lint-staged": ">=12", "lint-staged": ">=12",
"mini-css-extract-plugin": "^2.6.1", "mini-css-extract-plugin": "^2.6.1",
"minimatch": "^5.1.0", "minimatch": "^5.1.0",

View File

@@ -7,7 +7,6 @@ import {
SNApplication, SNApplication,
ItemGroupController, ItemGroupController,
removeFromArray, removeFromArray,
IconsController,
DesktopDeviceInterface, DesktopDeviceInterface,
isDesktopDevice, isDesktopDevice,
DeinitMode, DeinitMode,
@@ -47,7 +46,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter
private webServices!: WebServices private webServices!: WebServices
private webEventObservers: WebEventObserver[] = [] private webEventObservers: WebEventObserver[] = []
public itemControllerGroup: ItemGroupController public itemControllerGroup: ItemGroupController
public iconsController: IconsController
private onVisibilityChange: () => void private onVisibilityChange: () => void
private mobileWebReceiver?: MobileWebReceiver private mobileWebReceiver?: MobileWebReceiver
private androidBackHandler?: AndroidBackHandler private androidBackHandler?: AndroidBackHandler
@@ -81,7 +79,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter
const internalEventBus = new InternalEventBus() const internalEventBus = new InternalEventBus()
this.itemControllerGroup = new ItemGroupController(this) this.itemControllerGroup = new ItemGroupController(this)
this.iconsController = new IconsController()
this.routeService = new RouteService(this, internalEventBus) this.routeService = new RouteService(this, internalEventBus)
const viewControllerManager = new ViewControllerManager(this, deviceInterface) const viewControllerManager = new ViewControllerManager(this, deviceInterface)

View File

@@ -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)

View File

@@ -28,6 +28,7 @@ import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider'
import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler' import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler'
import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal' import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal'
import DarkModeHandler from '../DarkModeHandler/DarkModeHandler' import DarkModeHandler from '../DarkModeHandler/DarkModeHandler'
import ApplicationProvider from './ApplicationProvider'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -193,80 +194,85 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
} }
return ( return (
<AndroidBackHandlerProvider application={application}> <ApplicationProvider application={application}>
<DarkModeHandler application={application} /> <AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}> <DarkModeHandler application={application} />
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}> <ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}>
<div className={platformString + ' main-ui-view sn-component h-full'}> <PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<div id="app" className="app app-column-container" ref={appColumnContainerRef}> <div className={platformString + ' main-ui-view sn-component h-full'}>
<FileDragNDropProvider <div id="app" className="app app-column-container" ref={appColumnContainerRef}>
application={application} <FileDragNDropProvider
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
>
<Navigation application={application} />
<ContentListView
application={application} application={application}
accountMenuController={viewControllerManager.accountMenuController} featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController} filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController} >
navigationController={viewControllerManager.navigationController} <Navigation application={application} />
noAccountWarningController={viewControllerManager.noAccountWarningController} <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} notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController} selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController} subscriptionController={viewControllerManager.subscriptionController}
linkingController={viewControllerManager.linkingController}
/> />
<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> </div>
</PremiumModalProvider>
<> </ResponsivePaneProvider>
<Footer application={application} applicationGroup={mainApplicationGroup} /> </AndroidBackHandlerProvider>
<SessionsModal application={application} viewControllerManager={viewControllerManager} /> </ApplicationProvider>
<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>
) )
} }

View File

@@ -2,7 +2,11 @@ import { WebApplication } from '@/Application/Application'
import { SNNote } from '@standardnotes/snjs' import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useRef } from 'react' import { FunctionComponent, useCallback, useRef } from 'react'
import { BlockEditorController } from './BlockEditorController' 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' import { ErrorBoundary } from '@/Utils/ErrorBoundary'
const StringEllipses = '...' const StringEllipses = '...'
@@ -30,11 +34,16 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application }) =>
return ( return (
<div className="relative h-full w-full p-5"> <div className="relative h-full w-full p-5">
<ErrorBoundary> <ErrorBoundary>
<BlocksEditor <BlocksEditorComposer initialValue={note.text} nodes={[FileNode]}>
onChange={handleChange} <BlocksEditor
initialValue={note.content.text} onChange={handleChange}
className="relative relative resize-none text-base focus:shadow-none focus:outline-none" className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
/> >
<ItemSelectionPlugin currentNote={note} />
<FilePlugin />
<BlockPickerMenuPlugin />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) )

View File

@@ -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>
)
}

View File

@@ -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)
}
}

View File

@@ -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>
)
}}
/>
</>
)
}

View File

@@ -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),
}),
)
}

View File

@@ -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),
})
}

View File

@@ -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),
})
}

View File

@@ -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)
}
}
}),
})
}

View File

@@ -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),
})
}

View File

@@ -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),
})
}

View File

@@ -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),
}),
)
}

View File

@@ -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))
}
}),
}),
)
}

View File

@@ -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),
})
}

View File

@@ -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())
}
}),
})
}

View File

@@ -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())
}
}),
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
>

View File

@@ -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
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}}
/>
)
}

View File

@@ -5,6 +5,7 @@ import { FunctionComponent, useCallback, useRef, useState } from 'react'
import ChangeEditorMenu from './ChangeEditorMenu' import ChangeEditorMenu from './ChangeEditorMenu'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import RoundIconButton from '../Button/RoundIconButton' import RoundIconButton from '../Button/RoundIconButton'
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -24,9 +25,7 @@ const ChangeEditorButton: FunctionComponent<Props> = ({
const [selectedEditor, setSelectedEditor] = useState(() => { const [selectedEditor, setSelectedEditor] = useState(() => {
return note ? application.componentManager.editorForNote(note) : undefined return note ? application.componentManager.editorForNote(note) : undefined
}) })
const [selectedEditorIcon, selectedEditorIconTint] = application.iconsController.getIconAndTintForNoteType( const [selectedEditorIcon, selectedEditorIconTint] = getIconAndTintForNoteType(selectedEditor?.package_info.note_type)
selectedEditor?.package_info.note_type,
)
const toggleMenu = useCallback(async () => { const toggleMenu = useCallback(async () => {
const willMenuOpen = !isOpen const willMenuOpen = !isOpen

View File

@@ -11,9 +11,9 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent' import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { formatSizeToReadableString } from '@standardnotes/filepicker' import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
const FileListItem: FunctionComponent<DisplayableListItemProps<FileItem>> = ({ const FileListItem: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
application,
filesController, filesController,
hideDate, hideDate,
hideIcon, hideIcon,
@@ -66,10 +66,7 @@ const FileListItem: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
}, [item, onSelect, toggleAppPane]) }, [item, onSelect, toggleAppPane])
const IconComponent = () => const IconComponent = () =>
getFileIconComponent( getFileIconComponent(getIconForFileType((item as FileItem).mimeType), 'w-10 h-10 flex-shrink-0')
application.iconsController.getIconForFileType((item as FileItem).mimeType),
'w-10 h-10 flex-shrink-0',
)
useContextMenuEvent(listItemRef, openContextMenu) useContextMenuEvent(listItemRef, openContextMenu)

View File

@@ -15,6 +15,7 @@ import ListItemNotePreviewText from './ListItemNotePreviewText'
import { ListItemTitle } from './ListItemTitle' import { ListItemTitle } from './ListItemTitle'
import { log, LoggingDomain } from '@/Logging' import { log, LoggingDomain } from '@/Logging'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
application, application,
@@ -37,7 +38,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
const editorForNote = application.componentManager.editorForNote(item as SNNote) const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME 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 hasFiles = application.items.itemsReferencingItem(item).filter(isFile).length > 0
const openNoteContextMenu = (posX: number, posY: number) => { const openNoteContextMenu = (posX: number, posY: number) => {

View File

@@ -9,6 +9,7 @@ import { KeyboardKey } from '@standardnotes/ui-services'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager' import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import FilePreview from './FilePreview' import FilePreview from './FilePreview'
import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -67,12 +68,8 @@ const FilePreviewModal: FunctionComponent<Props> = observer(({ application, view
) )
const IconComponent = useMemo( const IconComponent = useMemo(
() => () => getFileIconComponent(getIconForFileType(currentFile.mimeType), 'w-6 h-6 flex-shrink-0'),
getFileIconComponent( [currentFile.mimeType],
application.iconsController.getIconForFileType(currentFile.mimeType),
'w-6 h-6 flex-shrink-0',
),
[application.iconsController, currentFile.mimeType],
) )
return ( return (

View File

@@ -18,6 +18,8 @@ import { LinkingController } from '@/Controllers/LinkingController'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import Menu from '../Menu/Menu' import Menu from '../Menu/Menu'
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
import { useApplication } from '../ApplicationView/ApplicationProvider'
type Props = { type Props = {
linkingController: LinkingController linkingController: LinkingController
@@ -27,18 +29,11 @@ type Props = {
} }
const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focusedId, setFocusedId }: Props) => { const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focusedId, setFocusedId }: Props) => {
const { const application = useApplication()
tags, const { tags, linkItemToSelectedItem, createAndAddNewTag, isEntitledToNoteLinking, activeItem } = linkingController
getTitleForLinkedTag,
getLinkedItemIcon,
getSearchResults,
linkItemToSelectedItem,
createAndAddNewTag,
isEntitledToNoteLinking,
} = linkingController
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const { unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery) const { unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, activeItem)
const [dropdownVisible, setDropdownVisible] = useState(false) const [dropdownVisible, setDropdownVisible] = useState(false)
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto') const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
@@ -105,7 +100,7 @@ const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focus
} }
}, [focusedId]) }, [focusedId])
const areSearchResultsVisible = dropdownVisible && (unlinkedResults.length > 0 || shouldShowCreateTag) const areSearchResultsVisible = dropdownVisible && (unlinkedItems.length > 0 || shouldShowCreateTag)
const handleMenuKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback((event) => { const handleMenuKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback((event) => {
if (event.key === KeyboardKey.Escape) { if (event.key === KeyboardKey.Escape) {
@@ -155,10 +150,8 @@ const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focus
> >
<LinkedItemSearchResults <LinkedItemSearchResults
createAndAddNewTag={createAndAddNewTag} createAndAddNewTag={createAndAddNewTag}
getLinkedItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
linkItemToSelectedItem={linkItemToSelectedItem} linkItemToSelectedItem={linkItemToSelectedItem}
results={unlinkedResults} results={unlinkedItems}
searchQuery={searchQuery} searchQuery={searchQuery}
shouldShowCreateTag={shouldShowCreateTag} shouldShowCreateTag={shouldShowCreateTag}
onClickCallback={() => setSearchQuery('')} onClickCallback={() => setSearchQuery('')}

View File

@@ -1,15 +1,18 @@
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react' import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
import { ContentType } from '@standardnotes/snjs' import { ContentType } from '@standardnotes/snjs'
import Icon from '../Icon/Icon' 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 = { type Props = {
link: ItemLink link: ItemLink
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
activateItem: (item: LinkableItem) => Promise<void> activateItem: (item: LinkableItem) => Promise<void>
unlinkItem: LinkingController['unlinkItemFromSelectedItem'] unlinkItem: LinkingController['unlinkItemFromSelectedItem']
focusPreviousItem: () => void focusPreviousItem: () => void
@@ -21,8 +24,6 @@ type Props = {
const LinkedItemBubble = ({ const LinkedItemBubble = ({
link, link,
getItemIcon,
getTitleForLinkedTag,
activateItem, activateItem,
unlinkItem, unlinkItem,
focusPreviousItem, focusPreviousItem,
@@ -32,6 +33,7 @@ const LinkedItemBubble = ({
isBidirectional, isBidirectional,
}: Props) => { }: Props) => {
const ref = useRef<HTMLButtonElement>(null) const ref = useRef<HTMLButtonElement>(null)
const application = useApplication()
const [showUnlinkButton, setShowUnlinkButton] = useState(false) const [showUnlinkButton, setShowUnlinkButton] = useState(false)
const unlinkButtonRef = useRef<HTMLAnchorElement | null>(null) const unlinkButtonRef = useRef<HTMLAnchorElement | null>(null)
@@ -80,8 +82,8 @@ const LinkedItemBubble = ({
} }
} }
const [icon, iconClassName] = getItemIcon(link.item) const [icon, iconClassName] = getIconForItem(link.item, application)
const tagTitle = getTitleForLinkedTag(link.item) const tagTitle = getTitleForLinkedTag(link.item, application)
useEffect(() => { useEffect(() => {
if (link.id === focusedId) { if (link.id === focusedId) {

View File

@@ -1,12 +1,14 @@
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput' import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble' import LinkedItemBubble from './LinkedItemBubble'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider' import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { ContentType } from '@standardnotes/snjs' import { ContentType } from '@standardnotes/snjs'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
type Props = { type Props = {
linkingController: LinkingController linkingController: LinkingController
@@ -19,8 +21,6 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
notesLinkingToActiveItem, notesLinkingToActiveItem,
filesLinkingToActiveItem, filesLinkingToActiveItem,
unlinkItemFromSelectedItem: unlinkItem, unlinkItemFromSelectedItem: unlinkItem,
getTitleForLinkedTag,
getLinkedItemIcon: getItemIcon,
activateItem, activateItem,
} = linkingController } = linkingController
@@ -86,8 +86,6 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
<LinkedItemBubble <LinkedItemBubble
link={link} link={link}
key={link.id} key={link.id}
getItemIcon={getItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
activateItem={activateItemAndTogglePane} activateItem={activateItemAndTogglePane}
unlinkItem={unlinkItem} unlinkItem={unlinkItem}
focusPreviousItem={focusPreviousItem} focusPreviousItem={focusPreviousItem}

View File

@@ -1,22 +1,21 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { splitQueryInString } from '@/Utils' import { splitQueryInString } from '@/Utils'
import { classNames } from '@/Utils/ConcatenateClassNames' 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 { observer } from 'mobx-react-lite'
import { useApplication } from '../ApplicationView/ApplicationProvider'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
const LinkedItemMeta = ({ type Props = {
item,
getItemIcon,
getTitleForLinkedTag,
searchQuery,
}: {
item: LinkableItem item: LinkableItem
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
searchQuery?: string 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 ?? '' const title = item.title ?? ''
return ( return (

View File

@@ -1,15 +1,14 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { SNNote } from '@standardnotes/snjs' import { SNNote } from '@standardnotes/snjs'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import LinkedItemMeta from './LinkedItemMeta' import LinkedItemMeta from './LinkedItemMeta'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
type Props = { type Props = {
createAndAddNewTag: LinkingController['createAndAddNewTag'] createAndAddNewTag: LinkingController['createAndAddNewTag']
getLinkedItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
linkItemToSelectedItem: LinkingController['linkItemToSelectedItem'] linkItemToSelectedItem: LinkingController['linkItemToSelectedItem']
results: LinkableItem[] results: LinkableItem[]
searchQuery: string searchQuery: string
@@ -20,8 +19,6 @@ type Props = {
const LinkedItemSearchResults = ({ const LinkedItemSearchResults = ({
createAndAddNewTag, createAndAddNewTag,
getLinkedItemIcon,
getTitleForLinkedTag,
linkItemToSelectedItem, linkItemToSelectedItem,
results, results,
searchQuery, searchQuery,
@@ -48,12 +45,7 @@ const LinkedItemSearchResults = ({
} }
}} }}
> >
<LinkedItemMeta <LinkedItemMeta item={result} searchQuery={searchQuery} />
item={result}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
/>
{cannotLinkItem && <Icon type={PremiumFeatureIconName} className="ml-auto flex-shrink-0 text-info" />} {cannotLinkItem && <Icon type={PremiumFeatureIconName} className="ml-auto flex-shrink-0 text-info" />}
</button> </button>
) )

View File

@@ -1,13 +1,17 @@
import { FeaturesController } from '@/Controllers/FeaturesController' import { FeaturesController } from '@/Controllers/FeaturesController'
import { FilesController } from '@/Controllers/FilesController' import { FilesController } from '@/Controllers/FilesController'
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { formatDateForContextMenu } from '@/Utils/DateUtils' 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 { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, useEffect, useRef, useState } from 'react' import { ChangeEventHandler, useEffect, useRef, useState } from 'react'
import { useApplication } from '../ApplicationView/ApplicationProvider'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction' import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import ClearInputButton from '../ClearInputButton/ClearInputButton' import ClearInputButton from '../ClearInputButton/ClearInputButton'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
@@ -22,29 +26,26 @@ import LinkedItemSearchResults from './LinkedItemSearchResults'
const LinkedItemsSectionItem = ({ const LinkedItemsSectionItem = ({
activateItem, activateItem,
getItemIcon,
getTitleForLinkedTag,
item, item,
searchQuery, searchQuery,
unlinkItem, unlinkItem,
handleFileAction, handleFileAction,
}: { }: {
activateItem: LinkingController['activateItem'] activateItem: LinkingController['activateItem']
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
item: LinkableItem item: LinkableItem
searchQuery?: string searchQuery?: string
unlinkItem: () => void unlinkItem: () => void
handleFileAction: FilesController['handleFileAction'] handleFileAction: FilesController['handleFileAction']
}) => { }) => {
const menuButtonRef = useRef<HTMLButtonElement>(null) const menuButtonRef = useRef<HTMLButtonElement>(null)
const application = useApplication()
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => setIsMenuOpen((open) => !open) const toggleMenu = () => setIsMenuOpen((open) => !open)
const [isRenamingFile, setIsRenamingFile] = useState(false) const [isRenamingFile, setIsRenamingFile] = useState(false)
const [icon, className] = getItemIcon(item) const [icon, className] = getIconForItem(item, application)
const title = item.title ?? '' const title = item.title ?? ''
const renameFile = async (name: string) => { const renameFile = async (name: string) => {
@@ -93,12 +94,7 @@ const LinkedItemsSectionItem = ({
toggleMenu() toggleMenu()
}} }}
> >
<LinkedItemMeta <LinkedItemMeta item={item} searchQuery={searchQuery} />
item={item}
getItemIcon={getItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
/>
</button> </button>
)} )}
<button <button
@@ -174,23 +170,26 @@ const LinkedItemsPanel = ({
notesLinkedToItem, notesLinkedToItem,
notesLinkingToActiveItem, notesLinkingToActiveItem,
allItemLinks: allLinkedItems, allItemLinks: allLinkedItems,
getTitleForLinkedTag,
getLinkedItemIcon,
getSearchResults,
linkItemToSelectedItem, linkItemToSelectedItem,
unlinkItemFromSelectedItem, unlinkItemFromSelectedItem,
activateItem, activateItem,
createAndAddNewTag, createAndAddNewTag,
isEntitledToNoteLinking, isEntitledToNoteLinking,
activeItem,
} = linkingController } = linkingController
const { hasFiles } = featuresController const { hasFiles } = featuresController
const application = useApplication()
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(null) const searchInputRef = useRef<HTMLInputElement | null>(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const isSearching = !!searchQuery.length const isSearching = !!searchQuery.length
const { linkedResults, unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery) const { linkedResults, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(
searchQuery,
application,
activeItem,
)
useEffect(() => { useEffect(() => {
if (isOpen && searchInputRef.current) { if (isOpen && searchInputRef.current) {
@@ -227,7 +226,7 @@ const LinkedItemsPanel = ({
<form <form
className={classNames( className={classNames(
'sticky top-0 z-10 bg-default px-2.5 pt-2.5', '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' ? 'border-b border-border pb-2.5'
: 'pb-1', : 'pb-1',
)} )}
@@ -254,15 +253,13 @@ const LinkedItemsPanel = ({
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{isSearching ? ( {isSearching ? (
<> <>
{(!!unlinkedResults.length || shouldShowCreateTag) && ( {(!!unlinkedItems.length || shouldShowCreateTag) && (
<div> <div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Unlinked</div> <div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Unlinked</div>
<LinkedItemSearchResults <LinkedItemSearchResults
createAndAddNewTag={createAndAddNewTag} createAndAddNewTag={createAndAddNewTag}
getLinkedItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
linkItemToSelectedItem={linkItemToSelectedItem} linkItemToSelectedItem={linkItemToSelectedItem}
results={unlinkedResults} results={unlinkedItems}
searchQuery={searchQuery} searchQuery={searchQuery}
shouldShowCreateTag={shouldShowCreateTag} shouldShowCreateTag={shouldShowCreateTag}
isEntitledToNoteLinking={isEntitledToNoteLinking} isEntitledToNoteLinking={isEntitledToNoteLinking}
@@ -281,8 +278,6 @@ const LinkedItemsPanel = ({
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={link.id} key={link.id}
item={link.item} item={link.item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link)} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
@@ -303,8 +298,6 @@ const LinkedItemsPanel = ({
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={link.id} key={link.id}
item={link.item} item={link.item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link)} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
@@ -336,8 +329,6 @@ const LinkedItemsPanel = ({
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={link.id} key={link.id}
item={link.item} item={link.item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link)} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
@@ -357,8 +348,6 @@ const LinkedItemsPanel = ({
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={link.id} key={link.id}
item={link.item} item={link.item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link)} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
@@ -376,8 +365,6 @@ const LinkedItemsPanel = ({
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={link.id} key={link.id}
item={link.item} item={link.item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link)} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}
@@ -397,8 +384,6 @@ const LinkedItemsPanel = ({
<LinkedItemsSectionItem <LinkedItemsSectionItem
key={link.id} key={link.id}
item={link.item} item={link.item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery} searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link)} unlinkItem={() => unlinkItemFromSelectedItem(link)}
activateItem={activateItem} activateItem={activateItem}

View File

@@ -33,7 +33,7 @@ import {
import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services' import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react' import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { EditorEventSource } from '../../Types/EditorEventSource' import { EditorEventSource } from '../../Types/EditorEventSource'
import { BlockEditor } from '../BlockEditor/BlockEditor' import { BlockEditor } from '../BlockEditor/BlockEditorComponent'
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle' import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer' import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton' import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'

View File

@@ -5,13 +5,13 @@ import { NavigationController } from '@/Controllers/Navigation/NavigationControl
import { NotesController } from '@/Controllers/NotesController' import { NotesController } from '@/Controllers/NotesController'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import { LinkingController } from '@/Controllers/LinkingController'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag'
import { useApplication } from '../ApplicationView/ApplicationProvider'
type Props = { type Props = {
navigationController: NavigationController navigationController: NavigationController
notesController: NotesController notesController: NotesController
linkingController: LinkingController
className: string className: string
iconClassName: string iconClassName: string
} }
@@ -19,10 +19,10 @@ type Props = {
const AddTagOption: FunctionComponent<Props> = ({ const AddTagOption: FunctionComponent<Props> = ({
navigationController, navigationController,
notesController, notesController,
linkingController,
className, className,
iconClassName, iconClassName,
}) => { }) => {
const application = useApplication()
const menuContainerRef = useRef<HTMLDivElement>(null) const menuContainerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
@@ -79,7 +79,7 @@ const AddTagOption: FunctionComponent<Props> = ({
className={`overflow-hidden overflow-ellipsis whitespace-nowrap className={`overflow-hidden overflow-ellipsis whitespace-nowrap
${notesController.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`} ${notesController.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
> >
{linkingController.getTitleForLinkedTag(tag)?.longTitle} {getTitleForLinkedTag(tag, application)?.longTitle}
</span> </span>
</button> </button>
))} ))}

View File

@@ -28,7 +28,10 @@ type DeletePermanentlyButtonProps = {
const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => ( const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
<button <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} onClick={onClick}
> >
<Icon type="close" className="mr-2 text-danger" /> <Icon type="close" className="mr-2 text-danger" />
@@ -183,7 +186,6 @@ const NotesOptions = ({
application, application,
navigationController, navigationController,
notesController, notesController,
linkingController,
historyModalController, historyModalController,
closeMenu, closeMenu,
}: NotesOptionsProps) => { }: NotesOptionsProps) => {
@@ -353,7 +355,6 @@ const NotesOptions = ({
className={switchClassNames} className={switchClassNames}
navigationController={navigationController} navigationController={navigationController}
notesController={notesController} notesController={notesController}
linkingController={linkingController}
/> />
)} )}

View File

@@ -1,4 +1,6 @@
import { isSearchResultAlreadyLinkedToItem } from '@/Utils/Items/Search/isSearchResultAlreadyLinkedToItem'
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { doesItemMatchSearchQuery } from '@/Utils/Items/Search/doesItemMatchSearchQuery'
import { import {
AnonymousReference, AnonymousReference,
ContentReferenceType, ContentReferenceType,
@@ -7,6 +9,7 @@ import {
FileToNoteReference, FileToNoteReference,
InternalEventBus, InternalEventBus,
SNNote, SNNote,
ItemsClientInterface,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { FilesController } from './FilesController' import { FilesController } from './FilesController'
import { ItemListController } from './ItemList/ItemListController' import { ItemListController } from './ItemList/ItemListController'
@@ -14,6 +17,7 @@ import { LinkingController } from './LinkingController'
import { NavigationController } from './Navigation/NavigationController' import { NavigationController } from './Navigation/NavigationController'
import { SelectedItemsController } from './SelectedItemsController' import { SelectedItemsController } from './SelectedItemsController'
import { SubscriptionController } from './Subscription/SubscriptionController' import { SubscriptionController } from './Subscription/SubscriptionController'
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
const createNote = (name: string, options?: Partial<SNNote>) => { const createNote = (name: string, options?: Partial<SNNote>) => {
return { return {
@@ -54,6 +58,8 @@ describe('LinkingController', () => {
application.addSingleEventObserver = jest.fn() application.addSingleEventObserver = jest.fn()
application.streamItems = jest.fn() application.streamItems = jest.fn()
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemsClientInterface> })
navigationController = {} as jest.Mocked<NavigationController> navigationController = {} as jest.Mocked<NavigationController>
selectionController = {} as jest.Mocked<SelectedItemsController> selectionController = {} as jest.Mocked<SelectedItemsController>
@@ -74,7 +80,7 @@ describe('LinkingController', () => {
const file = createFile('anotherFile') const file = createFile('anotherFile')
const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery) const isFileValidResult = doesItemMatchSearchQuery(file, searchQuery, application)
expect(isFileValidResult).toBeFalsy() expect(isFileValidResult).toBeFalsy()
}) })
@@ -86,10 +92,10 @@ describe('LinkingController', () => {
const trashed = createFile('test', { trashed: true }) const trashed = createFile('test', { trashed: true })
const isArchivedFileValidResult = linkingController.isValidSearchResult(archived, searchQuery) const isArchivedFileValidResult = doesItemMatchSearchQuery(archived, searchQuery, application)
expect(isArchivedFileValidResult).toBeFalsy() expect(isArchivedFileValidResult).toBeFalsy()
const isTrashedFileValidResult = linkingController.isValidSearchResult(trashed, searchQuery) const isTrashedFileValidResult = doesItemMatchSearchQuery(trashed, searchQuery, application)
expect(isTrashedFileValidResult).toBeFalsy() expect(isTrashedFileValidResult).toBeFalsy()
}) })
@@ -98,12 +104,11 @@ describe('LinkingController', () => {
const activeItem = createFile('test', { uuid: 'same-uuid' }) 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([...results.unlinkedItems, ...results.linkedItems]).toHaveLength(0)
expect(isFileValidResult).toBeFalsy()
}) })
it('should be valid result if it matches query even case insensitive', () => { it('should be valid result if it matches query even case insensitive', () => {
@@ -111,13 +116,15 @@ describe('LinkingController', () => {
const file = createFile('TeSt') 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() expect(isFileValidResult).toBeTruthy()
}) })
}) })
describe('isSearchResultAlreadyLinked', () => { describe('isSearchResultAlreadyLinkedToItem', () => {
it('should be true if active item & result are same content type & active item references result', () => { it('should be true if active item & result are same content type & active item references result', () => {
const activeItem = createFile('test', { const activeItem = createFile('test', {
uuid: 'active-item', uuid: 'active-item',
@@ -130,9 +137,7 @@ describe('LinkingController', () => {
}) })
const result = createFile('test', { uuid: 'result', references: [] }) const result = createFile('test', { uuid: 'result', references: [] })
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem }) const isFileAlreadyLinked = isSearchResultAlreadyLinkedToItem(result, activeItem)
const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
expect(isFileAlreadyLinked).toBeTruthy() expect(isFileAlreadyLinked).toBeTruthy()
}) })
@@ -151,9 +156,7 @@ describe('LinkingController', () => {
], ],
}) })
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem }) const isFileAlreadyLinked = isSearchResultAlreadyLinkedToItem(result, activeItem)
const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
expect(isFileAlreadyLinked).toBeFalsy() expect(isFileAlreadyLinked).toBeFalsy()
}) })
@@ -173,9 +176,7 @@ describe('LinkingController', () => {
], ],
}) })
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeNote }) const isFileResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(fileResult, activeNote)
const isFileResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(fileResult)
expect(isFileResultAlreadyLinked).toBeTruthy() expect(isFileResultAlreadyLinked).toBeTruthy()
}) })
@@ -195,9 +196,7 @@ describe('LinkingController', () => {
references: [], references: [],
}) })
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile }) const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(noteResult, activeFile)
const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
expect(isNoteResultAlreadyLinked).toBeTruthy() expect(isNoteResultAlreadyLinked).toBeTruthy()
}) })
@@ -212,9 +211,7 @@ describe('LinkingController', () => {
references: [], references: [],
}) })
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile }) const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(noteResult, activeFile)
const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
expect(isNoteResultAlreadyLinked).toBeFalsy() expect(isNoteResultAlreadyLinked).toBeFalsy()
}) })
}) })

View File

@@ -2,14 +2,14 @@ import { WebApplication } from '@/Application/Application'
import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction' import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { AppPaneId } from '@/Components/ResponsivePane/AppPaneMetadata' import { AppPaneId } from '@/Components/ResponsivePane/AppPaneMetadata'
import { PrefDefaults } from '@/Constants/PrefDefaults' 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 { import {
ApplicationEvent, ApplicationEvent,
ContentType, ContentType,
DecryptedItemInterface,
FileItem, FileItem,
IconType,
InternalEventBus, InternalEventBus,
ItemContent,
naturalSort, naturalSort,
NoteViewController, NoteViewController,
PrefKey, PrefKey,
@@ -26,14 +26,6 @@ import { NavigationController } from './Navigation/NavigationController'
import { SelectedItemsController } from './SelectedItemsController' import { SelectedItemsController } from './SelectedItemsController'
import { SubscriptionController } from './Subscription/SubscriptionController' 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 { export class LinkingController extends AbstractViewController {
tags: ItemLink<SNTag>[] = [] tags: ItemLink<SNTag>[] = []
linkedFiles: ItemLink<FileItem>[] = [] linkedFiles: ItemLink<FileItem>[] = []
@@ -142,14 +134,6 @@ export class LinkingController extends AbstractViewController {
this.reloadNotesLinkingToItem() this.reloadNotesLinkingToItem()
} }
createLinkFromItem = <I extends LinkableItem = LinkableItem>(itemA: I, type: 'linked' | 'linked-by'): ItemLink<I> => {
return {
id: `${itemA.uuid}-${type}`,
item: itemA,
type,
}
}
reloadLinkedFiles() { reloadLinkedFiles() {
if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) { if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) {
this.linkedFiles = [] this.linkedFiles = []
@@ -168,11 +152,11 @@ export class LinkingController extends AbstractViewController {
) )
if (this.activeItem.content_type === ContentType.File) { if (this.activeItem.content_type === ContentType.File) {
this.linkedFiles = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked')) this.linkedFiles = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked'))
this.filesLinkingToActiveItem = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by')) this.filesLinkingToActiveItem = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked-by'))
} else { } else {
this.linkedFiles = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked')) this.linkedFiles = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked'))
this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by')) this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked-by'))
} }
} }
@@ -183,7 +167,7 @@ export class LinkingController extends AbstractViewController {
this.tags = this.application.items this.tags = this.application.items
.getSortedTagsForItem(this.activeItem) .getSortedTagsForItem(this.activeItem)
.map((item) => this.createLinkFromItem(item, 'linked')) .map((item) => createLinkFromItem(item, 'linked'))
} }
reloadLinkedNotes() { reloadLinkedNotes() {
@@ -195,7 +179,7 @@ export class LinkingController extends AbstractViewController {
this.notesLinkedToItem = naturalSort( this.notesLinkedToItem = naturalSort(
this.application.items.referencesForItem(this.activeItem).filter(isNote), this.application.items.referencesForItem(this.activeItem).filter(isNote),
'title', 'title',
).map((item) => this.createLinkFromItem(item, 'linked')) ).map((item) => createLinkFromItem(item, 'linked'))
} }
reloadNotesLinkingToItem() { reloadNotesLinkingToItem() {
@@ -207,40 +191,7 @@ export class LinkingController extends AbstractViewController {
this.notesLinkingToActiveItem = naturalSort( this.notesLinkingToActiveItem = naturalSort(
this.application.items.itemsReferencingItem(this.activeItem).filter(isNote), this.application.items.itemsReferencingItem(this.activeItem).filter(isNote),
'title', 'title',
).map((item) => this.createLinkFromItem(item, 'linked-by')) ).map((item) => 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')
} }
activateItem = async (item: LinkableItem): Promise<AppPaneId | undefined> => { activateItem = async (item: LinkableItem): Promise<AppPaneId | undefined> => {
@@ -341,110 +292,4 @@ export class LinkingController extends AbstractViewController {
this.reloadLinkedTags() this.reloadLinkedTags()
this.application.sync.sync().catch(console.error) 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,
}
}
} }

View File

@@ -3,6 +3,7 @@ import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants' import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk' import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
import { getIconAndTintForNoteType } from './Items/Icons/getIconAndTintForNoteType'
export const PlainEditorType = 'plain-editor' export const PlainEditorType = 'plain-editor'
export const BlocksType = 'blocks-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 options = application.componentManager.componentsForArea(ComponentArea.Editor).map((editor): EditorOption => {
const identifier = editor.package_info.identifier 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 { return {
label: editor.displayName, label: editor.displayName,

View File

@@ -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,
}
}

View 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')
})
})
})

View File

@@ -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]
}
}

View File

@@ -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
}

View File

@@ -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')
}

View File

@@ -0,0 +1,7 @@
import { LinkableItem } from './LinkableItem'
export type ItemLink<ItemType extends LinkableItem = LinkableItem> = {
id: string
item: ItemType
type: 'linked' | 'linked-by'
}

View File

@@ -0,0 +1,3 @@
import { DecryptedItemInterface, ItemContent } from '@standardnotes/snjs'
export type LinkableItem = DecryptedItemInterface<ItemContent>

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -3,7 +3,7 @@
const plugin = require('tailwindcss/plugin') const plugin = require('tailwindcss/plugin')
module.exports = { module.exports = {
content: ['./src/javascripts/**/*.tsx', '../toast/src/**/*.tsx'], content: ['./src/javascripts/**/*.tsx', '../toast/src/**/*.tsx', '../blocks-editor/src/Editor/**/*'],
theme: { theme: {
extend: { extend: {
spacing: { spacing: {

View File

@@ -5334,13 +5334,13 @@ __metadata:
dependencies: dependencies:
"@lexical/react": ^0.6.0 "@lexical/react": ^0.6.0
"@standardnotes/icons": "workspace:*" "@standardnotes/icons": "workspace:*"
"@types/react": "link:../web/node_modules/@types/react" "@types/react": ^18.0.20
"@types/react-dom": "link:../web/node_modules/@types/react-dom" "@types/react-dom": ^18.0.6
eslint: "*" eslint: "*"
lexical: ^0.6.0 lexical: ^0.6.0
prettier: "*" prettier: "*"
react: "link:../web/node_modules/react" react: ^18.2.0
react-dom: "link:../web/node_modules/react-dom" react-dom: ^18.2.0
typescript: "*" typescript: "*"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -6109,6 +6109,7 @@ __metadata:
"@babel/plugin-transform-react-jsx": ^7.19.0 "@babel/plugin-transform-react-jsx": ^7.19.0
"@babel/preset-env": "*" "@babel/preset-env": "*"
"@babel/preset-typescript": ^7.18.6 "@babel/preset-typescript": ^7.18.6
"@lexical/react": ^0.6.0
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.7 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.7
"@reach/alert": ^0.17.0 "@reach/alert": ^0.17.0
"@reach/alert-dialog": ^0.17.0 "@reach/alert-dialog": ^0.17.0
@@ -6165,6 +6166,7 @@ __metadata:
identity-obj-proxy: ^3.0.0 identity-obj-proxy: ^3.0.0
jest: ^29.2.1 jest: ^29.2.1
jest-environment-jsdom: ^29.2.1 jest-environment-jsdom: ^29.2.1
lexical: 0.6.0
lint-staged: ">=12" lint-staged: ">=12"
mini-css-extract-plugin: ^2.6.1 mini-css-extract-plugin: ^2.6.1
minimatch: ^5.1.0 minimatch: ^5.1.0
@@ -7064,12 +7066,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast" resolution: "@types/react-dom@link:../web/node_modules/@types/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
@@ -7094,12 +7090,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast" resolution: "@types/react@link:../web/node_modules/@types/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
@@ -19677,7 +19667,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lexical@npm:^0.6.0": "lexical@npm:0.6.0, lexical@npm:^0.6.0":
version: 0.6.0 version: 0.6.0
resolution: "lexical@npm:0.6.0" resolution: "lexical@npm:0.6.0"
checksum: cac65609c231d9494b5f183fd50fc579d0df8af9c0075ef9e293d2fff21af8b84f66d97bebb7714edddd365d4f4d019a151d24838ffdce422d53815c7b1a0519 checksum: cac65609c231d9494b5f183fd50fc579d0df8af9c0075ef9e293d2fff21af8b84f66d97bebb7714edddd365d4f4d019a151d24838ffdce422d53815c7b1a0519
@@ -25397,12 +25387,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast" resolution: "react-dom@link:../web/node_modules/react-dom::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"
@@ -25863,12 +25847,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react@link:../web/node_modules/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "react@link:../web/node_modules/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast" resolution: "react@link:../web/node_modules/react::locator=%40standardnotes%2Ftoast%40workspace%3Apackages%2Ftoast"