feat: option to show markdown preview for super notes (skip e2e) (#2048)
This commit is contained in:
@@ -7,12 +7,6 @@ import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
|
|||||||
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
|
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
|
||||||
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
|
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
|
||||||
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
|
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
|
||||||
import {
|
|
||||||
CHECK_LIST,
|
|
||||||
ELEMENT_TRANSFORMERS,
|
|
||||||
TEXT_FORMAT_TRANSFORMERS,
|
|
||||||
TEXT_MATCH_TRANSFORMERS,
|
|
||||||
} from '@lexical/markdown';
|
|
||||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||||
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
|
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
|
||||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||||
@@ -32,15 +26,17 @@ import {truncateString} from './Utils';
|
|||||||
import {SuperEditorContentId} from './Constants';
|
import {SuperEditorContentId} from './Constants';
|
||||||
import {classNames} from '@standardnotes/utils';
|
import {classNames} from '@standardnotes/utils';
|
||||||
import {EditorLineHeight} from '@standardnotes/snjs';
|
import {EditorLineHeight} from '@standardnotes/snjs';
|
||||||
|
import {MarkdownTransformers} from './MarkdownTransformers';
|
||||||
|
|
||||||
type BlocksEditorProps = {
|
type BlocksEditorProps = {
|
||||||
onChange: (value: string, preview: string) => void;
|
onChange?: (value: string, preview: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
previewLength: number;
|
previewLength?: number;
|
||||||
spellcheck?: boolean;
|
spellcheck?: boolean;
|
||||||
ignoreFirstChange?: boolean;
|
ignoreFirstChange?: boolean;
|
||||||
lineHeight?: EditorLineHeight;
|
lineHeight?: EditorLineHeight;
|
||||||
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||||
@@ -51,6 +47,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
spellcheck,
|
spellcheck,
|
||||||
ignoreFirstChange = false,
|
ignoreFirstChange = false,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
readonly,
|
||||||
}) => {
|
}) => {
|
||||||
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
|
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@@ -69,11 +66,14 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
previewText += '\n';
|
previewText += '\n';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
previewText = truncateString(previewText, previewLength);
|
|
||||||
|
if (previewLength) {
|
||||||
|
previewText = truncateString(previewText, previewLength);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
|
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
|
||||||
onChange(stringifiedEditorState, previewText);
|
onChange?.(stringifiedEditorState, previewText);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(
|
window.alert(
|
||||||
`An invalid change was made inside the Super editor. Your change was not saved. Please report this error to the team: ${error}`,
|
`An invalid change was made inside the Super editor. Your change was not saved. Please report this error to the team: ${error}`,
|
||||||
@@ -116,14 +116,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
ErrorBoundary={LexicalErrorBoundary}
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
/>
|
/>
|
||||||
<ListPlugin />
|
<ListPlugin />
|
||||||
<MarkdownShortcutPlugin
|
<MarkdownShortcutPlugin transformers={MarkdownTransformers} />
|
||||||
transformers={[
|
|
||||||
CHECK_LIST,
|
|
||||||
...ELEMENT_TRANSFORMERS,
|
|
||||||
...TEXT_FORMAT_TRANSFORMERS,
|
|
||||||
...TEXT_MATCH_TRANSFORMERS,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<TablePlugin />
|
<TablePlugin />
|
||||||
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
|
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
@@ -138,7 +131,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
<TwitterPlugin />
|
<TwitterPlugin />
|
||||||
<YouTubePlugin />
|
<YouTubePlugin />
|
||||||
<CollapsiblePlugin />
|
<CollapsiblePlugin />
|
||||||
{floatingAnchorElem && (
|
{!readonly && floatingAnchorElem && (
|
||||||
<>
|
<>
|
||||||
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
|
||||||
<FloatingLinkEditorPlugin />
|
<FloatingLinkEditorPlugin />
|
||||||
|
|||||||
13
packages/blocks-editor/src/Editor/MarkdownTransformers.ts
Normal file
13
packages/blocks-editor/src/Editor/MarkdownTransformers.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
CHECK_LIST,
|
||||||
|
ELEMENT_TRANSFORMERS,
|
||||||
|
TEXT_FORMAT_TRANSFORMERS,
|
||||||
|
TEXT_MATCH_TRANSFORMERS,
|
||||||
|
} from '@lexical/markdown';
|
||||||
|
|
||||||
|
export const MarkdownTransformers = [
|
||||||
|
CHECK_LIST,
|
||||||
|
...ELEMENT_TRANSFORMERS,
|
||||||
|
...TEXT_FORMAT_TRANSFORMERS,
|
||||||
|
...TEXT_MATCH_TRANSFORMERS,
|
||||||
|
];
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './Editor/BlocksEditor';
|
export * from './Editor/BlocksEditor';
|
||||||
export * from './Editor/BlocksEditorComposer';
|
export * from './Editor/BlocksEditorComposer';
|
||||||
export * from './Editor/Constants';
|
export * from './Editor/Constants';
|
||||||
|
export * from './Editor/MarkdownTransformers';
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ export const OPEN_NOTE_HISTORY_COMMAND = createKeyboardCommand('OPEN_NOTE_HISTOR
|
|||||||
export const CAPTURE_SAVE_COMMAND = createKeyboardCommand('CAPTURE_SAVE_COMMAND')
|
export const CAPTURE_SAVE_COMMAND = createKeyboardCommand('CAPTURE_SAVE_COMMAND')
|
||||||
export const STAR_NOTE_COMMAND = createKeyboardCommand('STAR_NOTE_COMMAND')
|
export const STAR_NOTE_COMMAND = createKeyboardCommand('STAR_NOTE_COMMAND')
|
||||||
export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND')
|
export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND')
|
||||||
|
export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MARKDOWN_PREVIEW')
|
||||||
|
|||||||
@@ -165,6 +165,22 @@ export class KeyboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public triggerCommand(command: KeyboardCommand): void {
|
||||||
|
for (const observer of this.commandHandlers) {
|
||||||
|
if (observer.command !== command) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = observer.onKeyDown || observer.onKeyUp
|
||||||
|
if (callback) {
|
||||||
|
const exclusive = callback(new KeyboardEvent('command-trigger'))
|
||||||
|
if (exclusive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerShortcut(shortcut: KeyboardShortcut): void {
|
registerShortcut(shortcut: KeyboardShortcut): void {
|
||||||
this.commandMap.set(shortcut.command, shortcut)
|
this.commandMap.set(shortcut.command, shortcut)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
CAPTURE_SAVE_COMMAND,
|
CAPTURE_SAVE_COMMAND,
|
||||||
STAR_NOTE_COMMAND,
|
STAR_NOTE_COMMAND,
|
||||||
PIN_NOTE_COMMAND,
|
PIN_NOTE_COMMAND,
|
||||||
|
SUPER_SHOW_MARKDOWN_PREVIEW,
|
||||||
} from './KeyboardCommands'
|
} from './KeyboardCommands'
|
||||||
import { KeyboardKey } from './KeyboardKey'
|
import { KeyboardKey } from './KeyboardKey'
|
||||||
import { KeyboardModifier } from './KeyboardModifier'
|
import { KeyboardModifier } from './KeyboardModifier'
|
||||||
@@ -132,5 +133,11 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme
|
|||||||
modifiers: [primaryModifier, KeyboardModifier.Shift],
|
modifiers: [primaryModifier, KeyboardModifier.Shift],
|
||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
command: SUPER_SHOW_MARKDOWN_PREVIEW,
|
||||||
|
key: 'm',
|
||||||
|
modifiers: [primaryModifier, KeyboardModifier.Shift],
|
||||||
|
preventDefault: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { $createCodeNode } from '@lexical/code'
|
||||||
|
import { $createTextNode, $getRoot } from 'lexical'
|
||||||
|
import { $convertToMarkdownString } from '@lexical/markdown'
|
||||||
|
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onMarkdown: (markdown: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownPreviewPlugin({ onMarkdown }: Props): JSX.Element | null {
|
||||||
|
const [editor] = useLexicalComposerContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot()
|
||||||
|
const markdown = $convertToMarkdownString(MarkdownTransformers)
|
||||||
|
root.clear().append($createCodeNode('markdown').append($createTextNode(markdown)))
|
||||||
|
root.selectEnd()
|
||||||
|
onMarkdown(markdown)
|
||||||
|
})
|
||||||
|
}, [editor, onMarkdown])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
} from './Plugins/ChangeContentCallback/ChangeContentCallback'
|
} from './Plugins/ChangeContentCallback/ChangeContentCallback'
|
||||||
import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
|
import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
|
||||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||||
|
import { useCommandService } from '@/Components/ApplicationView/CommandProvider'
|
||||||
|
import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services'
|
||||||
|
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
|
||||||
|
|
||||||
const NotePreviewCharLimit = 160
|
const NotePreviewCharLimit = 160
|
||||||
|
|
||||||
@@ -44,6 +47,20 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
const note = useRef(controller.item)
|
const note = useRef(controller.item)
|
||||||
const changeEditorFunction = useRef<ChangeEditorFunction>()
|
const changeEditorFunction = useRef<ChangeEditorFunction>()
|
||||||
const ignoreNextChange = useRef(false)
|
const ignoreNextChange = useRef(false)
|
||||||
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||||
|
|
||||||
|
const commandService = useCommandService()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return commandService.addCommandHandler({
|
||||||
|
command: SUPER_SHOW_MARKDOWN_PREVIEW,
|
||||||
|
onKeyDown: () => setShowMarkdownPreview(true),
|
||||||
|
})
|
||||||
|
}, [commandService])
|
||||||
|
|
||||||
|
const closeMarkdownPreview = useCallback(() => {
|
||||||
|
setShowMarkdownPreview(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [lineHeight, setLineHeight] = useState<EditorLineHeight>(PrefDefaults[PrefKey.EditorLineHeight])
|
const [lineHeight, setLineHeight] = useState<EditorLineHeight>(PrefDefaults[PrefKey.EditorLineHeight])
|
||||||
|
|
||||||
@@ -110,37 +127,41 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<LinkingControllerProvider controller={linkingController}>
|
<>
|
||||||
<FilesControllerProvider controller={filesController}>
|
<LinkingControllerProvider controller={linkingController}>
|
||||||
<BlocksEditorComposer
|
<FilesControllerProvider controller={filesController}>
|
||||||
readonly={note.current.locked}
|
<BlocksEditorComposer
|
||||||
initialValue={note.current.text}
|
readonly={note.current.locked}
|
||||||
nodes={[FileNode, BubbleNode]}
|
initialValue={note.current.text}
|
||||||
>
|
nodes={[FileNode, BubbleNode]}
|
||||||
<BlocksEditor
|
|
||||||
onChange={handleChange}
|
|
||||||
ignoreFirstChange={true}
|
|
||||||
className="relative h-full resize-none px-6 py-4 text-base focus:shadow-none focus:outline-none"
|
|
||||||
previewLength={NotePreviewCharLimit}
|
|
||||||
spellcheck={spellcheck}
|
|
||||||
lineHeight={lineHeight}
|
|
||||||
>
|
>
|
||||||
<ItemSelectionPlugin currentNote={note.current} />
|
<BlocksEditor
|
||||||
<FilePlugin />
|
onChange={handleChange}
|
||||||
<ItemBubblePlugin />
|
ignoreFirstChange={true}
|
||||||
<BlockPickerMenuPlugin />
|
className="relative h-full resize-none px-6 py-4 text-base focus:shadow-none focus:outline-none"
|
||||||
<DatetimePlugin />
|
previewLength={NotePreviewCharLimit}
|
||||||
<PasswordPlugin />
|
spellcheck={spellcheck}
|
||||||
<AutoLinkPlugin />
|
lineHeight={lineHeight}
|
||||||
<ChangeContentCallbackPlugin
|
>
|
||||||
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
<ItemSelectionPlugin currentNote={note.current} />
|
||||||
/>
|
<FilePlugin />
|
||||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
<ItemBubblePlugin />
|
||||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
<BlockPickerMenuPlugin />
|
||||||
</BlocksEditor>
|
<DatetimePlugin />
|
||||||
</BlocksEditorComposer>
|
<PasswordPlugin />
|
||||||
</FilesControllerProvider>
|
<AutoLinkPlugin />
|
||||||
</LinkingControllerProvider>
|
<ChangeContentCallbackPlugin
|
||||||
|
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
||||||
|
/>
|
||||||
|
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||||
|
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||||
|
</BlocksEditor>
|
||||||
|
</BlocksEditorComposer>
|
||||||
|
</FilesControllerProvider>
|
||||||
|
</LinkingControllerProvider>
|
||||||
|
|
||||||
|
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
|
||||||
|
</>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
|||||||
import Button from '@/Components/Button/Button'
|
import Button from '@/Components/Button/Button'
|
||||||
import ImportPlugin from './Plugins/ImportPlugin/ImportPlugin'
|
import ImportPlugin from './Plugins/ImportPlugin/ImportPlugin'
|
||||||
import { NoteViewController } from '../Controller/NoteViewController'
|
import { NoteViewController } from '../Controller/NoteViewController'
|
||||||
|
import { spaceSeparatedStrings } from '../../../Utils/spaceSeparatedStrings'
|
||||||
export function spaceSeparatedStrings(...strings: string[]): string {
|
|
||||||
return strings.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotePreviewCharLimit = 160
|
const NotePreviewCharLimit = 160
|
||||||
|
|
||||||
@@ -103,6 +100,7 @@ export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application,
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BlocksEditorComposer readonly initialValue={undefined}>
|
<BlocksEditorComposer readonly initialValue={undefined}>
|
||||||
<BlocksEditor
|
<BlocksEditor
|
||||||
|
readonly
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
ignoreFirstChange={false}
|
ignoreFirstChange={false}
|
||||||
className="relative resize-none text-base focus:shadow-none focus:outline-none"
|
className="relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { SNNote } from '@standardnotes/snjs'
|
||||||
|
import { FunctionComponent, useCallback, useState } from 'react'
|
||||||
|
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
|
||||||
|
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
|
||||||
|
import ModalDialog from '@/Components/Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import MarkdownPreviewPlugin from './Plugins/MarkdownPreviewPlugin/MarkdownPreviewPlugin'
|
||||||
|
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
||||||
|
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
|
||||||
|
import { copyTextToClipboard } from '../../../Utils/copyTextToClipboard'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
note: SNNote
|
||||||
|
closeDialog: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SuperNoteMarkdownPreview: FunctionComponent<Props> = ({ note, closeDialog }) => {
|
||||||
|
const [markdown, setMarkdown] = useState('')
|
||||||
|
const [didCopy, setDidCopy] = useState(false)
|
||||||
|
|
||||||
|
const copy = useCallback(() => {
|
||||||
|
copyTextToClipboard(markdown)
|
||||||
|
setDidCopy(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setDidCopy(false)
|
||||||
|
}, 1500)
|
||||||
|
}, [markdown])
|
||||||
|
|
||||||
|
const onMarkdown = useCallback((markdown: string) => {
|
||||||
|
setMarkdown(markdown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={closeDialog}>Markdown Preview</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BlocksEditorComposer readonly initialValue={note.text} nodes={[FileNode, BubbleNode]}>
|
||||||
|
<BlocksEditor
|
||||||
|
readonly
|
||||||
|
className="relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||||
|
spellcheck={note.spellcheck}
|
||||||
|
>
|
||||||
|
<MarkdownPreviewPlugin onMarkdown={onMarkdown} />
|
||||||
|
</BlocksEditor>
|
||||||
|
</BlocksEditorComposer>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
<div className="flex">
|
||||||
|
<Button onClick={closeDialog}>Close</Button>
|
||||||
|
<div className="min-w-3" />
|
||||||
|
<Button primary onClick={copy}>
|
||||||
|
{didCopy ? 'Copied' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import NotesOptions from '@/Components/NotesOptions/NotesOptions'
|
import NotesOptions from '@/Components/NotesOptions/NotesOptions'
|
||||||
import { useRef } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { WebApplication } from '@/Application/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||||
@@ -29,6 +29,11 @@ const NotesContextMenu = ({
|
|||||||
|
|
||||||
const closeMenu = () => setContextMenuOpen(!contextMenuOpen)
|
const closeMenu = () => setContextMenuOpen(!contextMenuOpen)
|
||||||
|
|
||||||
|
const [disableClickOutside, setDisableClickOutside] = useState(false)
|
||||||
|
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
|
||||||
|
setDisableClickOutside(disabled)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
align="start"
|
align="start"
|
||||||
@@ -36,6 +41,7 @@ const NotesContextMenu = ({
|
|||||||
x: contextMenuClickLocation.x,
|
x: contextMenuClickLocation.x,
|
||||||
y: contextMenuClickLocation.y,
|
y: contextMenuClickLocation.y,
|
||||||
}}
|
}}
|
||||||
|
disableClickOutside={disableClickOutside}
|
||||||
className="py-2"
|
className="py-2"
|
||||||
open={contextMenuOpen}
|
open={contextMenuOpen}
|
||||||
togglePopover={closeMenu}
|
togglePopover={closeMenu}
|
||||||
@@ -47,6 +53,7 @@ const NotesContextMenu = ({
|
|||||||
notesController={notesController}
|
notesController={notesController}
|
||||||
linkingController={linkingController}
|
linkingController={linkingController}
|
||||||
historyModalController={historyModalController}
|
historyModalController={historyModalController}
|
||||||
|
requestDisableClickOutside={handleDisableClickOutsideRequest}
|
||||||
closeMenu={closeMenu}
|
closeMenu={closeMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import { classNames } from '@standardnotes/utils'
|
||||||
|
|
||||||
|
type DeletePermanentlyButtonProps = {
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
|
||||||
|
<button
|
||||||
|
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" />
|
||||||
|
<span className="text-danger">Delete permanently</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ import { FunctionComponent, useCallback, useRef, useState } from 'react'
|
|||||||
import Icon from '@/Components/Icon/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import ListedActionsMenu from './ListedActionsMenu'
|
import ListedActionsMenu from './ListedActionsMenu'
|
||||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||||
import Popover from '../Popover/Popover'
|
import Popover from '../../Popover/Popover'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo, FunctionComponent } from 'react'
|
||||||
|
import { SNApplication, SNNote } from '@standardnotes/snjs'
|
||||||
|
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||||
|
import { calculateReadTime } from './Utils/calculateReadTime'
|
||||||
|
import { countNoteAttributes } from './Utils/countNoteAttributes'
|
||||||
|
|
||||||
|
export const NoteAttributes: FunctionComponent<{
|
||||||
|
application: SNApplication
|
||||||
|
note: SNNote
|
||||||
|
}> = ({ application, note }) => {
|
||||||
|
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
|
||||||
|
|
||||||
|
const readTime = useMemo(() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), [words])
|
||||||
|
|
||||||
|
const dateLastModified = useMemo(() => formatDateForContextMenu(note.userModifiedDate), [note.userModifiedDate])
|
||||||
|
|
||||||
|
const dateCreated = useMemo(() => formatDateForContextMenu(note.created_at), [note.created_at])
|
||||||
|
|
||||||
|
const editor = application.componentManager.editorForNote(note)
|
||||||
|
const format = editor?.package_info?.file_type || 'txt'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs">
|
||||||
|
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-1">
|
||||||
|
{words} words · {characters} characters · {paragraphs} paragraphs
|
||||||
|
</div>
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-semibold">Read time:</span> {readTime}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-semibold">Last modified:</span> {dateLastModified}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-semibold">Created:</span> {dateCreated}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Note ID:</span> {note.uuid}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import { SNNote } from '@standardnotes/snjs'
|
||||||
|
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
||||||
|
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||||
|
|
||||||
|
export const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
|
||||||
|
|
||||||
|
export const NoteSizeWarning: FunctionComponent<{
|
||||||
|
note: SNNote
|
||||||
|
}> = ({ note }) => {
|
||||||
|
return new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
||||||
|
<>
|
||||||
|
<HorizontalSeparator classes="my-2" />
|
||||||
|
<div className="bg-warning-faded relative flex items-center px-3 py-3.5">
|
||||||
|
<Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" />
|
||||||
|
<div className="leading-140% max-w-80% select-none text-warning">
|
||||||
|
This note may have trouble syncing to the mobile application due to its size.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
import Icon from '@/Components/Icon/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import Switch from '@/Components/Switch/Switch'
|
import Switch from '@/Components/Switch/Switch'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { useState, useEffect, useMemo, useCallback, FunctionComponent } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { Platform, SNApplication, SNComponent, SNNote } from '@standardnotes/snjs'
|
import { NoteType, Platform, SNNote } from '@standardnotes/snjs'
|
||||||
import {
|
import {
|
||||||
OPEN_NOTE_HISTORY_COMMAND,
|
OPEN_NOTE_HISTORY_COMMAND,
|
||||||
PIN_NOTE_COMMAND,
|
PIN_NOTE_COMMAND,
|
||||||
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
|
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
|
||||||
STAR_NOTE_COMMAND,
|
STAR_NOTE_COMMAND,
|
||||||
|
SUPER_SHOW_MARKDOWN_PREVIEW,
|
||||||
} from '@standardnotes/ui-services'
|
} from '@standardnotes/ui-services'
|
||||||
import ChangeEditorOption from './ChangeEditorOption'
|
import ChangeEditorOption from './ChangeEditorOption'
|
||||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
import ListedActionsOption from './Listed/ListedActionsOption'
|
||||||
import ListedActionsOption from './ListedActionsOption'
|
|
||||||
import AddTagOption from './AddTagOption'
|
import AddTagOption from './AddTagOption'
|
||||||
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
|
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
|
||||||
import { NotesOptionsProps } from './NotesOptionsProps'
|
import { NotesOptionsProps } from './NotesOptionsProps'
|
||||||
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
|
||||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
|
||||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||||
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||||
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||||
@@ -27,167 +25,18 @@ import ProtectedUnauthorizedLabel from '../ProtectedItemOverlay/ProtectedUnautho
|
|||||||
import { classNames } from '@standardnotes/utils'
|
import { classNames } from '@standardnotes/utils'
|
||||||
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
||||||
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
|
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
|
||||||
|
import { NoteAttributes } from './NoteAttributes'
|
||||||
type DeletePermanentlyButtonProps = {
|
import { SpellcheckOptions } from './SpellcheckOptions'
|
||||||
onClick: () => void
|
import { NoteSizeWarning } from './NoteSizeWarning'
|
||||||
}
|
import { DeletePermanentlyButton } from './DeletePermanentlyButton'
|
||||||
|
import { useCommandService } from '../ApplicationView/CommandProvider'
|
||||||
const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
|
|
||||||
<button
|
|
||||||
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" />
|
|
||||||
<span className="text-danger">Delete permanently</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
const iconSize = MenuItemIconSize
|
const iconSize = MenuItemIconSize
|
||||||
const iconClass = `text-neutral mr-2 ${iconSize}`
|
export const iconClass = `text-neutral mr-2 ${iconSize}`
|
||||||
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
||||||
const iconClassWarning = `text-warning mr-2 ${iconSize}`
|
const iconClassWarning = `text-warning mr-2 ${iconSize}`
|
||||||
const iconClassSuccess = `text-success mr-2 ${iconSize}`
|
const iconClassSuccess = `text-success mr-2 ${iconSize}`
|
||||||
|
|
||||||
const getWordCount = (text: string) => {
|
|
||||||
if (text.trim().length === 0) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return text.split(/\s+/).length
|
|
||||||
}
|
|
||||||
|
|
||||||
const getParagraphCount = (text: string) => {
|
|
||||||
if (text.trim().length === 0) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return text.replace(/\n$/gm, '').split(/\n/).length
|
|
||||||
}
|
|
||||||
|
|
||||||
const countNoteAttributes = (text: string) => {
|
|
||||||
try {
|
|
||||||
JSON.parse(text)
|
|
||||||
return {
|
|
||||||
characters: 'N/A',
|
|
||||||
words: 'N/A',
|
|
||||||
paragraphs: 'N/A',
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const characters = text.length
|
|
||||||
const words = getWordCount(text)
|
|
||||||
const paragraphs = getParagraphCount(text)
|
|
||||||
|
|
||||||
return {
|
|
||||||
characters,
|
|
||||||
words,
|
|
||||||
paragraphs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateReadTime = (words: number) => {
|
|
||||||
const timeToRead = Math.round(words / 200)
|
|
||||||
if (timeToRead === 0) {
|
|
||||||
return '< 1 minute'
|
|
||||||
} else {
|
|
||||||
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const NoteAttributes: FunctionComponent<{
|
|
||||||
application: SNApplication
|
|
||||||
note: SNNote
|
|
||||||
}> = ({ application, note }) => {
|
|
||||||
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
|
|
||||||
|
|
||||||
const readTime = useMemo(() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), [words])
|
|
||||||
|
|
||||||
const dateLastModified = useMemo(() => formatDateForContextMenu(note.userModifiedDate), [note.userModifiedDate])
|
|
||||||
|
|
||||||
const dateCreated = useMemo(() => formatDateForContextMenu(note.created_at), [note.created_at])
|
|
||||||
|
|
||||||
const editor = application.componentManager.editorForNote(note)
|
|
||||||
const format = editor?.package_info?.file_type || 'txt'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs">
|
|
||||||
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-1">
|
|
||||||
{words} words · {characters} characters · {paragraphs} paragraphs
|
|
||||||
</div>
|
|
||||||
<div className="mb-1">
|
|
||||||
<span className="font-semibold">Read time:</span> {readTime}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<div className="mb-1">
|
|
||||||
<span className="font-semibold">Last modified:</span> {dateLastModified}
|
|
||||||
</div>
|
|
||||||
<div className="mb-1">
|
|
||||||
<span className="font-semibold">Created:</span> {dateCreated}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold">Note ID:</span> {note.uuid}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SpellcheckOptions: FunctionComponent<{
|
|
||||||
editorForNote: SNComponent | undefined
|
|
||||||
notesController: NotesController
|
|
||||||
note: SNNote
|
|
||||||
className: string
|
|
||||||
}> = ({ editorForNote, notesController, note, className }) => {
|
|
||||||
const spellcheckControllable = Boolean(!editorForNote || editorForNote.package_info.spellcheckControl)
|
|
||||||
const noteSpellcheck = !spellcheckControllable
|
|
||||||
? true
|
|
||||||
: note
|
|
||||||
? notesController.getSpellcheckStateForNote(note)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<button
|
|
||||||
className={className}
|
|
||||||
onClick={() => {
|
|
||||||
notesController.toggleGlobalSpellcheckForNote(note).catch(console.error)
|
|
||||||
}}
|
|
||||||
disabled={!spellcheckControllable}
|
|
||||||
>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<Icon type="notes" className={iconClass} />
|
|
||||||
Spellcheck
|
|
||||||
</span>
|
|
||||||
<Switch className="px-0" checked={noteSpellcheck} disabled={!spellcheckControllable} />
|
|
||||||
</button>
|
|
||||||
{!spellcheckControllable && (
|
|
||||||
<p className="px-3 py-1.5 text-xs">Spellcheck cannot be controlled for this editor.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
|
|
||||||
|
|
||||||
const NoteSizeWarning: FunctionComponent<{
|
|
||||||
note: SNNote
|
|
||||||
}> = ({ note }) => {
|
|
||||||
return new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
|
||||||
<>
|
|
||||||
<HorizontalSeparator classes="my-2" />
|
|
||||||
<div className="bg-warning-faded relative flex items-center px-3 py-3.5">
|
|
||||||
<Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" />
|
|
||||||
<div className="leading-140% max-w-80% select-none text-warning">
|
|
||||||
This note may have trouble syncing to the mobile application due to its size.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotesOptions = ({
|
const NotesOptions = ({
|
||||||
application,
|
application,
|
||||||
navigationController,
|
navigationController,
|
||||||
@@ -197,6 +46,12 @@ const NotesOptions = ({
|
|||||||
}: NotesOptionsProps) => {
|
}: NotesOptionsProps) => {
|
||||||
const [altKeyDown, setAltKeyDown] = useState(false)
|
const [altKeyDown, setAltKeyDown] = useState(false)
|
||||||
const { toggleAppPane } = useResponsiveAppPane()
|
const { toggleAppPane } = useResponsiveAppPane()
|
||||||
|
const commandService = useCommandService()
|
||||||
|
|
||||||
|
const markdownShortcut = useMemo(
|
||||||
|
() => commandService.keyboardShortcutForCommand(SUPER_SHOW_MARKDOWN_PREVIEW),
|
||||||
|
[commandService],
|
||||||
|
)
|
||||||
|
|
||||||
const toggleOn = (condition: (note: SNNote) => boolean) => {
|
const toggleOn = (condition: (note: SNNote) => boolean) => {
|
||||||
const notesMatchingAttribute = notes.filter(condition)
|
const notesMatchingAttribute = notes.filter(condition)
|
||||||
@@ -295,6 +150,10 @@ const NotesOptions = ({
|
|||||||
[application],
|
[application],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const enableSuperMarkdownPreview = useCallback(() => {
|
||||||
|
commandService.triggerCommand(SUPER_SHOW_MARKDOWN_PREVIEW)
|
||||||
|
}, [commandService])
|
||||||
|
|
||||||
const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note))
|
const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note))
|
||||||
if (unauthorized) {
|
if (unauthorized) {
|
||||||
return <ProtectedUnauthorizedLabel />
|
return <ProtectedUnauthorizedLabel />
|
||||||
@@ -532,6 +391,23 @@ const NotesOptions = ({
|
|||||||
|
|
||||||
{notes.length === 1 ? (
|
{notes.length === 1 ? (
|
||||||
<>
|
<>
|
||||||
|
{notes[0].noteType === NoteType.Super && (
|
||||||
|
<>
|
||||||
|
<HorizontalSeparator classes="my-2" />
|
||||||
|
|
||||||
|
<div className="my-1 px-3 text-base font-semibold uppercase text-text lg:text-xs">Super</div>
|
||||||
|
|
||||||
|
<button className={defaultClassNames} onClick={enableSuperMarkdownPreview}>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span className="flex">
|
||||||
|
<Icon type="markdown" className={iconClass} />
|
||||||
|
Show Markdown
|
||||||
|
</span>
|
||||||
|
{markdownShortcut && <KeyboardShortcutIndicator className={''} shortcut={markdownShortcut} />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<HorizontalSeparator classes="my-2" />
|
<HorizontalSeparator classes="my-2" />
|
||||||
|
|
||||||
<ListedActionsOption
|
<ListedActionsOption
|
||||||
|
|||||||
@@ -37,16 +37,28 @@ const NotesOptionsPanel = ({
|
|||||||
setIsOpen(willMenuOpen)
|
setIsOpen(willMenuOpen)
|
||||||
}, [onClickPreprocessing, isOpen])
|
}, [onClickPreprocessing, isOpen])
|
||||||
|
|
||||||
|
const [disableClickOutside, setDisableClickOutside] = useState(false)
|
||||||
|
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
|
||||||
|
setDisableClickOutside(disabled)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoundIconButton label="Note options menu" onClick={toggleMenu} ref={buttonRef} icon="more" />
|
<RoundIconButton label="Note options menu" onClick={toggleMenu} ref={buttonRef} icon="more" />
|
||||||
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="select-none">
|
<Popover
|
||||||
|
disableClickOutside={disableClickOutside}
|
||||||
|
togglePopover={toggleMenu}
|
||||||
|
anchorElement={buttonRef.current}
|
||||||
|
open={isOpen}
|
||||||
|
className="select-none"
|
||||||
|
>
|
||||||
<NotesOptions
|
<NotesOptions
|
||||||
application={application}
|
application={application}
|
||||||
navigationController={navigationController}
|
navigationController={navigationController}
|
||||||
notesController={notesController}
|
notesController={notesController}
|
||||||
linkingController={linkingController}
|
linkingController={linkingController}
|
||||||
historyModalController={historyModalController}
|
historyModalController={historyModalController}
|
||||||
|
requestDisableClickOutside={handleDisableClickOutsideRequest}
|
||||||
closeMenu={toggleMenu}
|
closeMenu={toggleMenu}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export type NotesOptionsProps = {
|
|||||||
notesController: NotesController
|
notesController: NotesController
|
||||||
linkingController: LinkingController
|
linkingController: LinkingController
|
||||||
historyModalController: HistoryModalController
|
historyModalController: HistoryModalController
|
||||||
|
requestDisableClickOutside?: (disabled: boolean) => void
|
||||||
closeMenu: () => void
|
closeMenu: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import Switch from '@/Components/Switch/Switch'
|
||||||
|
import { FunctionComponent } from 'react'
|
||||||
|
import { SNComponent, SNNote } from '@standardnotes/snjs'
|
||||||
|
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||||
|
import { iconClass } from './NotesOptions'
|
||||||
|
|
||||||
|
export const SpellcheckOptions: FunctionComponent<{
|
||||||
|
editorForNote: SNComponent | undefined
|
||||||
|
notesController: NotesController
|
||||||
|
note: SNNote
|
||||||
|
className: string
|
||||||
|
}> = ({ editorForNote, notesController, note, className }) => {
|
||||||
|
const spellcheckControllable = Boolean(!editorForNote || editorForNote.package_info.spellcheckControl)
|
||||||
|
const noteSpellcheck = !spellcheckControllable
|
||||||
|
? true
|
||||||
|
: note
|
||||||
|
? notesController.getSpellcheckStateForNote(note)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
className={className}
|
||||||
|
onClick={() => {
|
||||||
|
notesController.toggleGlobalSpellcheckForNote(note).catch(console.error)
|
||||||
|
}}
|
||||||
|
disabled={!spellcheckControllable}
|
||||||
|
>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Icon type="notes" className={iconClass} />
|
||||||
|
Spellcheck
|
||||||
|
</span>
|
||||||
|
<Switch className="px-0" checked={noteSpellcheck} disabled={!spellcheckControllable} />
|
||||||
|
</button>
|
||||||
|
{!spellcheckControllable && (
|
||||||
|
<p className="px-3 py-1.5 text-xs">Spellcheck cannot be controlled for this editor.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const calculateReadTime = (words: number) => {
|
||||||
|
const timeToRead = Math.round(words / 200)
|
||||||
|
if (timeToRead === 0) {
|
||||||
|
return '< 1 minute'
|
||||||
|
} else {
|
||||||
|
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getWordCount } from './getWordCount'
|
||||||
|
import { getParagraphCount } from './getParagraphCount'
|
||||||
|
|
||||||
|
export const countNoteAttributes = (text: string) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(text)
|
||||||
|
return {
|
||||||
|
characters: 'N/A',
|
||||||
|
words: 'N/A',
|
||||||
|
paragraphs: 'N/A',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const characters = text.length
|
||||||
|
const words = getWordCount(text)
|
||||||
|
const paragraphs = getParagraphCount(text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters,
|
||||||
|
words,
|
||||||
|
paragraphs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const getParagraphCount = (text: string) => {
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return text.replace(/\n$/gm, '').split(/\n/).length
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const getWordCount = (text: string) => {
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return text.split(/\s+/).length
|
||||||
|
}
|
||||||
26
packages/web/src/javascripts/Utils/copyTextToClipboard.tsx
Normal file
26
packages/web/src/javascripts/Utils/copyTextToClipboard.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export function fallbackCopyTextToClipboard(text: string) {
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = text
|
||||||
|
textArea.style.top = '0'
|
||||||
|
textArea.style.left = '0'
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.focus()
|
||||||
|
textArea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Unable to copy', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyTextToClipboard(text: string) {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
fallbackCopyTextToClipboard(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function spaceSeparatedStrings(...strings: string[]): string {
|
||||||
|
return strings.join(' ')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user