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": {
"@lexical/react": "^0.6.0",
"@standardnotes/icons": "workspace:*",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"lexical": "^0.6.0",
"react": "link:../web/node_modules/react",
"react-dom": "link:../web/node_modules/react-dom"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "link:../web/node_modules/@types/react",
"@types/react-dom": "link:../web/node_modules/@types/react-dom",
"eslint": "*",
"prettier": "*",
"typescript": "*"

View File

@@ -1,5 +1,4 @@
import {FunctionComponent, useCallback, useState} from 'react';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
@@ -20,27 +19,25 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {EditorState, LexicalEditor} from 'lexical';
import ComponentPickerMenuPlugin from '../Lexical/Plugins/ComponentPickerPlugin';
import BlocksEditorTheme from '../Lexical/Theme/Theme';
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin';
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
// import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
const BlockDragEnabled = false;
type BlocksEditorProps = {
initialValue: string;
onChange: (value: string) => void;
className?: string;
children: React.ReactNode;
};
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
initialValue,
onChange,
className,
children,
}) => {
const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => {
@@ -60,16 +57,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
};
return (
<LexicalComposer
initialConfig={{
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
onError: (error: Error) => console.error(error),
editorState:
initialValue && initialValue.length > 0 ? initialValue : undefined,
nodes: BlockEditorNodes,
}}>
<>
{children}
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller">
@@ -97,7 +86,6 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<HistoryPlugin />
<HorizontalRulePlugin />
<AutoFocusPlugin />
<ComponentPickerMenuPlugin />
<ClearEditorPlugin />
<CheckListPlugin />
<LinkPlugin />
@@ -106,10 +94,9 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<TwitterPlugin />
<YouTubePlugin />
<CollapsiblePlugin />
{floatingAnchorElem && (
<>{/* <DraggableBlockPlugin anchorElem={floatingAnchorElem} /> */}</>
{floatingAnchorElem && BlockDragEnabled && (
<>{<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?: JSX.Element;
iconName: string;
// An example of a matching url https://twitter.com/jack/status/20
exampleUrl: string;
@@ -49,6 +50,7 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
// Icon for display.
icon: <i className="icon youtube" />,
iconName: 'youtube',
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
@@ -84,6 +86,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Icon for display.
icon: <i className="icon tweet" />,
iconName: 'tweet',
// Create the Lexical embed node from the url data.
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {

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 {
font-size: 24px;
color: rgb(5, 5, 5);
color: var(--sn-stylekit-editor-foreground-color);
font-weight: 400;
margin: 0;
}
.Lexical__h2 {
font-size: 15px;
color: rgb(101, 103, 107);
color: var(--sn-stylekit-editor-foreground-color);
font-weight: 700;
margin: 0;
text-transform: uppercase;

View File

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

View File

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

View File

@@ -5,6 +5,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"include": ["src", "../web/src/javascripts/Components/BlockEditor/EncryptedFileNode.tsx"],
"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 './FileViewController'
export * from './ItemGroupController'

View File

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

View File

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

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 ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal'
import DarkModeHandler from '../DarkModeHandler/DarkModeHandler'
import ApplicationProvider from './ApplicationProvider'
type Props = {
application: WebApplication
@@ -193,6 +194,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
}
return (
<ApplicationProvider application={application}>
<AndroidBackHandlerProvider application={application}>
<DarkModeHandler application={application} />
<ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}>
@@ -261,12 +263,16 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
<ToastContainer />
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
<PermissionsModalWrapper application={application} />
<ConfirmDeleteAccountContainer application={application} viewControllerManager={viewControllerManager} />
<ConfirmDeleteAccountContainer
application={application}
viewControllerManager={viewControllerManager}
/>
</>
</div>
</PremiumModalProvider>
</ResponsivePaneProvider>
</AndroidBackHandlerProvider>
</ApplicationProvider>
)
}

View File

@@ -2,7 +2,11 @@ import { WebApplication } from '@/Application/Application'
import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useRef } from 'react'
import { BlockEditorController } from './BlockEditorController'
import { BlocksEditor } from '@standardnotes/blocks-editor'
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
import { ItemSelectionPlugin } from './Plugins/ItemSelectionPlugin/ItemSelectionPlugin'
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
import FilePlugin from './Plugins/EncryptedFilePlugin/FilePlugin'
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
const StringEllipses = '...'
@@ -30,11 +34,16 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application }) =>
return (
<div className="relative h-full w-full p-5">
<ErrorBoundary>
<BlocksEditorComposer initialValue={note.text} nodes={[FileNode]}>
<BlocksEditor
onChange={handleChange}
initialValue={note.content.text}
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
/>
>
<ItemSelectionPlugin currentNote={note} />
<FilePlugin />
<BlockPickerMenuPlugin />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</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 Popover from '../Popover/Popover'
import RoundIconButton from '../Button/RoundIconButton'
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
type Props = {
application: WebApplication
@@ -24,9 +25,7 @@ const ChangeEditorButton: FunctionComponent<Props> = ({
const [selectedEditor, setSelectedEditor] = useState(() => {
return note ? application.componentManager.editorForNote(note) : undefined
})
const [selectedEditorIcon, selectedEditorIconTint] = application.iconsController.getIconAndTintForNoteType(
selectedEditor?.package_info.note_type,
)
const [selectedEditorIcon, selectedEditorIconTint] = getIconAndTintForNoteType(selectedEditor?.package_info.note_type)
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isOpen

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,21 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { splitQueryInString } from '@/Utils'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag'
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { observer } from 'mobx-react-lite'
import { useApplication } from '../ApplicationView/ApplicationProvider'
import Icon from '../Icon/Icon'
const LinkedItemMeta = ({
item,
getItemIcon,
getTitleForLinkedTag,
searchQuery,
}: {
type Props = {
item: LinkableItem
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
searchQuery?: string
}) => {
const [icon, className] = getItemIcon(item)
const tagTitle = getTitleForLinkedTag(item)
}
const LinkedItemMeta = ({ item, searchQuery }: Props) => {
const application = useApplication()
const [icon, className] = getIconForItem(item, application)
const tagTitle = getTitleForLinkedTag(item, application)
const title = item.title ?? ''
return (

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,10 @@ type DeletePermanentlyButtonProps = {
const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-menu-item"
className={classNames(
'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item',
'text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-menu-item',
)}
onClick={onClick}
>
<Icon type="close" className="mr-2 text-danger" />
@@ -183,7 +186,6 @@ const NotesOptions = ({
application,
navigationController,
notesController,
linkingController,
historyModalController,
closeMenu,
}: NotesOptionsProps) => {
@@ -353,7 +355,6 @@ const NotesOptions = ({
className={switchClassNames}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}
/>
)}

View File

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

View File

@@ -2,14 +2,14 @@ import { WebApplication } from '@/Application/Application'
import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { AppPaneId } from '@/Components/ResponsivePane/AppPaneMetadata'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import {
ApplicationEvent,
ContentType,
DecryptedItemInterface,
FileItem,
IconType,
InternalEventBus,
ItemContent,
naturalSort,
NoteViewController,
PrefKey,
@@ -26,14 +26,6 @@ import { NavigationController } from './Navigation/NavigationController'
import { SelectedItemsController } from './SelectedItemsController'
import { SubscriptionController } from './Subscription/SubscriptionController'
export type LinkableItem = DecryptedItemInterface<ItemContent>
export type ItemLink<ItemType extends LinkableItem = LinkableItem> = {
id: string
item: ItemType
type: 'linked' | 'linked-by'
}
export class LinkingController extends AbstractViewController {
tags: ItemLink<SNTag>[] = []
linkedFiles: ItemLink<FileItem>[] = []
@@ -142,14 +134,6 @@ export class LinkingController extends AbstractViewController {
this.reloadNotesLinkingToItem()
}
createLinkFromItem = <I extends LinkableItem = LinkableItem>(itemA: I, type: 'linked' | 'linked-by'): ItemLink<I> => {
return {
id: `${itemA.uuid}-${type}`,
item: itemA,
type,
}
}
reloadLinkedFiles() {
if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) {
this.linkedFiles = []
@@ -168,11 +152,11 @@ export class LinkingController extends AbstractViewController {
)
if (this.activeItem.content_type === ContentType.File) {
this.linkedFiles = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked'))
this.filesLinkingToActiveItem = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by'))
this.linkedFiles = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked'))
this.filesLinkingToActiveItem = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked-by'))
} else {
this.linkedFiles = referencingActiveItem.map((item) => this.createLinkFromItem(item, 'linked'))
this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => this.createLinkFromItem(item, 'linked-by'))
this.linkedFiles = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked'))
this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked-by'))
}
}
@@ -183,7 +167,7 @@ export class LinkingController extends AbstractViewController {
this.tags = this.application.items
.getSortedTagsForItem(this.activeItem)
.map((item) => this.createLinkFromItem(item, 'linked'))
.map((item) => createLinkFromItem(item, 'linked'))
}
reloadLinkedNotes() {
@@ -195,7 +179,7 @@ export class LinkingController extends AbstractViewController {
this.notesLinkedToItem = naturalSort(
this.application.items.referencesForItem(this.activeItem).filter(isNote),
'title',
).map((item) => this.createLinkFromItem(item, 'linked'))
).map((item) => createLinkFromItem(item, 'linked'))
}
reloadNotesLinkingToItem() {
@@ -207,40 +191,7 @@ export class LinkingController extends AbstractViewController {
this.notesLinkingToActiveItem = naturalSort(
this.application.items.itemsReferencingItem(this.activeItem).filter(isNote),
'title',
).map((item) => this.createLinkFromItem(item, 'linked-by'))
}
getTitleForLinkedTag = (item: LinkableItem) => {
const isTag = item instanceof SNTag
if (!isTag) {
return
}
const titlePrefix = this.application.items.getTagPrefixTitle(item)
const longTitle = this.application.items.getTagLongTitle(item)
return {
titlePrefix,
longTitle,
}
}
getLinkedItemIcon = (item: LinkableItem): [IconType, string] => {
if (item instanceof SNNote) {
const editorForNote = this.application.componentManager.editorForNote(item)
const [icon, tint] = this.application.iconsController.getIconAndTintForNoteType(
editorForNote?.package_info.note_type,
)
const className = `text-accessory-tint-${tint}`
return [icon, className]
} else if (item instanceof FileItem) {
const icon = this.application.iconsController.getIconForFileType(item.mimeType)
return [icon, 'text-info']
} else if (item instanceof SNTag) {
return [item.iconString as IconType, 'text-info']
}
throw new Error('Unhandled case in getLinkedItemIcon')
).map((item) => createLinkFromItem(item, 'linked-by'))
}
activateItem = async (item: LinkableItem): Promise<AppPaneId | undefined> => {
@@ -341,110 +292,4 @@ export class LinkingController extends AbstractViewController {
this.reloadLinkedTags()
this.application.sync.sync().catch(console.error)
}
isValidSearchResult = (item: LinkableItem, searchQuery: string) => {
const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title ?? ''
const matchesQuery = title.toLowerCase().includes(searchQuery.toLowerCase())
const isActiveItem = this.activeItem?.uuid === item.uuid
const isArchivedOrTrashed = item.archived || item.trashed
const isValidSearchResult = matchesQuery && !isActiveItem && !isArchivedOrTrashed
return isValidSearchResult
}
isSearchResultAlreadyLinked = (item: LinkableItem) => {
if (!this.activeItem) {
return false
}
let isAlreadyLinked = false
const isItemReferencedByActiveItem = this.activeItem.references.some((ref) => ref.uuid === item.uuid)
const isActiveItemReferencedByItem = item.references.some((ref) => ref.uuid === this.activeItem?.uuid)
if (this.activeItem.content_type === item.content_type) {
isAlreadyLinked = isItemReferencedByActiveItem
} else {
isAlreadyLinked = isActiveItemReferencedByItem || isItemReferencedByActiveItem
}
return isAlreadyLinked
}
isSearchResultExistingTag = (result: DecryptedItemInterface<ItemContent>, searchQuery: string) =>
result.content_type === ContentType.Tag && result.title === searchQuery
getSearchResults = (searchQuery: string) => {
let unlinkedResults: LinkableItem[] = []
const linkedResults: ItemLink<LinkableItem>[] = []
let shouldShowCreateTag = false
const defaultReturnValue = {
linkedResults,
unlinkedResults,
shouldShowCreateTag,
}
if (!searchQuery.length) {
return defaultReturnValue
}
if (!this.activeItem) {
return defaultReturnValue
}
const searchableItems = naturalSort(
this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]),
'title',
)
const unlinkedTags: LinkableItem[] = []
const unlinkedNotes: LinkableItem[] = []
const unlinkedFiles: LinkableItem[] = []
for (let index = 0; index < searchableItems.length; index++) {
const item = searchableItems[index]
if (!this.isValidSearchResult(item, searchQuery)) {
continue
}
if (this.isSearchResultAlreadyLinked(item)) {
if (linkedResults.length < 20) {
linkedResults.push(this.createLinkFromItem(item, 'linked'))
}
continue
}
if (unlinkedTags.length < 5 && item.content_type === ContentType.Tag) {
unlinkedTags.push(item)
continue
}
if (unlinkedNotes.length < 5 && item.content_type === ContentType.Note) {
unlinkedNotes.push(item)
continue
}
if (unlinkedFiles.length < 5 && item.content_type === ContentType.File) {
unlinkedFiles.push(item)
continue
}
}
unlinkedResults = unlinkedTags.concat(unlinkedNotes).concat(unlinkedFiles)
shouldShowCreateTag =
!linkedResults.find((link) => this.isSearchResultExistingTag(link.item, searchQuery)) &&
!unlinkedResults.find((item) => this.isSearchResultExistingTag(item, searchQuery))
return {
unlinkedResults,
linkedResults,
shouldShowCreateTag,
}
}
}

View File

@@ -3,6 +3,7 @@ import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { WebApplication } from '@/Application/Application'
import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
import { getIconAndTintForNoteType } from './Items/Icons/getIconAndTintForNoteType'
export const PlainEditorType = 'plain-editor'
export const BlocksType = 'blocks-editor'
@@ -21,7 +22,7 @@ export function getDropdownItemsForAllEditors(application: WebApplication) {
const options = application.componentManager.componentsForArea(ComponentArea.Editor).map((editor): EditorOption => {
const identifier = editor.package_info.identifier
const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type)
const [iconType, tint] = getIconAndTintForNoteType(editor.package_info.note_type)
return {
label: editor.displayName,

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')
module.exports = {
content: ['./src/javascripts/**/*.tsx', '../toast/src/**/*.tsx'],
content: ['./src/javascripts/**/*.tsx', '../toast/src/**/*.tsx', '../blocks-editor/src/Editor/**/*'],
theme: {
extend: {
spacing: {

View File

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