feat: option to show markdown preview for super notes (skip e2e) (#2048)

This commit is contained in:
Mo
2022-11-23 11:22:01 -06:00
committed by GitHub
parent 99163d90d2
commit 8579ff39b1
28 changed files with 454 additions and 217 deletions

View File

@@ -7,12 +7,6 @@ import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
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 {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
@@ -32,15 +26,17 @@ import {truncateString} from './Utils';
import {SuperEditorContentId} from './Constants';
import {classNames} from '@standardnotes/utils';
import {EditorLineHeight} from '@standardnotes/snjs';
import {MarkdownTransformers} from './MarkdownTransformers';
type BlocksEditorProps = {
onChange: (value: string, preview: string) => void;
onChange?: (value: string, preview: string) => void;
className?: string;
children?: React.ReactNode;
previewLength: number;
previewLength?: number;
spellcheck?: boolean;
ignoreFirstChange?: boolean;
lineHeight?: EditorLineHeight;
readonly?: boolean;
};
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
@@ -51,6 +47,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
spellcheck,
ignoreFirstChange = false,
lineHeight,
readonly,
}) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false);
const handleChange = useCallback(
@@ -69,11 +66,14 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
previewText += '\n';
}
});
previewText = truncateString(previewText, previewLength);
if (previewLength) {
previewText = truncateString(previewText, previewLength);
}
try {
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
onChange(stringifiedEditorState, previewText);
onChange?.(stringifiedEditorState, previewText);
} catch (error) {
window.alert(
`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}
/>
<ListPlugin />
<MarkdownShortcutPlugin
transformers={[
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
]}
/>
<MarkdownShortcutPlugin transformers={MarkdownTransformers} />
<TablePlugin />
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
<HistoryPlugin />
@@ -138,7 +131,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<TwitterPlugin />
<YouTubePlugin />
<CollapsiblePlugin />
{floatingAnchorElem && (
{!readonly && floatingAnchorElem && (
<>
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin />

View 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,
];

View File

@@ -1,3 +1,4 @@
export * from './Editor/BlocksEditor';
export * from './Editor/BlocksEditorComposer';
export * from './Editor/Constants';
export * from './Editor/MarkdownTransformers';

View File

@@ -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 STAR_NOTE_COMMAND = createKeyboardCommand('STAR_NOTE_COMMAND')
export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND')
export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MARKDOWN_PREVIEW')

View File

@@ -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 {
this.commandMap.set(shortcut.command, shortcut)
}

View File

@@ -21,6 +21,7 @@ import {
CAPTURE_SAVE_COMMAND,
STAR_NOTE_COMMAND,
PIN_NOTE_COMMAND,
SUPER_SHOW_MARKDOWN_PREVIEW,
} from './KeyboardCommands'
import { KeyboardKey } from './KeyboardKey'
import { KeyboardModifier } from './KeyboardModifier'
@@ -132,5 +133,11 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme
modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true,
},
{
command: SUPER_SHOW_MARKDOWN_PREVIEW,
key: 'm',
modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true,
},
]
}

View File

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

View File

@@ -23,6 +23,9 @@ import {
} from './Plugins/ChangeContentCallback/ChangeContentCallback'
import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
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
@@ -44,6 +47,20 @@ export const SuperEditor: FunctionComponent<Props> = ({
const note = useRef(controller.item)
const changeEditorFunction = useRef<ChangeEditorFunction>()
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])
@@ -110,37 +127,41 @@ export const SuperEditor: FunctionComponent<Props> = ({
return (
<div className="relative h-full w-full">
<ErrorBoundary>
<LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}>
<BlocksEditorComposer
readonly={note.current.locked}
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}
<>
<LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}>
<BlocksEditorComposer
readonly={note.current.locked}
initialValue={note.current.text}
nodes={[FileNode, BubbleNode]}
>
<ItemSelectionPlugin currentNote={note.current} />
<FilePlugin />
<ItemBubblePlugin />
<BlockPickerMenuPlugin />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />
<ChangeContentCallbackPlugin
providerCallback={(callback) => (changeEditorFunction.current = callback)}
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>
</LinkingControllerProvider>
<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} />
<FilePlugin />
<ItemBubblePlugin />
<BlockPickerMenuPlugin />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />
<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>
</div>
)

View File

@@ -10,10 +10,7 @@ import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import ImportPlugin from './Plugins/ImportPlugin/ImportPlugin'
import { NoteViewController } from '../Controller/NoteViewController'
export function spaceSeparatedStrings(...strings: string[]): string {
return strings.join(' ')
}
import { spaceSeparatedStrings } from '../../../Utils/spaceSeparatedStrings'
const NotePreviewCharLimit = 160
@@ -103,6 +100,7 @@ export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application,
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={undefined}>
<BlocksEditor
readonly
onChange={handleChange}
ignoreFirstChange={false}
className="relative resize-none text-base focus:shadow-none focus:outline-none"

View File

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

View File

@@ -1,6 +1,6 @@
import { observer } from 'mobx-react-lite'
import NotesOptions from '@/Components/NotesOptions/NotesOptions'
import { useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { NotesController } from '@/Controllers/NotesController/NotesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
@@ -29,6 +29,11 @@ const NotesContextMenu = ({
const closeMenu = () => setContextMenuOpen(!contextMenuOpen)
const [disableClickOutside, setDisableClickOutside] = useState(false)
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
setDisableClickOutside(disabled)
}, [])
return (
<Popover
align="start"
@@ -36,6 +41,7 @@ const NotesContextMenu = ({
x: contextMenuClickLocation.x,
y: contextMenuClickLocation.y,
}}
disableClickOutside={disableClickOutside}
className="py-2"
open={contextMenuOpen}
togglePopover={closeMenu}
@@ -47,6 +53,7 @@ const NotesContextMenu = ({
notesController={notesController}
linkingController={linkingController}
historyModalController={historyModalController}
requestDisableClickOutside={handleDisableClickOutsideRequest}
closeMenu={closeMenu}
/>
</div>

View File

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

View File

@@ -4,7 +4,7 @@ import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import ListedActionsMenu from './ListedActionsMenu'
import { KeyboardKey } from '@standardnotes/ui-services'
import Popover from '../Popover/Popover'
import Popover from '../../Popover/Popover'
type Props = {
application: WebApplication

View File

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

View File

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

View File

@@ -1,23 +1,21 @@
import Icon from '@/Components/Icon/Icon'
import Switch from '@/Components/Switch/Switch'
import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo, useCallback, FunctionComponent } from 'react'
import { Platform, SNApplication, SNComponent, SNNote } from '@standardnotes/snjs'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { NoteType, Platform, SNNote } from '@standardnotes/snjs'
import {
OPEN_NOTE_HISTORY_COMMAND,
PIN_NOTE_COMMAND,
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
STAR_NOTE_COMMAND,
SUPER_SHOW_MARKDOWN_PREVIEW,
} from '@standardnotes/ui-services'
import ChangeEditorOption from './ChangeEditorOption'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
import ListedActionsOption from './ListedActionsOption'
import ListedActionsOption from './Listed/ListedActionsOption'
import AddTagOption from './AddTagOption'
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
import { NotesOptionsProps } from './NotesOptionsProps'
import { NotesController } from '@/Controllers/NotesController/NotesController'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
@@ -27,167 +25,18 @@ import ProtectedUnauthorizedLabel from '../ProtectedItemOverlay/ProtectedUnautho
import { classNames } from '@standardnotes/utils'
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
type DeletePermanentlyButtonProps = {
onClick: () => void
}
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>
)
import { NoteAttributes } from './NoteAttributes'
import { SpellcheckOptions } from './SpellcheckOptions'
import { NoteSizeWarning } from './NoteSizeWarning'
import { DeletePermanentlyButton } from './DeletePermanentlyButton'
import { useCommandService } from '../ApplicationView/CommandProvider'
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 iconClassWarning = `text-warning 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 = ({
application,
navigationController,
@@ -197,6 +46,12 @@ const NotesOptions = ({
}: NotesOptionsProps) => {
const [altKeyDown, setAltKeyDown] = useState(false)
const { toggleAppPane } = useResponsiveAppPane()
const commandService = useCommandService()
const markdownShortcut = useMemo(
() => commandService.keyboardShortcutForCommand(SUPER_SHOW_MARKDOWN_PREVIEW),
[commandService],
)
const toggleOn = (condition: (note: SNNote) => boolean) => {
const notesMatchingAttribute = notes.filter(condition)
@@ -295,6 +150,10 @@ const NotesOptions = ({
[application],
)
const enableSuperMarkdownPreview = useCallback(() => {
commandService.triggerCommand(SUPER_SHOW_MARKDOWN_PREVIEW)
}, [commandService])
const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note))
if (unauthorized) {
return <ProtectedUnauthorizedLabel />
@@ -532,6 +391,23 @@ const NotesOptions = ({
{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" />
<ListedActionsOption

View File

@@ -37,16 +37,28 @@ const NotesOptionsPanel = ({
setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen])
const [disableClickOutside, setDisableClickOutside] = useState(false)
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
setDisableClickOutside(disabled)
}, [])
return (
<>
<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
application={application}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}
historyModalController={historyModalController}
requestDisableClickOutside={handleDisableClickOutsideRequest}
closeMenu={toggleMenu}
/>
</Popover>

View File

@@ -10,5 +10,6 @@ export type NotesOptionsProps = {
notesController: NotesController
linkingController: LinkingController
historyModalController: HistoryModalController
requestDisableClickOutside?: (disabled: boolean) => void
closeMenu: () => void
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export const getParagraphCount = (text: string) => {
if (text.trim().length === 0) {
return 0
}
return text.replace(/\n$/gm, '').split(/\n/).length
}

View File

@@ -0,0 +1,6 @@
export const getWordCount = (text: string) => {
if (text.trim().length === 0) {
return 0
}
return text.split(/\s+/).length
}

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

View File

@@ -0,0 +1,3 @@
export function spaceSeparatedStrings(...strings: string[]): string {
return strings.join(' ')
}