fix: super note improvements (#1991)

* fix: super note previews

* fix; checkmark size

* fix: top padding

* fix: prevent delete shortcut

* fix: spellcheck control

* fix: only embed file if uploaded to current note

* fix: ability to create new tag from editor autocomplete

* feat: protected file embed handling

* fix: event payload
This commit is contained in:
Mo
2022-11-10 09:35:53 -06:00
committed by GitHub
parent 0cbc23f740
commit 2dbc89594e
25 changed files with 261 additions and 90 deletions

View File

@@ -18,7 +18,7 @@ import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin'; import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin'; import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {EditorState, LexicalEditor} from 'lexical'; import {$getRoot, EditorState, LexicalEditor} from 'lexical';
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin'; import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin'; import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin'; import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
@@ -28,24 +28,42 @@ import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin'; import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin';
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin'; import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin';
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin'; import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin';
import {truncateString} from './Utils';
import {SuperEditorContentId} from './Constants';
const BlockDragEnabled = false; const BlockDragEnabled = false;
type BlocksEditorProps = { type BlocksEditorProps = {
onChange: (value: string) => void; onChange: (value: string, preview: string) => void;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
previewLength: number;
spellcheck?: boolean;
}; };
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
onChange, onChange,
className, className,
children, children,
previewLength,
spellcheck,
}) => { }) => {
const handleChange = useCallback( const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => { (editorState: EditorState, _editor: LexicalEditor) => {
const stringifiedEditorState = JSON.stringify(editorState.toJSON()); editorState.read(() => {
onChange(stringifiedEditorState); const childrenNodes = $getRoot().getAllTextNodes().slice(0, 2);
let previewText = '';
childrenNodes.forEach((node, index) => {
previewText += node.getTextContent();
if (index !== childrenNodes.length - 1) {
previewText += '\n';
}
});
previewText = truncateString(previewText, previewLength);
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
onChange(stringifiedEditorState, previewText);
});
}, },
[onChange], [onChange],
); );
@@ -67,7 +85,9 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<div id="blocks-editor" className="editor-scroller"> <div id="blocks-editor" className="editor-scroller">
<div className="editor" ref={onRef}> <div className="editor" ref={onRef}>
<ContentEditable <ContentEditable
id={SuperEditorContentId}
className={`ContentEditable__root ${className}`} className={`ContentEditable__root ${className}`}
spellCheck={spellcheck}
/> />
</div> </div>
</div> </div>
@@ -85,7 +105,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
]} ]}
/> />
<TablePlugin /> <TablePlugin />
<OnChangePlugin onChange={handleChange} /> <OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
<HistoryPlugin /> <HistoryPlugin />
<HorizontalRulePlugin /> <HorizontalRulePlugin />
<AutoFocusPlugin /> <AutoFocusPlugin />

View File

@@ -0,0 +1 @@
export const SuperEditorContentId = 'super-editor-content';

View File

@@ -0,0 +1,7 @@
export function truncateString(string: string, limit: number) {
if (string.length <= limit) {
return string;
} else {
return string.substring(0, limit) + '...';
}
}

View File

@@ -6,8 +6,6 @@
* *
*/ */
@import 'https://fonts.googleapis.com/css?family=Reenie+Beanie';
body { body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;

View File

@@ -352,22 +352,22 @@
border-radius: 2px; border-radius: 2px;
} }
.Lexical__listItemChecked:before { .Lexical__listItemChecked:before {
border: 1px solid rgb(61, 135, 245); border: 1px solid var(--sn-stylekit-info-color);
border-radius: 2px; border-radius: 2px;
background-color: #3d87f5; background-color: var(--sn-stylekit-info-color);
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.Lexical__listItemChecked:after { .Lexical__listItemChecked:after {
content: ''; content: '';
cursor: pointer; cursor: pointer;
border-color: #fff; border-color: var(--sn-stylekit-info-contrast-color);
border-style: solid; border-style: solid;
position: absolute; position: absolute;
display: block; display: block;
top: 6px; top: 7px;
width: 3px; width: 5px;
left: 7px; left: 6px;
height: 6px; height: 10px;
transform: rotate(45deg); transform: rotate(45deg);
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
} }

View File

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

View File

@@ -366,4 +366,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.mobileWebReceiver.addReactListener(listener) return this.mobileWebReceiver.addReactListener(listener)
} }
showAccountMenu(): void {
this.getViewControllerManager().accountMenuController.setShow(true)
}
} }

