diff --git a/packages/blocks-editor/package.json b/packages/blocks-editor/package.json index 319420941..b48c6dfe4 100644 --- a/packages/blocks-editor/package.json +++ b/packages/blocks-editor/package.json @@ -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": "*" diff --git a/packages/blocks-editor/src/Editor/BlocksEditor.tsx b/packages/blocks-editor/src/Editor/BlocksEditor.tsx index edbf9bd26..78924d489 100644 --- a/packages/blocks-editor/src/Editor/BlocksEditor.tsx +++ b/packages/blocks-editor/src/Editor/BlocksEditor.tsx @@ -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 = ({ - initialValue, onChange, className, + children, }) => { const handleChange = useCallback( (editorState: EditorState, _editor: LexicalEditor) => { @@ -60,56 +57,46 @@ export const BlocksEditor: FunctionComponent = ({ }; return ( - console.error(error), - editorState: - initialValue && initialValue.length > 0 ? initialValue : undefined, - nodes: BlockEditorNodes, - }}> - <> - -
- -
+ <> + {children} + +
+
- } - placeholder="" - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - - - - - - - - {floatingAnchorElem && ( - <>{/* */} - )} - -
+ + } + placeholder="" + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + + + + + + + + {floatingAnchorElem && BlockDragEnabled && ( + <>{} + )} + ); }; diff --git a/packages/blocks-editor/src/Editor/BlocksEditorComposer.tsx b/packages/blocks-editor/src/Editor/BlocksEditorComposer.tsx new file mode 100644 index 000000000..4822b61be --- /dev/null +++ b/packages/blocks-editor/src/Editor/BlocksEditorComposer.tsx @@ -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>; +}; + +export const BlocksEditorComposer: FunctionComponent< + BlocksEditorComposerProps +> = ({initialValue, children, nodes}) => { + return ( + console.error(error), + editorState: + initialValue && initialValue.length > 0 ? initialValue : undefined, + nodes: [...nodes, ...BlockEditorNodes], + }}> + <>{children} + + ); +}; diff --git a/packages/blocks-editor/src/Editor/ClassNames.ts b/packages/blocks-editor/src/Editor/ClassNames.ts new file mode 100644 index 000000000..d1668161b --- /dev/null +++ b/packages/blocks-editor/src/Editor/ClassNames.ts @@ -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', +); diff --git a/packages/blocks-editor/src/Editor/Commands.ts b/packages/blocks-editor/src/Editor/Commands.ts new file mode 100644 index 000000000..6bcd54228 --- /dev/null +++ b/packages/blocks-editor/src/Editor/Commands.ts @@ -0,0 +1,5 @@ +import {createCommand, LexicalCommand} from 'lexical'; + +export const INSERT_FILE_COMMAND: LexicalCommand = createCommand( + 'INSERT_FILE_COMMAND', +); diff --git a/packages/blocks-editor/src/Lexical/Plugins/AutoEmbedPlugin/index.tsx b/packages/blocks-editor/src/Lexical/Plugins/AutoEmbedPlugin/index.tsx index 8d58c51a8..4f306b8f2 100644 --- a/packages/blocks-editor/src/Lexical/Plugins/AutoEmbedPlugin/index.tsx +++ b/packages/blocks-editor/src/Lexical/Plugins/AutoEmbedPlugin/index.tsx @@ -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: , + 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: , + iconName: 'tweet', // Create the Lexical embed node from the url data. insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => { diff --git a/packages/blocks-editor/src/Lexical/Plugins/ComponentPickerPlugin/index.tsx b/packages/blocks-editor/src/Lexical/Plugins/ComponentPickerPlugin/index.tsx deleted file mode 100644 index d1b9d45e9..000000000 --- a/packages/blocks-editor/src/Lexical/Plugins/ComponentPickerPlugin/index.tsx +++ /dev/null @@ -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; - // TBD - keyboardShortcut?: string; - // What happens when you select this option? - onSelect: (queryString: string) => void; - - constructor( - title: string, - options: { - icon?: JSX.Element; - keywords?: Array; - 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 ( -
  • - {option.icon} - {option.title} -
  • - ); -} - -export default function ComponentPickerMenuPlugin(): JSX.Element { - const [editor] = useLexicalComposerContext(); - const [modal, showModal] = useModal(); - const [queryString, setQueryString] = useState(null); - - const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { - minLength: 0, - }); - - const getDynamicOptions = useCallback(() => { - const options: Array = []; - - 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: , - 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: , - 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: , - 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: , - 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: , - keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], - onSelect: () => - showModal('Insert Table', (onClose) => ( - - )), - }), - new ComponentPickerOption('Numbered List', { - icon: , - keywords: ['numbered list', 'ordered list', 'ol'], - onSelect: () => - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), - }), - new ComponentPickerOption('Bulleted List', { - icon: , - keywords: ['bulleted list', 'unordered list', 'ul'], - onSelect: () => - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), - }), - new ComponentPickerOption('Check List', { - icon: , - keywords: ['check list', 'todo list'], - onSelect: () => - editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined), - }), - new ComponentPickerOption('Quote', { - icon: , - keywords: ['block quote'], - onSelect: () => - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $wrapNodes(selection, () => $createQuoteNode()); - } - }), - }), - new ComponentPickerOption('Code', { - icon: , - 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: , - 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: , - keywords: ['collapse', 'collapsible', 'toggle'], - onSelect: () => - editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined), - }), - ...['left', 'center', 'right', 'justify'].map( - (alignment) => - new ComponentPickerOption(`Align ${alignment}`, { - icon: , - 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} - - onQueryChange={setQueryString} - onSelectOption={onSelectOption} - triggerFn={checkForTriggerMatch} - options={options} - menuRenderFn={( - anchorElementRef, - {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, - ) => - anchorElementRef.current && options.length - ? ReactDOM.createPortal( -
    -
      - {options.map((option, i: number) => ( - { - setHighlightedIndex(i); - selectOptionAndCleanUp(option); - }} - onMouseEnter={() => { - setHighlightedIndex(i); - }} - key={option.key} - option={option} - /> - ))} -
    -
    , - anchorElementRef.current, - ) - : null - } - /> - - ); -} diff --git a/packages/blocks-editor/src/Lexical/Theme/component_picker.scss b/packages/blocks-editor/src/Lexical/Theme/component_picker.scss deleted file mode 100644 index 4f5f8483f..000000000 --- a/packages/blocks-editor/src/Lexical/Theme/component_picker.scss +++ /dev/null @@ -1,93 +0,0 @@ -.typeahead-popover { - background: #fff; - box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); - border-radius: 8px; - margin-top: 25px; -} - -.typeahead-popover ul { - padding: 0; - list-style: none; - margin: 0; - border-radius: 8px; - max-height: 200px; - overflow-y: scroll; -} - -.typeahead-popover ul::-webkit-scrollbar { - display: none; -} - -.typeahead-popover ul { - -ms-overflow-style: none; - scrollbar-width: none; -} - -.typeahead-popover ul li { - margin: 0; - min-width: 180px; - font-size: 14px; - outline: none; - cursor: pointer; - border-radius: 8px; -} - -.typeahead-popover ul li.selected { - background: #eee; -} - -.typeahead-popover li { - margin: 0 8px 0 8px; - padding: 8px; - color: #050505; - cursor: pointer; - line-height: 16px; - font-size: 15px; - display: flex; - align-content: center; - flex-direction: row; - flex-shrink: 0; - background-color: #fff; - border-radius: 8px; - border: 0; -} - -.typeahead-popover li.active { - display: flex; - width: 20px; - height: 20px; - background-size: contain; -} - -.typeahead-popover li:first-child { - border-radius: 8px 8px 0px 0px; -} - -.typeahead-popover li:last-child { - border-radius: 0px 0px 8px 8px; -} - -.typeahead-popover li:hover { - background-color: #eee; -} - -.typeahead-popover li .text { - display: flex; - line-height: 20px; - flex-grow: 1; - min-width: 150px; -} - -.typeahead-popover li .icon { - display: flex; - width: 20px; - height: 20px; - user-select: none; - margin-right: 8px; - line-height: 16px; - background-size: contain; -} - -.component-picker-menu { - width: 200px; -} diff --git a/packages/blocks-editor/src/Lexical/Theme/editor.scss b/packages/blocks-editor/src/Lexical/Theme/editor.scss index 49bccdd0f..e0c065bac 100644 --- a/packages/blocks-editor/src/Lexical/Theme/editor.scss +++ b/packages/blocks-editor/src/Lexical/Theme/editor.scss @@ -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; diff --git a/packages/blocks-editor/src/Lexical/Theme/lexical.scss b/packages/blocks-editor/src/Lexical/Theme/lexical.scss index 21d738788..fafa26f1f 100644 --- a/packages/blocks-editor/src/Lexical/Theme/lexical.scss +++ b/packages/blocks-editor/src/Lexical/Theme/lexical.scss @@ -1,5 +1,4 @@ @import 'base'; -@import 'component_picker'; @import 'custom'; @import 'editor'; @import 'icons'; \ No newline at end of file diff --git a/packages/blocks-editor/src/index.ts b/packages/blocks-editor/src/index.ts index b41bf6099..7fedf67cc 100644 --- a/packages/blocks-editor/src/index.ts +++ b/packages/blocks-editor/src/index.ts @@ -1 +1,4 @@ export * from './Editor/BlocksEditor'; +export * from './Editor/BlocksEditorComposer'; +export * from './Editor/Commands'; +export * from './Editor/ClassNames'; diff --git a/packages/blocks-editor/tsconfig.json b/packages/blocks-editor/tsconfig.json index 3a6863e5f..a46fc991c 100644 --- a/packages/blocks-editor/tsconfig.json +++ b/packages/blocks-editor/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src"], + "include": ["src", "../web/src/javascripts/Components/BlockEditor/EncryptedFileNode.tsx"], "exclude": ["dist", "node_modules"] } diff --git a/packages/snjs/lib/Client/IconsController.spec.ts b/packages/snjs/lib/Client/IconsController.spec.ts deleted file mode 100644 index 5250489a8..000000000 --- a/packages/snjs/lib/Client/IconsController.spec.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/packages/snjs/lib/Client/IconsController.ts b/packages/snjs/lib/Client/IconsController.ts deleted file mode 100644 index d46a09b7d..000000000 --- a/packages/snjs/lib/Client/IconsController.ts +++ /dev/null @@ -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] - } - } -} diff --git a/packages/snjs/lib/Client/index.ts b/packages/snjs/lib/Client/index.ts index b1db52d7b..2064237e9 100644 --- a/packages/snjs/lib/Client/index.ts +++ b/packages/snjs/lib/Client/index.ts @@ -1,4 +1,3 @@ -export * from './IconsController' export * from './NoteViewController' export * from './FileViewController' export * from './ItemGroupController' diff --git a/packages/web/package.json b/packages/web/package.json index ac867360a..52ce51aab 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 6a9e35264..12a55c467 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -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) diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationProvider.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationProvider.tsx new file mode 100644 index 000000000..52b5f7bf3 --- /dev/null +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationProvider.tsx @@ -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(undefined) + +export const useApplication = () => { + const value = useContext(ApplicationContext) + + if (!value) { + throw new Error('Component must be a child of ') + } + + return value +} + +type ChildrenProps = { + children: ReactNode +} + +type ProviderProps = { + application: WebApplication +} & ChildrenProps + +const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}) + +const ApplicationProvider = ({ application, children }: ProviderProps) => { + return ( + + + + ) +} + +export default observer(ApplicationProvider) diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index 69a9c9e3e..a28f66251 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -28,6 +28,7 @@ import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider' import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler' import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal' import DarkModeHandler from '../DarkModeHandler/DarkModeHandler' +import ApplicationProvider from './ApplicationProvider' type Props = { application: WebApplication @@ -193,80 +194,85 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio } return ( - - - - -
    -
    - - - + + + + +
    +
    + + + + + +
    + + <> +
    + + + - - + + + {renderChallenges()} + + <> + + + + + + + + + +
    - - <> -
    - - - - - - {renderChallenges()} - - <> - - - - - - - - - - -
    - - - + + + + ) } diff --git a/packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx similarity index 57% rename from packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx rename to packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx index 1bacb7f24..5ea3790c2 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockEditorComponent.tsx @@ -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 = ({ note, application }) => return (
    - + + + + + + +
    ) diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx new file mode 100644 index 000000000..4b7a92f6e --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerMenuItem.tsx @@ -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 ( +
  • + +
    {option.title}
    +
  • + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerOption.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerOption.tsx new file mode 100644 index 000000000..469ea4e68 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerOption.tsx @@ -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 + // TBD + keyboardShortcut?: string + // What happens when you select this option? + onSelect: (queryString: string) => void + + constructor( + title: string, + options: { + iconName?: string + keywords?: Array + 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) + } +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx new file mode 100644 index 000000000..26997700c --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/BlockPickerPlugin.tsx @@ -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(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) => ), + ), + 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} + + 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 ( + { + setPopoverOpen((prevValue) => !prevValue) + }} + > +
    +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i) + selectOptionAndCleanUp(option) + }} + onMouseEnter={() => { + setHighlightedIndex(i) + }} + key={option.key} + option={option} + /> + ))} +
    +
    +
    + ) + }} + /> + + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Alignment.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Alignment.tsx new file mode 100644 index 000000000..0add44748 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Alignment.tsx @@ -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), + }), + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/BulletedList.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/BulletedList.tsx new file mode 100644 index 000000000..6d5316f55 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/BulletedList.tsx @@ -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), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Checklist.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Checklist.tsx new file mode 100644 index 000000000..56940414d --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Checklist.tsx @@ -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), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Code.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Code.tsx new file mode 100644 index 000000000..39105d29c --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Code.tsx @@ -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) + } + } + }), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Collapsible.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Collapsible.tsx new file mode 100644 index 000000000..0df5337c7 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Collapsible.tsx @@ -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), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Divider.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Divider.tsx new file mode 100644 index 000000000..5a30ee8fd --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Divider.tsx @@ -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), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Embeds.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Embeds.tsx new file mode 100644 index 000000000..b4351769b --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Embeds.tsx @@ -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), + }), + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Headings.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Headings.tsx new file mode 100644 index 000000000..509136870 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Headings.tsx @@ -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)) + } + }), + }), + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/NumberedList.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/NumberedList.tsx new file mode 100644 index 000000000..4cd48b78e --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/NumberedList.tsx @@ -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), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Paragraph.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Paragraph.tsx new file mode 100644 index 000000000..c5b582d84 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Paragraph.tsx @@ -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()) + } + }), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Quote.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Quote.tsx new file mode 100644 index 000000000..f648871d4 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Quote.tsx @@ -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()) + } + }), + }) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Table.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Table.tsx new file mode 100644 index 000000000..65ca0c058 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/BlockPickerPlugin/Blocks/Table.tsx @@ -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 = [] + + 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 +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts new file mode 100644 index 000000000..f293c1497 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/FilePlugin.ts @@ -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( + INSERT_FILE_COMMAND, + (payload) => { + const fileNode = $createFileNode(payload) + $insertNodeToNearestRoot(fileNode) + + return true + }, + COMMAND_PRIORITY_EDITOR, + ) + }, [editor]) + + return null +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx new file mode 100644 index 000000000..6456d46eb --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileComponent.tsx @@ -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(fileUuid), [application, fileUuid]) + + if (!file) { + return
    Unable to find file {fileUuid}
    + } + + return ( + + + + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx new file mode 100644 index 000000000..ee08b737c --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileNode.tsx @@ -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 | 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 + } + + isInline(): false { + return false + } +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileUtils.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileUtils.tsx new file mode 100644 index 000000000..594de4e42 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/FileUtils.tsx @@ -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 +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/SerializedFileNode.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/SerializedFileNode.tsx new file mode 100644 index 000000000..7b0252fa0 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/EncryptedFilePlugin/Nodes/SerializedFileNode.tsx @@ -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 +> diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts new file mode 100644 index 000000000..2ac1c544f --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemOption.ts @@ -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 + keyboardShortcut?: string + onSelect: (queryString: string) => void + }, + ) { + super(item.title) + this.key = item.uuid + } +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx new file mode 100644 index 000000000..b84ef9e16 --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionItemComponent.tsx @@ -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 ( +
  • + +
  • + ) +} diff --git a/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx new file mode 100644 index 000000000..3938b64db --- /dev/null +++ b/packages/web/src/javascripts/Components/BlockEditor/Plugins/ItemSelectionPlugin/ItemSelectionPlugin.tsx @@ -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 = ({ currentNote }) => { + const application = useApplication() + + const [editor] = useLexicalComposerContext() + + const [queryString, setQueryString] = useState('') + + 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 ( + + 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 ( + { + setPopoverOpen((prevValue) => !prevValue) + }} + > +
    +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i) + selectOptionAndCleanUp(option) + }} + onMouseEnter={() => { + setHighlightedIndex(i) + }} + key={option.key} + option={option} + /> + ))} +
    +
    +
    + ) + }} + /> + ) +} diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx index d60981655..db770a7b3 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -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 = ({ 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 diff --git a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx index 0ad59b835..7c6da1d65 100644 --- a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx @@ -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> = ({ - application, filesController, hideDate, hideIcon, @@ -66,10 +66,7 @@ const FileListItem: FunctionComponent> = ({ }, [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) diff --git a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx index c5635741c..7d710a4bf 100644 --- a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx @@ -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> = ({ application, @@ -37,7 +38,7 @@ const NoteListItem: FunctionComponent> = ({ 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) => { diff --git a/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx b/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx index 0232eead3..3e23ee355 100644 --- a/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx +++ b/packages/web/src/javascripts/Components/FilePreview/FilePreviewModal.tsx @@ -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 = 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 ( diff --git a/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx b/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx index 40d432573..a205b70c7 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx @@ -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('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 = useCallback((event) => { if (event.key === KeyboardKey.Escape) { @@ -155,10 +150,8 @@ const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focus > setSearchQuery('')} diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx index 888da172d..da144e3cf 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx @@ -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 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(null) + const application = useApplication() const [showUnlinkButton, setShowUnlinkButton] = useState(false) const unlinkButtonRef = useRef(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) { diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx index b228a5777..7f0c85f4c 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx @@ -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) => { { - 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 ( diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx index 76410c9b7..c640d12aa 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx @@ -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 = ({ } }} > - + {cannotLinkItem && } ) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx index 61f158c2d..27acd7b95 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx @@ -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(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() }} > - + )} ))} diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index 6630457a6..a465dddfc 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -28,7 +28,10 @@ type DeletePermanentlyButtonProps = { const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (