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 {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 />
|
||||
|
||||
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/BlocksEditorComposer';
|
||||
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 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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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 ListedActionsMenu from './ListedActionsMenu'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import Popover from '../Popover/Popover'
|
||||
import Popover from '../../Popover/Popover'
|
||||
|
||||
type Props = {
|
||||
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 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,5 +10,6 @@ export type NotesOptionsProps = {
|
||||
notesController: NotesController
|
||||
linkingController: LinkingController
|
||||
historyModalController: HistoryModalController
|
||||
requestDisableClickOutside?: (disabled: boolean) => 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