View File

@@ -16,7 +16,6 @@ import { NodeObserverPlugin } from './Plugins/NodeObserverPlugin/NodeObserverPlu
import { FilesController } from '@/Controllers/FilesController' import { FilesController } from '@/Controllers/FilesController'
import FilesControllerProvider from '@/Controllers/FilesControllerProvider' import FilesControllerProvider from '@/Controllers/FilesControllerProvider'
const StringEllipses = '...'
const NotePreviewCharLimit = 160 const NotePreviewCharLimit = 160
type Props = { type Props = {
@@ -24,18 +23,21 @@ type Props = {
note: SNNote note: SNNote
linkingController: LinkingController linkingController: LinkingController
filesController: FilesController filesController: FilesController
spellcheck: boolean
} }
export const BlockEditor: FunctionComponent<Props> = ({ note, application, linkingController, filesController }) => { export const BlockEditor: FunctionComponent<Props> = ({
note,
application,
linkingController,
filesController,
spellcheck,
}) => {
const controller = useRef(new BlockEditorController(note, application)) const controller = useRef(new BlockEditorController(note, application))
const handleChange = useCallback( const handleChange = useCallback(
(value: string) => { (value: string, preview: string) => {
const content = value void controller.current.save({ text: value, previewPlain: preview, previewHtml: undefined })
const truncate = content.length > NotePreviewCharLimit
const substring = content.substring(0, NotePreviewCharLimit)
const previewPlain = substring + (truncate ? StringEllipses : '')
void controller.current.save({ text: content, previewPlain: previewPlain, previewHtml: undefined })
}, },
[controller], [controller],
) )
@@ -51,7 +53,7 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application, linki
) )
return ( return (
<div className="relative h-full w-full p-5"> <div className="relative h-full w-full px-5 py-4">
<ErrorBoundary> <ErrorBoundary>
<LinkingControllerProvider controller={linkingController}> <LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}> <FilesControllerProvider controller={filesController}>
@@ -59,6 +61,8 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application, linki
<BlocksEditor <BlocksEditor
onChange={handleChange} onChange={handleChange}
className="relative relative resize-none text-base focus:shadow-none focus:outline-none" className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
previewLength={NotePreviewCharLimit}
spellcheck={spellcheck}
> >
<ItemSelectionPlugin currentNote={note} /> <ItemSelectionPlugin currentNote={note} />
<FilePlugin /> <FilePlugin />

View File

@@ -3,14 +3,15 @@ import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
export class ItemOption extends TypeaheadOption { export class ItemOption extends TypeaheadOption {
constructor( constructor(
public item: LinkableItem, public item: LinkableItem | undefined,
public label: string,
public options: { public options: {
keywords?: Array<string> keywords?: Array<string>
keyboardShortcut?: string keyboardShortcut?: string
onSelect: (queryString: string) => void onSelect: (queryString: string) => void
}, },
) { ) {
super(item.title || '') super(label || '')
this.key = item.uuid this.key = item?.uuid || label
} }
} }

View File

@@ -1,4 +1,5 @@
import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta' import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta'
import { LinkedItemSearchResultsAddTagOption } from '@/Components/LinkedItems/LinkedItemSearchResultsAddTagOption'
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames' import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
import { ItemOption } from './ItemOption' import { ItemOption } from './ItemOption'
@@ -24,7 +25,14 @@ export function ItemSelectionItemComponent({ index, isSelected, onClick, onMouse
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onClick={onClick} onClick={onClick}
> >
<LinkedItemMeta item={option.item} searchQuery={searchQuery} /> {option.item && <LinkedItemMeta item={option.item} searchQuery={searchQuery} />}
{!option.item && (
<LinkedItemSearchResultsAddTagOption
searchQuery={searchQuery}
onClickCallback={onClick}
isFocused={isSelected}
/>
)}
</li> </li>
) )
} }

View File

