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:
@@ -18,7 +18,7 @@ import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
|
||||
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
|
||||
import {EditorState, LexicalEditor} from 'lexical';
|
||||
import {$getRoot, EditorState, LexicalEditor} from 'lexical';
|
||||
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
|
||||
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
|
||||
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
|
||||
@@ -28,24 +28,42 @@ import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
|
||||
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin';
|
||||
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin';
|
||||
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin';
|
||||
import {truncateString} from './Utils';
|
||||
import {SuperEditorContentId} from './Constants';
|
||||
|
||||
const BlockDragEnabled = false;
|
||||
|
||||
type BlocksEditorProps = {
|
||||
onChange: (value: string) => void;
|
||||
onChange: (value: string, preview: string) => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
previewLength: number;
|
||||
spellcheck?: boolean;
|
||||
};
|
||||
|
||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
onChange,
|
||||
className,
|
||||
children,
|
||||
previewLength,
|
||||
spellcheck,
|
||||
}) => {
|
||||
const handleChange = useCallback(
|
||||
(editorState: EditorState, _editor: LexicalEditor) => {
|
||||
const stringifiedEditorState = JSON.stringify(editorState.toJSON());
|
||||
onChange(stringifiedEditorState);
|
||||
editorState.read(() => {
|
||||
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],
|
||||
);
|
||||
@@ -67,7 +85,9 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
<div id="blocks-editor" className="editor-scroller">
|
||||
<div className="editor" ref={onRef}>
|
||||
<ContentEditable
|
||||
id={SuperEditorContentId}
|
||||
className={`ContentEditable__root ${className}`}
|
||||
spellCheck={spellcheck}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +105,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
]}
|
||||
/>
|
||||
<TablePlugin />
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
|
||||
<HistoryPlugin />
|
||||
<HorizontalRulePlugin />
|
||||
<AutoFocusPlugin />
|
||||
|
||||
1
packages/blocks-editor/src/Editor/Constants.ts
Normal file
1
packages/blocks-editor/src/Editor/Constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SuperEditorContentId = 'super-editor-content';
|
||||
7
packages/blocks-editor/src/Editor/Utils.ts
Normal file
7
packages/blocks-editor/src/Editor/Utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function truncateString(string: string, limit: number) {
|
||||
if (string.length <= limit) {
|
||||
return string;
|
||||
} else {
|
||||
return string.substring(0, limit) + '...';
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@
|
||||
*
|
||||
*/
|
||||
|
||||
@import 'https://fonts.googleapis.com/css?family=Reenie+Beanie';
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -352,22 +352,22 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
.Lexical__listItemChecked:before {
|
||||
border: 1px solid rgb(61, 135, 245);
|
||||
border: 1px solid var(--sn-stylekit-info-color);
|
||||
border-radius: 2px;
|
||||
background-color: #3d87f5;
|
||||
background-color: var(--sn-stylekit-info-color);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.Lexical__listItemChecked:after {
|
||||
content: '';
|
||||
cursor: pointer;
|
||||
border-color: #fff;
|
||||
border-color: var(--sn-stylekit-info-contrast-color);
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 6px;
|
||||
width: 3px;
|
||||
left: 7px;
|
||||
height: 6px;
|
||||
top: 7px;
|
||||
width: 5px;
|
||||
left: 6px;
|
||||
height: 10px;
|
||||
transform: rotate(45deg);
|
||||
border-width: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './Editor/BlocksEditor';
|
||||
export * from './Editor/BlocksEditorComposer';
|
||||
export * from './Editor/Constants';
|
||||
|
||||
@@ -366,4 +366,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
|
||||
return this.mobileWebReceiver.addReactListener(listener)
|
||||
}
|
||||
|
||||
showAccountMenu(): void {
|
||||
this.getViewControllerManager().accountMenuController.setShow(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { NodeObserverPlugin } from './Plugins/NodeObserverPlugin/NodeObserverPlu
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import FilesControllerProvider from '@/Controllers/FilesControllerProvider'
|
||||
|
||||
const StringEllipses = '...'
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
type Props = {
|
||||
@@ -24,18 +23,21 @@ type Props = {
|
||||
note: SNNote
|
||||
linkingController: LinkingController
|
||||
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 handleChange = useCallback(
|
||||
(value: string) => {
|
||||
const content = value
|
||||
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 })
|
||||
(value: string, preview: string) => {
|
||||
void controller.current.save({ text: value, previewPlain: preview, previewHtml: undefined })
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
@@ -51,7 +53,7 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application, linki
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full p-5">
|
||||
<div className="relative h-full w-full px-5 py-4">
|
||||
<ErrorBoundary>
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<FilesControllerProvider controller={filesController}>
|
||||
@@ -59,6 +61,8 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application, linki
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note} />
|
||||
<FilePlugin />
|
||||
|
||||
@@ -3,14 +3,15 @@ import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
|
||||
export class ItemOption extends TypeaheadOption {
|
||||
constructor(
|
||||
public item: LinkableItem,
|
||||
public item: LinkableItem | undefined,
|
||||
public label: string,
|
||||
public options: {
|
||||
keywords?: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
},
|
||||
) {
|
||||
super(item.title || '')
|
||||
this.key = item.uuid
|
||||
super(label || '')
|
||||
this.key = item?.uuid || label
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta'
|
||||
import { LinkedItemSearchResultsAddTagOption } from '@/Components/LinkedItems/LinkedItemSearchResultsAddTagOption'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
|
||||
import { ItemOption } from './ItemOption'
|
||||
|
||||
@@ -24,7 +25,14 @@ export function ItemSelectionItemComponent({ index, isSelected, onClick, onMouse
|
||||
onMouseEnter={onMouseEnter}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,14 +46,19 @@ export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) =
|
||||
)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const results = getLinkingSearchResults(queryString || '', application, currentNote, {
|
||||
returnEmptyIfQueryEmpty: false,
|
||||
})
|
||||
const { linkedItems, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(
|
||||
queryString || '',
|
||||
application,
|
||||
currentNote,
|
||||
{
|
||||
returnEmptyIfQueryEmpty: false,
|
||||
},
|
||||
)
|
||||
|
||||
const items = [...results.linkedItems, ...results.unlinkedItems]
|
||||
const items = [...linkedItems, ...unlinkedItems]
|
||||
|
||||
return items.map((item) => {
|
||||
return new ItemOption(item, {
|
||||
const options = items.map((item) => {
|
||||
return new ItemOption(item, item.title || '', {
|
||||
onSelect: (_queryString: string) => {
|
||||
void linkingController.linkItems(currentNote, item)
|
||||
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])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { concatenateUint8Arrays } from '@/Utils'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { ApplicationEvent, FileItem } from '@standardnotes/snjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Spinner from '@/Components/Spinner/Spinner'
|
||||
import FilePreviewError from './FilePreviewError'
|
||||
import { isFileTypePreviewable } from './isFilePreviewable'
|
||||
import PreviewComponent from './PreviewComponent'
|
||||
import ProtectedItemOverlay from '../ProtectedItemOverlay/ProtectedItemOverlay'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -13,6 +14,8 @@ type Props = {
|
||||
}
|
||||
|
||||
const FilePreview = ({ file, application }: Props) => {
|
||||
const [isAuthorized, setIsAuthorized] = useState(application.isAuthorizedToRenderItem(file))
|
||||
|
||||
const isFilePreviewable = useMemo(() => {
|
||||
return isFileTypePreviewable(file.mimeType)
|
||||
}, [file.mimeType])
|
||||
@@ -22,7 +25,23 @@ const FilePreview = ({ file, application }: Props) => {
|
||||
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
|
||||
|
||||
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)
|
||||
setDownloadProgress(0)
|
||||
setDownloadedBytes(undefined)
|
||||
@@ -55,10 +74,17 @@ const FilePreview = ({ file, application }: Props) => {
|
||||
}
|
||||
|
||||
void downloadFileForPreview()
|
||||
}, [application.files, downloadedBytes, file, isFilePreviewable])
|
||||
}, [application.files, downloadedBytes, file, isFilePreviewable, isAuthorized])
|
||||
|
||||
if (!application.isAuthorizedToRenderItem(file)) {
|
||||
return null
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<ProtectedItemOverlay
|
||||
showAccountMenu={application.showAccountMenu}
|
||||
itemType={'file'}
|
||||
onViewItem={() => application.protections.authorizeItemAccess(file)}
|
||||
hasProtectionSources={application.hasProtectionSources()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return isDownloading ? (
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
|
||||
import FileViewWithoutProtection from './FileViewWithoutProtection'
|
||||
import { FileViewProps } from './FileViewProps'
|
||||
import { ApplicationEvent } from '@standardnotes/snjs'
|
||||
|
||||
const FileView = ({ application, viewControllerManager, file }: FileViewProps) => {
|
||||
const [shouldShowProtectedOverlay, setShouldShowProtectedOverlay] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
viewControllerManager.filesController.setShowProtectedOverlay(file.protected && !application.hasProtectionSources())
|
||||
}, [application, file.protected, viewControllerManager.filesController])
|
||||
viewControllerManager.filesController.setShowProtectedOverlay(!application.isAuthorizedToRenderItem(file))
|
||||
}, [application, file, viewControllerManager.filesController])
|
||||
|
||||
useEffect(() => {
|
||||
setShouldShowProtectedOverlay(viewControllerManager.filesController.showProtectedOverlay)
|
||||
@@ -27,9 +28,21 @@ const FileView = ({ application, viewControllerManager, file }: FileViewProps) =
|
||||
}
|
||||
}, [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 ? (
|
||||
<ProtectedItemOverlay
|
||||
viewControllerManager={viewControllerManager}
|
||||
showAccountMenu={application.showAccountMenu}
|
||||
hasProtectionSources={application.hasProtectionSources()}
|
||||
onViewItem={dismissProtectedOverlay}
|
||||
itemType={'file'}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||
import LinkedItemMeta from './LinkedItemMeta'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { LinkedItemSearchResultsAddTagOption } from './LinkedItemSearchResultsAddTagOption'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type Props = {
|
||||
createAndAddNewTag: LinkingController['createAndAddNewTag']
|
||||
@@ -26,7 +27,13 @@ const LinkedItemSearchResults = ({
|
||||
onClickCallback,
|
||||
isEntitledToNoteLinking,
|
||||
}: Props) => {
|
||||
const premiumModal = usePremiumModal()
|
||||
const onClickAddNew = useCallback(
|
||||
(searchQuery: string) => {
|
||||
void createAndAddNewTag(searchQuery)
|
||||
onClickCallback?.()
|
||||
},
|
||||
[createAndAddNewTag, onClickCallback],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
@@ -37,12 +44,8 @@ const LinkedItemSearchResults = ({
|
||||
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"
|
||||
onClick={() => {
|
||||
if (cannotLinkItem) {
|
||||
premiumModal.activate('Note linking')
|
||||
} else {
|
||||
void linkItemToSelectedItem(result)
|
||||
onClickCallback?.()
|
||||
}
|
||||
void linkItemToSelectedItem(result)
|
||||
onClickCallback?.()
|
||||
}}
|
||||
>
|
||||
<LinkedItemMeta item={result} searchQuery={searchQuery} />
|
||||
@@ -51,19 +54,7 @@ const LinkedItemSearchResults = ({
|
||||
)
|
||||
})}
|
||||
{shouldShowCreateTag && (
|
||||
<button
|
||||
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 & 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>
|
||||
<LinkedItemSearchResultsAddTagOption searchQuery={searchQuery} onClickCallback={onClickAddNew} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ describe('NoteView', () => {
|
||||
application,
|
||||
})
|
||||
|
||||
await noteView.dismissProtectedWarning()
|
||||
await noteView.authorizeAndDismissProtectedWarning()
|
||||
|
||||
expect(notesController.setShowProtectedWarning).toHaveBeenCalledWith(false)
|
||||
})
|
||||
@@ -192,7 +192,7 @@ describe('NoteView', () => {
|
||||
application,
|
||||
})
|
||||
|
||||
await noteView.dismissProtectedWarning()
|
||||
await noteView.authorizeAndDismissProtectedWarning()
|
||||
|
||||
expect(notesController.setShowProtectedWarning).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -207,7 +207,7 @@ describe('NoteView', () => {
|
||||
application,
|
||||
})
|
||||
|
||||
await noteView.dismissProtectedWarning()
|
||||
await noteView.authorizeAndDismissProtectedWarning()
|
||||
|
||||
expect(notesController.setShowProtectedWarning).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from './TransactionFunctions'
|
||||
import { SuperEditorContentId } from '@standardnotes/blocks-editor'
|
||||
|
||||
const MinimumStatusDuration = 400
|
||||
const TextareaDebounce = 100
|
||||
@@ -202,7 +203,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
|
||||
this.statusTimeout = undefined
|
||||
;(this.onPanelResizeFinish as unknown) = undefined
|
||||
;(this.dismissProtectedWarning as unknown) = undefined
|
||||
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
|
||||
;(this.editorComponentViewerRequestsReload as unknown) = undefined
|
||||
;(this.onTextAreaChange 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
|
||||
|
||||
if (this.application.hasProtectionSources()) {
|
||||
@@ -893,6 +894,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
this.removeTrashKeyObserver = this.application.io.addKeyObserver({
|
||||
key: KeyboardKey.Backspace,
|
||||
notTags: ['INPUT', 'TEXTAREA'],
|
||||
notElementIds: [SuperEditorContentId],
|
||||
modifiers: [KeyboardModifier.Meta],
|
||||
onKeyDown: () => {
|
||||
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)) {
|
||||
return (
|
||||
<ProtectedItemOverlay
|
||||
viewControllerManager={this.viewControllerManager}
|
||||
showAccountMenu={() => this.application.showAccountMenu()}
|
||||
hasProtectionSources={this.application.hasProtectionSources()}
|
||||
onViewItem={this.dismissProtectedWarning}
|
||||
onViewItem={this.authorizeAndDismissProtectedWarning}
|
||||
itemType={'note'}
|
||||
/>
|
||||
)
|
||||
@@ -1009,6 +1011,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
<NoteViewFileDropTarget
|
||||
note={this.note}
|
||||
linkingController={this.viewControllerManager.linkingController}
|
||||
filesController={this.viewControllerManager.filesController}
|
||||
noteViewElement={this.noteViewElementRef.current}
|
||||
/>
|
||||
)}
|
||||
@@ -1155,6 +1158,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
note={this.note}
|
||||
linkingController={this.viewControllerManager.linkingController}
|
||||
filesController={this.viewControllerManager.filesController}
|
||||
spellcheck={this.state.spellcheck}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import { useEffect } from 'react'
|
||||
@@ -6,10 +7,11 @@ import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider
|
||||
type Props = {
|
||||
note: SNNote
|
||||
linkingController: LinkingController
|
||||
filesController: FilesController
|
||||
noteViewElement: HTMLElement | null
|
||||
}
|
||||
|
||||
const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Props) => {
|
||||
const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement, filesController }: Props) => {
|
||||
const { isDraggingFiles, addDragTarget, removeDragTarget } = useFileDragNDrop()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,6 +23,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Pr
|
||||
callback: (files) => {
|
||||
files.forEach(async (uploadedFile) => {
|
||||
await linkingController.linkItems(note, uploadedFile)
|
||||
filesController.notifyObserversOfUploadedFileLinkingToCurrentNote(uploadedFile.uuid)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -31,7 +34,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Pr
|
||||
removeDragTarget(target)
|
||||
}
|
||||
}
|
||||
}, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget])
|
||||
}, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget, filesController])
|
||||
|
||||
return isDraggingFiles ? (
|
||||
// Required to block drag events to editor iframe
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
showAccountMenu: () => void
|
||||
onViewItem: () => void
|
||||
hasProtectionSources: boolean
|
||||
itemType: 'note' | 'file'
|
||||
}
|
||||
|
||||
const ProtectedItemOverlay = ({ viewControllerManager, onViewItem, hasProtectionSources, itemType }: Props) => {
|
||||
const ProtectedItemOverlay = ({ showAccountMenu, onViewItem, hasProtectionSources, itemType }: Props) => {
|
||||
const instructionText = hasProtectionSources
|
||||
? `Authenticate to view this ${itemType}.`
|
||||
: `Add a passcode or create an account to require authentication to view this ${itemType}.`
|
||||
|
||||
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="mb-auto p-4 md:hidden">
|
||||
<MobileItemsListButton />
|
||||
@@ -29,7 +28,7 @@ const ProtectedItemOverlay = ({ viewControllerManager, onViewItem, hasProtection
|
||||
primary
|
||||
small
|
||||
onClick={() => {
|
||||
viewControllerManager.accountMenuController.setShow(true)
|
||||
showAccountMenu()
|
||||
}}
|
||||
>
|
||||
Open account menu
|
||||
|
||||
@@ -12,8 +12,8 @@ export abstract class AbstractViewController<Event = void, EventData = void> {
|
||||
|
||||
constructor(public application: WebApplication, protected eventBus: InternalEventBus) {}
|
||||
|
||||
protected async publishEventSync(name: CrossControllerEvent): Promise<void> {
|
||||
await this.eventBus.publishSync({ type: name, payload: undefined }, InternalEventPublishStrategy.SEQUENCE)
|
||||
protected async publishCrossControllerEventSync(name: CrossControllerEvent, data?: unknown): Promise<void> {
|
||||
await this.eventBus.publishSync({ type: name, payload: data }, InternalEventPublishStrategy.SEQUENCE)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum CrossControllerEvent {
|
||||
ActiveEditorChanged = 'ActiveEditorChanged',
|
||||
HydrateFromPersistedValues = 'HydrateFromPersistedValues',
|
||||
RequestValuePersistence = 'RequestValuePersistence',
|
||||
DisplayPremiumModal = 'DisplayPremiumModal',
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
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 { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
import { CrossControllerEvent } from './CrossControllerEvent'
|
||||
|
||||
export class FeaturesController extends AbstractViewController {
|
||||
hasFolders: boolean
|
||||
@@ -30,6 +37,8 @@ export class FeaturesController extends AbstractViewController {
|
||||
this.hasFiles = this.isEntitledToFiles()
|
||||
this.premiumAlertFeatureName = undefined
|
||||
|
||||
eventBus.addEventHandler(this, CrossControllerEvent.DisplayPremiumModal)
|
||||
|
||||
makeObservable(this, {
|
||||
hasFolders: 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> {
|
||||
this.premiumAlertFeatureName = featureName
|
||||
return when(() => this.premiumAlertFeatureName === undefined)
|
||||
|
||||
@@ -139,7 +139,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
}
|
||||
}
|
||||
|
||||
attachFileToNote = async (file: FileItem) => {
|
||||
attachFileToSelectedNote = async (file: FileItem) => {
|
||||
const note = this.notesController.firstSelectedNote
|
||||
if (!note) {
|
||||
addToast({
|
||||
@@ -207,7 +207,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
|
||||
switch (action.type) {
|
||||
case PopoverFileItemActionType.AttachFileToNote:
|
||||
await this.attachFileToNote(file)
|
||||
await this.attachFileToSelectedNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DetachFileToNote:
|
||||
await this.detachFileFromNote(file)
|
||||
@@ -398,10 +398,6 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
type: ToastType.Success,
|
||||
message: `Uploaded file "${uploadedFile.name}"`,
|
||||
})
|
||||
|
||||
this.notifyEvent(FilesControllerEvent.FileUploadedToNote, {
|
||||
[FilesControllerEvent.FileUploadedToNote]: { uuid: uploadedFile.uuid },
|
||||
})
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
@@ -420,6 +416,12 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
return undefined
|
||||
}
|
||||
|
||||
notifyObserversOfUploadedFileLinkingToCurrentNote(fileUuid: string) {
|
||||
this.notifyEvent(FilesControllerEvent.FileUploadedToNote, {
|
||||
[FilesControllerEvent.FileUploadedToNote]: { uuid: fileUuid },
|
||||
})
|
||||
}
|
||||
|
||||
deleteFilesPermanently = async (files: FileItem[]) => {
|
||||
const title = Strings.trashItemsTitle
|
||||
const text = files.length === 1 ? StringUtils.deleteFile(files[0].name) : Strings.deleteMultipleFiles
|
||||
|
||||
@@ -259,7 +259,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
|
||||
this.linkingController.reloadAllLinks()
|
||||
|
||||
await this.publishEventSync(CrossControllerEvent.ActiveEditorChanged)
|
||||
await this.publishCrossControllerEventSync(CrossControllerEvent.ActiveEditorChanged)
|
||||
}
|
||||
|
||||
async openFile(fileUuid: string): Promise<void> {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
import { CrossControllerEvent } from './CrossControllerEvent'
|
||||
import { FilesController } from './FilesController'
|
||||
import { ItemListController } from './ItemList/ItemListController'
|
||||
import { NavigationController } from './Navigation/NavigationController'
|
||||
@@ -262,24 +263,35 @@ export class LinkingController extends AbstractViewController {
|
||||
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()
|
||||
const activeItem = this.activeItem
|
||||
|
||||
if (!activeItem) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
await this.linkItems(activeItem, itemToLink)
|
||||
return true
|
||||
}
|
||||
|
||||
createAndAddNewTag = async (title: string) => {
|
||||
createAndAddNewTag = async (title: string): Promise<SNTag> => {
|
||||
await this.ensureActiveItemIsInserted()
|
||||
const activeItem = this.activeItem
|
||||
const newTag = await this.application.mutator.findOrCreateTag(title)
|
||||
if (activeItem) {
|
||||
await this.addTagToItem(newTag, activeItem)
|
||||
}
|
||||
|
||||
return newTag
|
||||
}
|
||||
|
||||
addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => {
|
||||
|
||||
Reference in New Issue
Block a user