diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 4de7a4eb4..54055705a 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -45,6 +45,7 @@ export const PrefDefaults = { [PrefKey.ComponentPreferences]: {}, [PrefKey.ActiveThemes]: [], [PrefKey.ActiveComponents]: [], + [PrefKey.AlwaysShowSuperToolbar]: true, } satisfies { [key in PrefKey]: PrefValue[key] } diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index 07bc4c0f2..a29c26c0e 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -46,6 +46,7 @@ export enum PrefKey { ComponentPreferences = 'componentPreferences', ActiveThemes = 'activeThemes', ActiveComponents = 'activeComponents', + AlwaysShowSuperToolbar = 'alwaysShowSuperToolbar', } export type PrefValue = { @@ -87,4 +88,5 @@ export type PrefValue = { [PrefKey.ComponentPreferences]: AllComponentPreferences [PrefKey.ActiveThemes]: string[] [PrefKey.ActiveComponents]: string[] + [PrefKey.AlwaysShowSuperToolbar]: boolean } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx index d40436441..15e4991c3 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Defaults.tsx @@ -1,4 +1,4 @@ -import { PrefKey, Platform, PrefDefaults } from '@standardnotes/snjs' +import { PrefKey, Platform } from '@standardnotes/snjs' import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content' import { WebApplication } from '@/Application/WebApplication' import { FunctionComponent, useState } from 'react' @@ -6,6 +6,8 @@ import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' import Switch from '@/Components/Switch/Switch' import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' +import usePreference from '@/Hooks/usePreference' +import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' type Props = { application: WebApplication @@ -18,16 +20,15 @@ const Defaults: FunctionComponent = ({ application }) => { () => (application.getValue(AndroidConfirmBeforeExitKey) as boolean) ?? true, ) - const [spellcheck, setSpellcheck] = useState(() => - application.getPreference(PrefKey.EditorSpellcheck, PrefDefaults[PrefKey.EditorSpellcheck]), - ) + const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) - const [addNoteToParentFolders, setAddNoteToParentFolders] = useState(() => - application.getPreference(PrefKey.NoteAddToParentFolders, PrefDefaults[PrefKey.NoteAddToParentFolders]), - ) + const spellcheck = usePreference(PrefKey.EditorSpellcheck) + + const addNoteToParentFolders = usePreference(PrefKey.NoteAddToParentFolders) + + const alwaysShowSuperToolbar = usePreference(PrefKey.AlwaysShowSuperToolbar) const toggleSpellcheck = () => { - setSpellcheck(!spellcheck) application.toggleGlobalSpellcheck().catch(console.error) } @@ -72,11 +73,29 @@ const Defaults: FunctionComponent = ({ application }) => { { application.setPreference(PrefKey.NoteAddToParentFolders, !addNoteToParentFolders).catch(console.error) - setAddNoteToParentFolders(!addNoteToParentFolders) }} checked={addNoteToParentFolders} /> + + {!isMobile && ( +
+
+ Use always-visible toolbar in Super notes + + When enabled, the Super toolbar will always be shown at the top of the note. It can be temporarily + toggled using Cmd/Ctrl+Shift+K. When disabled, the Super toolbar will only be shown as a floating + toolbar when text is selected. + +
+ { + application.setPreference(PrefKey.AlwaysShowSuperToolbar, !alwaysShowSuperToolbar).catch(console.error) + }} + checked={alwaysShowSuperToolbar} + /> +
+ )} ) diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx index f6d809ea7..5f5068656 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx @@ -45,7 +45,7 @@ import { IndentBlock, OutdentBlock } from '../Blocks/IndentOutdent' import { ParagraphBlock } from '../Blocks/Paragraph' import { QuoteBlock } from '../Blocks/Quote' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' -import { classNames } from '@standardnotes/snjs' +import { PrefKey, classNames } from '@standardnotes/snjs' import { SUPER_TOGGLE_SEARCH, SUPER_TOGGLE_TOOLBAR } from '@standardnotes/ui-services' import { useApplication } from '@/Components/ApplicationProvider' import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin' @@ -58,9 +58,13 @@ import Popover from '@/Components/Popover/Popover' import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents' import Menu from '@/Components/Menu/Menu' import MenuItem, { MenuItemProps } from '@/Components/Menu/MenuItem' -import { remToPx } from '@/Utils' +import { debounce, remToPx } from '@/Utils' import FloatingLinkEditor from './FloatingLinkEditor' import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator' +import { useStateRef } from '@/Hooks/useStateRef' +import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect' +import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles' +import usePreference from '@/Hooks/usePreference' const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand('TOGGLE_LINK_AND_EDIT_COMMAND') @@ -215,73 +219,174 @@ const ToolbarPlugin = () => { const [canUndo, setCanUndo] = useState(false) const [canRedo, setCanRedo] = useState(false) + const containerRef = useRef(null) + + const alwaysShowToolbar = usePreference(PrefKey.AlwaysShowSuperToolbar) + + const [isToolbarFixedToTop, setIsToolbarFixedToTop] = useState(alwaysShowToolbar) + const isToolbarFixedRef = useStateRef(isToolbarFixedToTop) + + const updateToolbarFloatingPosition = useCallback(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection)) { + return + } + + if (isMobile) { + return + } + + if (isToolbarFixedRef.current) { + return + } + + const containerElement = containerRef.current + + if (!containerElement) { + return + } + + if (selection.getTextContent() === '') { + containerElement.style.removeProperty('opacity') + return + } + + const nativeSelection = window.getSelection() + const rootElement = activeEditor.getRootElement() + + if (nativeSelection !== null && rootElement !== null && rootElement.contains(nativeSelection.anchorNode)) { + const rangeRect = getDOMRangeRect(nativeSelection, rootElement) + const containerRect = containerElement.getBoundingClientRect() + const rootRect = rootElement.getBoundingClientRect() + + const calculatedStyles = getPositionedPopoverStyles({ + align: 'start', + side: 'top', + anchorRect: rangeRect, + popoverRect: containerRect, + documentRect: rootRect, + offset: 8, + maxHeightFunction: () => 'none', + }) + + if (calculatedStyles) { + Object.entries(calculatedStyles).forEach(([key, value]) => { + if (key === 'transform') { + return + } + containerElement.style.setProperty(key, value) + }) + containerElement.style.setProperty('opacity', '1') + } + } + }, [activeEditor, isMobile, isToolbarFixedRef]) + const $updateToolbar = useCallback(() => { const selection = $getSelection() - if ($isRangeSelection(selection)) { - const anchorNode = selection.anchor.getNode() - let element = - anchorNode.getKey() === 'root' - ? anchorNode - : $findMatchingParent(anchorNode, (e) => { - const parent = e.getParent() - return parent !== null && $isRootOrShadowRoot(parent) - }) + if (!$isRangeSelection(selection)) { + return + } - if (element === null) { - element = anchorNode.getTopLevelElementOrThrow() - } + const anchorNode = selection.anchor.getNode() + let element = + anchorNode.getKey() === 'root' + ? anchorNode + : $findMatchingParent(anchorNode, (e) => { + const parent = e.getParent() + return parent !== null && $isRootOrShadowRoot(parent) + }) - const elementKey = element.getKey() - const elementDOM = activeEditor.getElementByKey(elementKey) + if (element === null) { + element = anchorNode.getTopLevelElementOrThrow() + } - // Update text format - setIsBold(selection.hasFormat('bold')) - setIsItalic(selection.hasFormat('italic')) - setIsUnderline(selection.hasFormat('underline')) - setIsStrikethrough(selection.hasFormat('strikethrough')) - setIsSubscript(selection.hasFormat('subscript')) - setIsSuperscript(selection.hasFormat('superscript')) - setIsCode(selection.hasFormat('code')) - setIsHighlight(selection.hasFormat('highlight')) + const elementKey = element.getKey() + const elementDOM = activeEditor.getElementByKey(elementKey) - // Update links - const node = getSelectedNode(selection) - const parent = node.getParent() - if ($isLinkNode(parent) || $isLinkNode(node)) { - setIsLink(true) + // Update text format + setIsBold(selection.hasFormat('bold')) + setIsItalic(selection.hasFormat('italic')) + setIsUnderline(selection.hasFormat('underline')) + setIsStrikethrough(selection.hasFormat('strikethrough')) + setIsSubscript(selection.hasFormat('subscript')) + setIsSuperscript(selection.hasFormat('superscript')) + setIsCode(selection.hasFormat('code')) + setIsHighlight(selection.hasFormat('highlight')) + + // Update links + const node = getSelectedNode(selection) + const parent = node.getParent() + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true) + } else { + setIsLink(false) + } + setLinkUrl($isLinkNode(parent) ? parent.getURL() : $isLinkNode(node) ? node.getURL() : '') + if ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) { + setIsAutoLink(true) + } else { + setIsAutoLink(false) + } + if ($isLinkTextNode(node, selection)) { + setIsLinkText(true) + setLinkText(node.getTextContent()) + } else { + setIsLinkText(false) + setLinkText('') + } + + if (elementDOM !== null) { + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode) + const type = parentList ? parentList.getListType() : element.getListType() + setBlockType(type) } else { - setIsLink(false) - } - setLinkUrl($isLinkNode(parent) ? parent.getURL() : $isLinkNode(node) ? node.getURL() : '') - if ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) { - setIsAutoLink(true) - } else { - setIsAutoLink(false) - } - if ($isLinkTextNode(node, selection)) { - setIsLinkText(true) - setLinkText(node.getTextContent()) - } else { - setIsLinkText(false) - setLinkText('') - } - - if (elementDOM !== null) { - if ($isListNode(element)) { - const parentList = $getNearestNodeOfType(anchorNode, ListNode) - const type = parentList ? parentList.getListType() : element.getListType() - setBlockType(type) - } else { - const type = $isHeadingNode(element) ? element.getTag() : element.getType() - if (type in blockTypeToBlockName) { - setBlockType(type as keyof typeof blockTypeToBlockName) - } + const type = $isHeadingNode(element) ? element.getTag() : element.getType() + if (type in blockTypeToBlockName) { + setBlockType(type as keyof typeof blockTypeToBlockName) } } - - setElementFormat(($isElementNode(node) ? node.getFormatType() : parent?.getFormatType()) || 'left') } - }, [activeEditor]) + + setElementFormat(($isElementNode(node) ? node.getFormatType() : parent?.getFormatType()) || 'left') + + updateToolbarFloatingPosition() + }, [activeEditor, updateToolbarFloatingPosition]) + + const clearContainerFloatingStyles = useCallback(() => { + const containerElement = containerRef.current + if (!containerElement) { + return + } + containerElement.style.removeProperty('--translate-x') + containerElement.style.removeProperty('--translate-y') + containerElement.style.removeProperty('transform') + containerElement.style.removeProperty('transform-origin') + containerElement.style.removeProperty('opacity') + }, []) + + useEffect(() => { + const scrollerElem = activeEditor.getRootElement() + + const update = () => { + activeEditor.getEditorState().read(() => { + updateToolbarFloatingPosition() + }) + } + const debouncedUpdate = debounce(update, 50) + + window.addEventListener('resize', debouncedUpdate) + if (scrollerElem) { + scrollerElem.addEventListener('scroll', debouncedUpdate) + } + + return () => { + window.removeEventListener('resize', debouncedUpdate) + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', debouncedUpdate) + } + } + }, [activeEditor, updateToolbarFloatingPosition]) useEffect(() => { return mergeRegister( @@ -379,14 +484,12 @@ const ToolbarPlugin = () => { ) }, [activeEditor, isLink]) - const containerRef = useRef(null) const dismissButtonRef = useRef(null) const [isFocusInEditor, setIsFocusInEditor] = useState(false) const [isFocusInToolbar, setIsFocusInToolbar] = useState(false) - const isFocusInEditorOrToolbar = isFocusInEditor || isFocusInToolbar - const [isToolbarVisible, setIsToolbarVisible] = useState(true) - const canShowToolbar = isMobile ? isFocusInEditorOrToolbar : isToolbarVisible + const canShowToolbarOnMobile = isFocusInEditor || isFocusInToolbar + const canShowAllItems = isMobile || isToolbarFixedToTop useEffect(() => { const container = containerRef.current @@ -438,24 +541,32 @@ const ToolbarPlugin = () => { if (isMobile) { return } - event.preventDefault() - - if (!isToolbarVisible) { - setIsToolbarVisible(true) - toolbarStore.move(toolbarStore.first()) + if (!alwaysShowToolbar) { return } - const isFocusInContainer = containerRef.current?.contains(document.activeElement) - if (isFocusInContainer) { - setIsToolbarVisible(false) - editor.focus() - } else { + event.preventDefault() + + if (!isToolbarFixedToTop) { + setIsToolbarFixedToTop(true) + clearContainerFloatingStyles() toolbarStore.move(toolbarStore.first()) + return + } else { + setIsToolbarFixedToTop(false) + editor.focus() } }, }) - }, [application.keyboardService, editor, isMobile, isToolbarVisible, toolbarStore]) + }, [ + alwaysShowToolbar, + application.keyboardService, + clearContainerFloatingStyles, + editor, + isMobile, + isToolbarFixedToTop, + toolbarStore, + ]) return ( <> @@ -463,8 +574,15 @@ const ToolbarPlugin = () => {
{ ref={toolbarRef} store={toolbarStore} > - setIsTOCOpen(!isTOCOpen)} - ref={tocAnchorRef} - /> - application.keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)} - /> - editor.dispatchCommand(UNDO_COMMAND, undefined)} - /> - editor.dispatchCommand(REDO_COMMAND, undefined)} - /> + {canShowAllItems && ( + <> + setIsTOCOpen(!isTOCOpen)} + ref={tocAnchorRef} + /> + application.keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)} + /> + editor.dispatchCommand(UNDO_COMMAND, undefined)} + /> + editor.dispatchCommand(REDO_COMMAND, undefined)} + /> + + )} { iconName={OutdentBlock.iconName} onSelect={() => OutdentBlock.onSelect(editor)} /> - { - setIsInsertMenuOpen(!isInsertMenuOpen) - }} - ref={insertAnchorRef} - className={isInsertMenuOpen ? 'md:bg-default' : ''} - > - - - + {canShowAllItems && ( + { + setIsInsertMenuOpen(!isInsertMenuOpen) + }} + ref={insertAnchorRef} + className={isInsertMenuOpen ? 'md:bg-default' : ''} + > + + + + )} {isMobile && (