@@ -46,14 +46,19 @@ export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) =
) )
const options = useMemo(() => { const options = useMemo(() => {
const results = getLinkingSearchResults(queryString || '', application, currentNote, { const { linkedItems, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(
returnEmptyIfQueryEmpty: false, queryString || '',
}) application,
currentNote,
{
returnEmptyIfQueryEmpty: false,
},
)
const items = [...results.linkedItems, ...results.unlinkedItems] const items = [...linkedItems, ...unlinkedItems]
return items.map((item) => { const options = items.map((item) => {
return new ItemOption(item, { return new ItemOption(item, item.title || '', {
onSelect: (_queryString: string) => { onSelect: (_queryString: string) => {
void linkingController.linkItems(currentNote, item) void linkingController.linkItems(currentNote, item)
if (item.content_type === ContentType.File) { if (item.content_type === ContentType.File) {
@@ -64,6 +69,19 @@ export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) =
}, },
}) })
}) })
if (shouldShowCreateTag) {
options.push(
new ItemOption(undefined, '', {
onSelect: async (queryString: string) => {
const newTag = await linkingController.createAndAddNewTag(queryString || '')
editor.dispatchCommand(INSERT_BUBBLE_COMMAND, newTag.uuid)
},
}),
)
}
return options
}, [application, editor, currentNote, queryString, linkingController]) }, [application, editor, currentNote, queryString, linkingController])
return ( return (

View File

@@ -1,11 +1,12 @@
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { concatenateUint8Arrays } from '@/Utils' import { concatenateUint8Arrays } from '@/Utils'
import { FileItem } from '@standardnotes/snjs' import { ApplicationEvent, FileItem } from '@standardnotes/snjs'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Spinner from '@/Components/Spinner/Spinner' import Spinner from '@/Components/Spinner/Spinner'
import FilePreviewError from './FilePreviewError' import FilePreviewError from './FilePreviewError'
import { isFileTypePreviewable } from './isFilePreviewable' import { isFileTypePreviewable } from './isFilePreviewable'
import PreviewComponent from './PreviewComponent' import PreviewComponent from './PreviewComponent'
import ProtectedItemOverlay from '../ProtectedItemOverlay/ProtectedItemOverlay'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -13,6 +14,8 @@ type Props = {
} }
const FilePreview = ({ file, application }: Props) => { const FilePreview = ({ file, application }: Props) => {
const [isAuthorized, setIsAuthorized] = useState(application.isAuthorizedToRenderItem(file))
const isFilePreviewable = useMemo(() => { const isFilePreviewable = useMemo(() => {
return isFileTypePreviewable(file.mimeType) return isFileTypePreviewable(file.mimeType)
}, [file.mimeType]) }, [file.mimeType])
@@ -22,7 +25,23 @@ const FilePreview = ({ file, application }: Props) => {
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>() const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
useEffect(() => { useEffect(() => {
if (!isFilePreviewable) { setIsAuthorized(application.isAuthorizedToRenderItem(file))
}, [file.protected, application, file])
useEffect(() => {
const disposer = application.addEventObserver(async (event) => {
if (event === ApplicationEvent.UnprotectedSessionBegan) {
setIsAuthorized(true)
} else if (event === ApplicationEvent.UnprotectedSessionExpired) {
setIsAuthorized(application.isAuthorizedToRenderItem(file))
}
})
return disposer
}, [application, file])
useEffect(() => {
if (!isFilePreviewable || !isAuthorized) {
setIsDownloading(false) setIsDownloading(false)
setDownloadProgress(0) setDownloadProgress(0)
setDownloadedBytes(undefined) setDownloadedBytes(undefined)
@@ -55,10 +74,17 @@ const FilePreview = ({ file, application }: Props) => {
} }
void downloadFileForPreview() void downloadFileForPreview()
}, [application.files, downloadedBytes, file, isFilePreviewable]) }, [application.files, downloadedBytes, file, isFilePreviewable, isAuthorized])
if (!application.isAuthorizedToRenderItem(file)) { if (!isAuthorized) {
return null return (
<ProtectedItemOverlay
showAccountMenu={application.showAccountMenu}
itemType={'file'}
onViewItem={() => application.protections.authorizeItemAccess(file)}
hasProtectionSources={application.hasProtectionSources()}
/>
)
} }
return isDownloading ? ( return isDownloading ? (

View File

@@ -3,13 +3,14 @@ import { useCallback, useEffect, useState } from 'react'
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay' import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
import FileViewWithoutProtection from './FileViewWithoutProtection' import FileViewWithoutProtection from './FileViewWithoutProtection'
import { FileViewProps } from './FileViewProps' import { FileViewProps } from './FileViewProps'
import { ApplicationEvent } from '@standardnotes/snjs'
const FileView = ({ application, viewControllerManager, file }: FileViewProps) => { const FileView = ({ application, viewControllerManager, file }: FileViewProps) => {
const [shouldShowProtectedOverlay, setShouldShowProtectedOverlay] = useState(false) const [shouldShowProtectedOverlay, setShouldShowProtectedOverlay] = useState(false)
useEffect(() => { useEffect(() => {
viewControllerManager.filesController.setShowProtectedOverlay(file.protected && !application.hasProtectionSources()) viewControllerManager.filesController.setShowProtectedOverlay(!application.isAuthorizedToRenderItem(file))
}, [application, file.protected, viewControllerManager.filesController]) }, [application, file, viewControllerManager.filesController])
useEffect(() => { useEffect(() => {
setShouldShowProtectedOverlay(viewControllerManager.filesController.showProtectedOverlay) setShouldShowProtectedOverlay(viewControllerManager.filesController.showProtectedOverlay)
@@ -27,9 +28,21 @@ const FileView = ({ application, viewControllerManager, file }: FileViewProps) =
} }
}, [application, file]) }, [application, file])
useEffect(() => {
const disposer = application.addEventObserver(async (event) => {
if (event === ApplicationEvent.UnprotectedSessionBegan) {
setShouldShowProtectedOverlay(false)
} else if (event === ApplicationEvent.UnprotectedSessionExpired) {
setShouldShowProtectedOverlay(!application.isAuthorizedToRenderItem(file))
}
})
return disposer
}, [application, file])
return shouldShowProtectedOverlay ? ( return shouldShowProtectedOverlay ? (
<ProtectedItemOverlay <ProtectedItemOverlay
viewControllerManager={viewControllerManager} showAccountMenu={application.showAccountMenu}
hasProtectionSources={application.hasProtectionSources()} hasProtectionSources={application.hasProtectionSources()}
onViewItem={dismissProtectedOverlay} onViewItem={dismissProtectedOverlay}
itemType={'file'} itemType={'file'}

View File

@@ -1,11 +1,12 @@
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { SNNote } from '@standardnotes/snjs' import { SNNote } from '@standardnotes/snjs'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import LinkedItemMeta from './LinkedItemMeta' import LinkedItemMeta from './LinkedItemMeta'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem' import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { LinkedItemSearchResultsAddTagOption } from './LinkedItemSearchResultsAddTagOption'
import { useCallback } from 'react'
type Props = { type Props = {
createAndAddNewTag: LinkingController['createAndAddNewTag'] createAndAddNewTag: LinkingController['createAndAddNewTag']
@@ -26,7 +27,13 @@ const LinkedItemSearchResults = ({
onClickCallback, onClickCallback,
isEntitledToNoteLinking, isEntitledToNoteLinking,
}: Props) => { }: Props) => {
const premiumModal = usePremiumModal() const onClickAddNew = useCallback(
(searchQuery: string) => {
void createAndAddNewTag(searchQuery)
onClickCallback?.()
},
[createAndAddNewTag, onClickCallback],
)
return ( return (
<div className="my-1"> <div className="my-1">
@@ -37,12 +44,8 @@ const LinkedItemSearchResults = ({
key={result.uuid} key={result.uuid}
className="flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop" className="flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop"
onClick={() => { onClick={() => {
if (cannotLinkItem) { void linkItemToSelectedItem(result)
premiumModal.activate('Note linking') onClickCallback?.()
} else {
void linkItemToSelectedItem(result)
onClickCallback?.()
}
}} }}
> >
<LinkedItemMeta item={result} searchQuery={searchQuery} /> <LinkedItemMeta item={result} searchQuery={searchQuery} />
@@ -51,19 +54,7 @@ const LinkedItemSearchResults = ({
) )
})} })}
{shouldShowCreateTag && ( {shouldShowCreateTag && (
<button <LinkedItemSearchResultsAddTagOption searchQuery={searchQuery} onClickCallback={onClickAddNew} />
className="group flex w-full items-center gap-2 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop"
onClick={() => {
void createAndAddNewTag(searchQuery)
onClickCallback?.()
}}
>
<span className="flex-shrink-0 align-middle">Create &amp; add tag</span>{' '}
<span className="inline-flex min-w-0 items-center gap-1 rounded bg-contrast py-1 pl-1 pr-2 align-middle text-xs text-text group-hover:bg-info group-hover:text-info-contrast">
<Icon type="hashtag" className="flex-shrink-0 text-info group-hover:text-info-contrast" size="small" />
<span className="min-w-0 overflow-hidden text-ellipsis">{searchQuery}</span>
</span>
</button>
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,42 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import Icon from '../Icon/Icon'
type Props = {
searchQuery: string
onClickCallback: (searchQuery: string) => void
isFocused?: boolean
}
export const LinkedItemSearchResultsAddTagOption = ({ searchQuery, onClickCallback, isFocused }: Props) => {
return (
<button
className={classNames(
'group flex w-full items-center gap-2 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground',
'focus:bg-info-backdrop',
isFocused ? 'bg-contrast bg-info-backdrop text-foreground' : '',
)}
onClick={() => {
onClickCallback(searchQuery)
}}
>
<span className="flex-shrink-0 align-middle text-sm lg:text-xs">Create &amp; add tag</span>{' '}
<span
className={classNames(
'inline-flex min-w-0 items-center gap-1 rounded py-1 pl-1 pr-2 align-middle text-xs ',
'group-hover:bg-info group-hover:text-info-contrast',
isFocused ? 'bg-info text-info-contrast' : 'bg-contrast text-text',
)}
>
<Icon
type="hashtag"
className={classNames(
'flex-shrink-0 group-hover:text-info-contrast',
isFocused ? 'text-info-contrast' : 'text-info',
)}
size="small"
/>
<span className="min-w-0 overflow-hidden text-ellipsis">{searchQuery}</span>
</span>
</button>
)
}

View File

@@ -179,7 +179,7 @@ describe('NoteView', () => {
application, application,
}) })
await noteView.dismissProtectedWarning() await noteView.authorizeAndDismissProtectedWarning()
expect(notesController.setShowProtectedWarning).toHaveBeenCalledWith(false) expect(notesController.setShowProtectedWarning).toHaveBeenCalledWith(false)
}) })
@@ -192,7 +192,7 @@ describe('NoteView', () => {
application, application,
}) })
await noteView.dismissProtectedWarning() await noteView.authorizeAndDismissProtectedWarning()
expect(notesController.setShowProtectedWarning).not.toHaveBeenCalled() expect(notesController.setShowProtectedWarning).not.toHaveBeenCalled()
}) })
@@ -207,7 +207,7 @@ describe('NoteView', () => {
application, application,
}) })
await noteView.dismissProtectedWarning() await noteView.authorizeAndDismissProtectedWarning()
expect(notesController.setShowProtectedWarning).toHaveBeenCalledWith(false) expect(notesController.setShowProtectedWarning).toHaveBeenCalledWith(false)
}) })

View File

@@ -47,6 +47,7 @@ import {
transactionForAssociateComponentWithCurrentNote, transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote, transactionForDisassociateComponentWithCurrentNote,
} from './TransactionFunctions' } from './TransactionFunctions'
import { SuperEditorContentId } from '@standardnotes/blocks-editor'
const MinimumStatusDuration = 400 const MinimumStatusDuration = 400
const TextareaDebounce = 100 const TextareaDebounce = 100
@@ -202,7 +203,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.statusTimeout = undefined this.statusTimeout = undefined
;(this.onPanelResizeFinish as unknown) = undefined ;(this.onPanelResizeFinish as unknown) = undefined
;(this.dismissProtectedWarning as unknown) = undefined ;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
;(this.editorComponentViewerRequestsReload as unknown) = undefined ;(this.editorComponentViewerRequestsReload as unknown) = undefined
;(this.onTextAreaChange as unknown) = undefined ;(this.onTextAreaChange as unknown) = undefined
;(this.onTitleEnter as unknown) = undefined ;(this.onTitleEnter as unknown) = undefined
@@ -452,7 +453,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
} }
} }
dismissProtectedWarning = async () => { authorizeAndDismissProtectedWarning = async () => {
let showNoteContents = true let showNoteContents = true
if (this.application.hasProtectionSources()) { if (this.application.hasProtectionSources()) {
@@ -893,6 +894,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.removeTrashKeyObserver = this.application.io.addKeyObserver({ this.removeTrashKeyObserver = this.application.io.addKeyObserver({
key: KeyboardKey.Backspace, key: KeyboardKey.Backspace,
notTags: ['INPUT', 'TEXTAREA'], notTags: ['INPUT', 'TEXTAREA'],
notElementIds: [SuperEditorContentId],
modifiers: [KeyboardModifier.Meta], modifiers: [KeyboardModifier.Meta],
onKeyDown: () => { onKeyDown: () => {
this.deleteNote(false).catch(console.error) this.deleteNote(false).catch(console.error)
@@ -984,9 +986,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) { if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) {
return ( return (
<ProtectedItemOverlay <ProtectedItemOverlay
viewControllerManager={this.viewControllerManager} showAccountMenu={() => this.application.showAccountMenu()}
hasProtectionSources={this.application.hasProtectionSources()} hasProtectionSources={this.application.hasProtectionSources()}
onViewItem={this.dismissProtectedWarning} onViewItem={this.authorizeAndDismissProtectedWarning}
itemType={'note'} itemType={'note'}
/> />
) )
@@ -1009,6 +1011,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
<NoteViewFileDropTarget <NoteViewFileDropTarget
note={this.note} note={this.note}
linkingController={this.viewControllerManager.linkingController} linkingController={this.viewControllerManager.linkingController}
filesController={this.viewControllerManager.filesController}
noteViewElement={this.noteViewElementRef.current} noteViewElement={this.noteViewElementRef.current}
/> />
)} )}
@@ -1155,6 +1158,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
note={this.note} note={this.note}
linkingController={this.viewControllerManager.linkingController} linkingController={this.viewControllerManager.linkingController}
filesController={this.viewControllerManager.filesController} filesController={this.viewControllerManager.filesController}
spellcheck={this.state.spellcheck}
/> />
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { SNNote } from '@standardnotes/snjs' import { SNNote } from '@standardnotes/snjs'
import { useEffect } from 'react' import { useEffect } from 'react'
@@ -6,10 +7,11 @@ import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider
type Props = { type Props = {
note: SNNote note: SNNote
linkingController: LinkingController linkingController: LinkingController
filesController: FilesController
noteViewElement: HTMLElement | null noteViewElement: HTMLElement | null
} }
const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Props) => { const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement, filesController }: Props) => {
const { isDraggingFiles, addDragTarget, removeDragTarget } = useFileDragNDrop() const { isDraggingFiles, addDragTarget, removeDragTarget } = useFileDragNDrop()
useEffect(() => { useEffect(() => {
@@ -21,6 +23,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Pr
callback: (files) => { callback: (files) => {
files.forEach(async (uploadedFile) => { files.forEach(async (uploadedFile) => {
await linkingController.linkItems(note, uploadedFile) await linkingController.linkItems(note, uploadedFile)
filesController.notifyObserversOfUploadedFileLinkingToCurrentNote(uploadedFile.uuid)
}) })
}, },
}) })
@@ -31,7 +34,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Pr
removeDragTarget(target) removeDragTarget(target)
} }
} }
}, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget]) }, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget, filesController])
return isDraggingFiles ? ( return isDraggingFiles ? (
// Required to block drag events to editor iframe // Required to block drag events to editor iframe

View File

@@ -1,21 +1,20 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import Button from '@/Components/Button/Button' import Button from '@/Components/Button/Button'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton' import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
type Props = { type Props = {
viewControllerManager: ViewControllerManager showAccountMenu: () => void
onViewItem: () => void onViewItem: () => void
hasProtectionSources: boolean hasProtectionSources: boolean
itemType: 'note' | 'file' itemType: 'note' | 'file'
} }
const ProtectedItemOverlay = ({ viewControllerManager, onViewItem, hasProtectionSources, itemType }: Props) => { const ProtectedItemOverlay = ({ showAccountMenu, onViewItem, hasProtectionSources, itemType }: Props) => {
const instructionText = hasProtectionSources const instructionText = hasProtectionSources
? `Authenticate to view this ${itemType}.` ? `Authenticate to view this ${itemType}.`
: `Add a passcode or create an account to require authentication to view this ${itemType}.` : `Add a passcode or create an account to require authentication to view this ${itemType}.`
return ( return (
<div aria-label="Protected overlay" className="section editor sn-component"> <div aria-label="Protected overlay" className="section editor sn-component p-5">
<div className="flex h-full flex-grow flex-col justify-center md:flex-row md:items-center"> <div className="flex h-full flex-grow flex-col justify-center md:flex-row md:items-center">
<div className="mb-auto p-4 md:hidden"> <div className="mb-auto p-4 md:hidden">
<MobileItemsListButton /> <MobileItemsListButton />
@@ -29,7 +28,7 @@ const ProtectedItemOverlay = ({ viewControllerManager, onViewItem, hasProtection
primary primary
small small
onClick={() => { onClick={() => {
viewControllerManager.accountMenuController.setShow(true) showAccountMenu()
}} }}
> >
Open account menu Open account menu

View File

@@ -12,8 +12,8 @@ export abstract class AbstractViewController<Event = void, EventData = void> {
constructor(public application: WebApplication, protected eventBus: InternalEventBus) {} constructor(public application: WebApplication, protected eventBus: InternalEventBus) {}
protected async publishEventSync(name: CrossControllerEvent): Promise<void> { protected async publishCrossControllerEventSync(name: CrossControllerEvent, data?: unknown): Promise<void> {
await this.eventBus.publishSync({ type: name, payload: undefined }, InternalEventPublishStrategy.SEQUENCE) await this.eventBus.publishSync({ type: name, payload: data }, InternalEventPublishStrategy.SEQUENCE)
} }
deinit(): void { deinit(): void {
@@ -38,7 +38,7 @@ export abstract class AbstractViewController<Event = void, EventData = void> {
} }
} }
notifyEvent(event: Event, data: EventData): void { protected notifyEvent(event: Event, data: EventData): void {
this.eventObservers.forEach((observer) => observer(event, data)) this.eventObservers.forEach((observer) => observer(event, data))
} }
} }

View File

@@ -3,4 +3,5 @@ export enum CrossControllerEvent {
ActiveEditorChanged = 'ActiveEditorChanged', ActiveEditorChanged = 'ActiveEditorChanged',
HydrateFromPersistedValues = 'HydrateFromPersistedValues', HydrateFromPersistedValues = 'HydrateFromPersistedValues',
RequestValuePersistence = 'RequestValuePersistence', RequestValuePersistence = 'RequestValuePersistence',
DisplayPremiumModal = 'DisplayPremiumModal',
} }

View File

@@ -1,8 +1,15 @@
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { destroyAllObjectProperties } from '@/Utils' import { destroyAllObjectProperties } from '@/Utils'
import { ApplicationEvent, FeatureIdentifier, FeatureStatus, InternalEventBus } from '@standardnotes/snjs' import {
ApplicationEvent,
FeatureIdentifier,
FeatureStatus,
InternalEventBus,
InternalEventInterface,
} from '@standardnotes/snjs'
import { action, makeObservable, observable, runInAction, when } from 'mobx' import { action, makeObservable, observable, runInAction, when } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController' import { AbstractViewController } from './Abstract/AbstractViewController'
import { CrossControllerEvent } from './CrossControllerEvent'
export class FeaturesController extends AbstractViewController { export class FeaturesController extends AbstractViewController {
hasFolders: boolean hasFolders: boolean
@@ -30,6 +37,8 @@ export class FeaturesController extends AbstractViewController {
this.hasFiles = this.isEntitledToFiles() this.hasFiles = this.isEntitledToFiles()
this.premiumAlertFeatureName = undefined this.premiumAlertFeatureName = undefined
eventBus.addEventHandler(this, CrossControllerEvent.DisplayPremiumModal)
makeObservable(this, { makeObservable(this, {
hasFolders: observable, hasFolders: observable,
hasSmartViews: observable, hasSmartViews: observable,
@@ -58,6 +67,13 @@ export class FeaturesController extends AbstractViewController {
) )
} }
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.DisplayPremiumModal) {
const payload = event.payload as { featureName: string }
void this.showPremiumAlert(payload.featureName)
}
}
public async showPremiumAlert(featureName: string): Promise<void> { public async showPremiumAlert(featureName: string): Promise<void> {
this.premiumAlertFeatureName = featureName this.premiumAlertFeatureName = featureName
return when(() => this.premiumAlertFeatureName === undefined) return when(() => this.premiumAlertFeatureName === undefined)

View File

@@ -139,7 +139,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
} }
} }
attachFileToNote = async (file: FileItem) => { attachFileToSelectedNote = async (file: FileItem) => {
const note = this.notesController.firstSelectedNote const note = this.notesController.firstSelectedNote
if (!note) { if (!note) {
addToast({ addToast({
@@ -207,7 +207,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
switch (action.type) { switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote: case PopoverFileItemActionType.AttachFileToNote:
await this.attachFileToNote(file) await this.attachFileToSelectedNote(file)
break break
case PopoverFileItemActionType.DetachFileToNote: case PopoverFileItemActionType.DetachFileToNote:
await this.detachFileFromNote(file) await this.detachFileFromNote(file)
@@ -398,10 +398,6 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
type: ToastType.Success, type: ToastType.Success,
message: `Uploaded file "${uploadedFile.name}"`, message: `Uploaded file "${uploadedFile.name}"`,
}) })
this.notifyEvent(FilesControllerEvent.FileUploadedToNote, {
[FilesControllerEvent.FileUploadedToNote]: { uuid: uploadedFile.uuid },
})
} }
return uploadedFiles return uploadedFiles
@@ -420,6 +416,12 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
return undefined return undefined
} }
notifyObserversOfUploadedFileLinkingToCurrentNote(fileUuid: string) {
this.notifyEvent(FilesControllerEvent.FileUploadedToNote, {
[FilesControllerEvent.FileUploadedToNote]: { uuid: fileUuid },
})
}
deleteFilesPermanently = async (files: FileItem[]) => { deleteFilesPermanently = async (files: FileItem[]) => {
const title = Strings.trashItemsTitle const title = Strings.trashItemsTitle
const text = files.length === 1 ? StringUtils.deleteFile(files[0].name) : Strings.deleteMultipleFiles const text = files.length === 1 ? StringUtils.deleteFile(files[0].name) : Strings.deleteMultipleFiles

View File

@@ -259,7 +259,7 @@ export class ItemListController extends AbstractViewController implements Intern
this.linkingController.reloadAllLinks() this.linkingController.reloadAllLinks()
await this.publishEventSync(CrossControllerEvent.ActiveEditorChanged) await this.publishCrossControllerEventSync(CrossControllerEvent.ActiveEditorChanged)
} }
async openFile(fileUuid: string): Promise<void> { async openFile(fileUuid: string): Promise<void> {

View File

@@ -20,6 +20,7 @@ import {
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx' import { action, computed, makeObservable, observable } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController' import { AbstractViewController } from './Abstract/AbstractViewController'
import { CrossControllerEvent } from './CrossControllerEvent'
import { FilesController } from './FilesController' import { FilesController } from './FilesController'
import { ItemListController } from './ItemList/ItemListController' import { ItemListController } from './ItemList/ItemListController'
import { NavigationController } from './Navigation/NavigationController' import { NavigationController } from './Navigation/NavigationController'
@@ -262,24 +263,35 @@ export class LinkingController extends AbstractViewController {
this.reloadAllLinks() this.reloadAllLinks()
} }
linkItemToSelectedItem = async (itemToLink: LinkableItem) => { linkItemToSelectedItem = async (itemToLink: LinkableItem): Promise<boolean> => {
const cannotLinkItem = !this.isEntitledToNoteLinking && itemToLink instanceof SNNote
if (cannotLinkItem) {
void this.publishCrossControllerEventSync(CrossControllerEvent.DisplayPremiumModal, {
featureName: 'Note linking',
})
return false
}
await this.ensureActiveItemIsInserted() await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem const activeItem = this.activeItem
if (!activeItem) { if (!activeItem) {
return return false
} }
await this.linkItems(activeItem, itemToLink) await this.linkItems(activeItem, itemToLink)
return true
} }
createAndAddNewTag = async (title: string) => { createAndAddNewTag = async (title: string): Promise<SNTag> => {
await this.ensureActiveItemIsInserted() await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem const activeItem = this.activeItem
const newTag = await this.application.mutator.findOrCreateTag(title) const newTag = await this.application.mutator.findOrCreateTag(title)
if (activeItem) { if (activeItem) {
await this.addTagToItem(newTag, activeItem) await this.addTagToItem(newTag, activeItem)
} }
return newTag
} }
addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => { addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => {