feat: Full toolbar is now shown in Super notes on non-mobile screens instead of floating formatting toolbar. It can be focused or toggled using Ctrl/Cmd+Shift+K (#2576)

This commit is contained in:
Aman Harwara
2023-10-11 17:34:54 +05:30
committed by GitHub
parent ec4c7c2c62
commit dac6ec22f2
16 changed files with 334 additions and 767 deletions

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="currentColor" d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm.646 2.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1 0 .708l-2 2a.5.5 0 0 1-.708-.708L4.293 8L2.646 6.354a.5.5 0 0 1 0-.708zM7 6.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm-5 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z" />
</svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="currentColor" d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm10.646 2.146a.5.5 0 0 1 .708.708L11.707 8l1.647 1.646a.5.5 0 0 1-.708.708l-2-2a.5.5 0 0 1 0-.708l2-2zM2 6.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z" />
</svg>

After

Width:  |  Height:  |  Size: 449 B

View File

@@ -104,6 +104,7 @@ import HistoryLockedIllustration from './il-history-locked.svg'
import IconsSpriteStylekit from './icons-sprite-stylekit.svg' import IconsSpriteStylekit from './icons-sprite-stylekit.svg'
import IlNotesIcon from './il-notes.svg' import IlNotesIcon from './il-notes.svg'
import ImageIcon from './ic-image.svg' import ImageIcon from './ic-image.svg'
import IndentIcon from './ic-indent.svg'
import InfoIcon from './ic-info.svg' import InfoIcon from './ic-info.svg'
import ItalicIcon from './ic-italic.svg' import ItalicIcon from './ic-italic.svg'
import KeyboardCloseIcon from './ic-keyboard-close.svg' import KeyboardCloseIcon from './ic-keyboard-close.svg'
@@ -137,6 +138,7 @@ import NoPreviewIllustration from './il-no-preview.svg'
import NotesFilledIcon from './ic-notes-filled.svg' import NotesFilledIcon from './ic-notes-filled.svg'
import NotesIcon from './ic-notes.svg' import NotesIcon from './ic-notes.svg'
import OpenInIcon from './ic-open-in.svg' import OpenInIcon from './ic-open-in.svg'
import OutdentIcon from './ic-outdent.svg'
import PasswordIcon from './ic-textbox-password.svg' import PasswordIcon from './ic-textbox-password.svg'
import PencilFilledIcon from './ic-pencil-filled.svg' import PencilFilledIcon from './ic-pencil-filled.svg'
import PencilIcon from './ic-pencil.svg' import PencilIcon from './ic-pencil.svg'
@@ -188,8 +190,8 @@ import SubtractIcon from './ic-subtract.svg'
import SuperscriptIcon from './ic-superscript.svg' import SuperscriptIcon from './ic-superscript.svg'
import SyncIcon from './ic-sync.svg' import SyncIcon from './ic-sync.svg'
import TasksIcon from './ic-tasks.svg' import TasksIcon from './ic-tasks.svg'
import TextIcon from './ic-text.svg'
import TextCircleIcon from './ic-text-circle.svg' import TextCircleIcon from './ic-text-circle.svg'
import TextIcon from './ic-text.svg'
import TextParagraphLongIcon from './ic-text-paragraph-long.svg' import TextParagraphLongIcon from './ic-text-paragraph-long.svg'
import ThemesFilledIcon from './ic-themes-filled.svg' import ThemesFilledIcon from './ic-themes-filled.svg'
import ThemesIcon from './ic-themes.svg' import ThemesIcon from './ic-themes.svg'
@@ -221,6 +223,7 @@ export {
AddBoldIcon, AddBoldIcon,
AddIcon, AddIcon,
AddTextIcon, AddTextIcon,
AegisIcon,
ArchiveIcon, ArchiveIcon,
ArrowDownCheckmarkIcon, ArrowDownCheckmarkIcon,
ArrowDownIcon, ArrowDownIcon,
@@ -274,6 +277,7 @@ export {
EmailFilledIcon, EmailFilledIcon,
EmailIcon, EmailIcon,
EnterIcon, EnterIcon,
EvernoteIcon,
EyeFilledIcon, EyeFilledIcon,
EyeIcon, EyeIcon,
EyeOffFilledIcon, EyeOffFilledIcon,
@@ -304,6 +308,7 @@ export {
FullscreenExitIcon, FullscreenExitIcon,
FullscreenIcon, FullscreenIcon,
GiftOutlineIcon, GiftOutlineIcon,
GoogleKeepIcon,
GroupIcon, GroupIcon,
HashtagFilledIcon, HashtagFilledIcon,
HashtagIcon, HashtagIcon,
@@ -316,6 +321,7 @@ export {
IconsSpriteStylekit, IconsSpriteStylekit,
IlNotesIcon, IlNotesIcon,
ImageIcon, ImageIcon,
IndentIcon,
InfoIcon, InfoIcon,
ItalicIcon, ItalicIcon,
KeyboardCloseIcon, KeyboardCloseIcon,
@@ -349,6 +355,7 @@ export {
NotesFilledIcon, NotesFilledIcon,
NotesIcon, NotesIcon,
OpenInIcon, OpenInIcon,
OutdentIcon,
PasswordIcon, PasswordIcon,
PencilFilledIcon, PencilFilledIcon,
PencilIcon, PencilIcon,
@@ -364,6 +371,8 @@ export {
ProtectedIllustration, ProtectedIllustration,
RedoIcon, RedoIcon,
ReorderIcon, ReorderIcon,
ReplaceAllIcon,
ReplaceIcon,
RestoreIcon, RestoreIcon,
RichTextIcon, RichTextIcon,
SafeIcon, SafeIcon,
@@ -382,6 +391,7 @@ export {
ShortcutButtonIcon, ShortcutButtonIcon,
SignInIcon, SignInIcon,
SignOutIcon, SignOutIcon,
SimplenoteIcon,
SNLogoAltIcon, SNLogoAltIcon,
SNLogoFull, SNLogoFull,
SNLogoIcon, SNLogoIcon,
@@ -397,8 +407,8 @@ export {
SuperscriptIcon, SuperscriptIcon,
SyncIcon, SyncIcon,
TasksIcon, TasksIcon,
TextIcon,
TextCircleIcon, TextCircleIcon,
TextIcon,
TextParagraphLongIcon, TextParagraphLongIcon,
ThemesFilledIcon, ThemesFilledIcon,
ThemesIcon, ThemesIcon,
@@ -420,10 +430,4 @@ export {
ViewIcon, ViewIcon,
WarningIcon, WarningIcon,
WindowIcon, WindowIcon,
EvernoteIcon,
GoogleKeepIcon,
SimplenoteIcon,
AegisIcon,
ReplaceIcon,
ReplaceAllIcon,
} }

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="bi bi-text-indent-left"><path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm.646 2.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1 0 .708l-2 2a.5.5 0 0 1-.708-.708L4.293 8 2.646 6.354a.5.5 0 0 1 0-.708zM7 6.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm-5 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/></svg>

Before

Width:  |  Height:  |  Size: 490 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="bi bi-text-indent-right"><path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm10.646 2.146a.5.5 0 0 1 .708.708L11.707 8l1.647 1.646a.5.5 0 0 1-.708.708l-2-2a.5.5 0 0 1 0-.708l2-2zM2 6.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/></svg>

Before

Width:  |  Height:  |  Size: 476 B

View File

@@ -27,6 +27,7 @@ export const STAR_NOTE_COMMAND = createKeyboardCommand('STAR_NOTE_COMMAND')
export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND') export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND')
export const SUPER_TOGGLE_SEARCH = createKeyboardCommand('SUPER_TOGGLE_SEARCH') export const SUPER_TOGGLE_SEARCH = createKeyboardCommand('SUPER_TOGGLE_SEARCH')
export const SUPER_TOGGLE_TOOLBAR = createKeyboardCommand('SUPER_TOGGLE_TOOLBAR')
export const SUPER_SEARCH_TOGGLE_CASE_SENSITIVE = createKeyboardCommand('SUPER_SEARCH_TOGGLE_CASE_SENSITIVE') export const SUPER_SEARCH_TOGGLE_CASE_SENSITIVE = createKeyboardCommand('SUPER_SEARCH_TOGGLE_CASE_SENSITIVE')
export const SUPER_SEARCH_TOGGLE_REPLACE_MODE = createKeyboardCommand('SUPER_SEARCH_TOGGLE_REPLACE_MODE') export const SUPER_SEARCH_TOGGLE_REPLACE_MODE = createKeyboardCommand('SUPER_SEARCH_TOGGLE_REPLACE_MODE')
export const SUPER_SEARCH_NEXT_RESULT = createKeyboardCommand('SUPER_SEARCH_NEXT_RESULT') export const SUPER_SEARCH_NEXT_RESULT = createKeyboardCommand('SUPER_SEARCH_NEXT_RESULT')

View File

@@ -30,6 +30,7 @@ import {
SUPER_SEARCH_PREVIOUS_RESULT, SUPER_SEARCH_PREVIOUS_RESULT,
SUPER_SEARCH_TOGGLE_REPLACE_MODE, SUPER_SEARCH_TOGGLE_REPLACE_MODE,
CHANGE_EDITOR_WIDTH_COMMAND, CHANGE_EDITOR_WIDTH_COMMAND,
SUPER_TOGGLE_TOOLBAR,
} from './KeyboardCommands' } from './KeyboardCommands'
import { KeyboardKey } from './KeyboardKey' import { KeyboardKey } from './KeyboardKey'
import { KeyboardModifier } from './KeyboardModifier' import { KeyboardModifier } from './KeyboardModifier'
@@ -147,6 +148,11 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme
modifiers: [primaryModifier, KeyboardModifier.Shift], modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true, preventDefault: true,
}, },
{
command: SUPER_TOGGLE_TOOLBAR,
key: 'k',
modifiers: [primaryModifier, KeyboardModifier.Shift],
},
{ {
command: SUPER_TOGGLE_SEARCH, command: SUPER_TOGGLE_SEARCH,
key: 'f', key: 'f',

View File

@@ -42,8 +42,8 @@ export const IconNameToSvgMapping = {
'fullscreen-exit': icons.FullscreenExitIcon, 'fullscreen-exit': icons.FullscreenExitIcon,
'hashtag-off': icons.HashtagOffIcon, 'hashtag-off': icons.HashtagOffIcon,
'keyboard-close': icons.KeyboardCloseIcon, 'keyboard-close': icons.KeyboardCloseIcon,
'link-off': icons.LinkOffIcon,
'line-width': icons.LineWidthIcon, 'line-width': icons.LineWidthIcon,
'link-off': icons.LinkOffIcon,
'list-bulleted': icons.ListBulleted, 'list-bulleted': icons.ListBulleted,
'list-numbered': icons.ListNumbered, 'list-numbered': icons.ListNumbered,
'lock-filled': icons.LockFilledIcon, 'lock-filled': icons.LockFilledIcon,
@@ -99,17 +99,19 @@ export const IconNameToSvgMapping = {
hashtag: icons.HashtagIcon, hashtag: icons.HashtagIcon,
help: icons.HelpIcon, help: icons.HelpIcon,
history: icons.HistoryIcon, history: icons.HistoryIcon,
image: icons.ImageIcon,
indent: icons.IndentIcon,
info: icons.InfoIcon, info: icons.InfoIcon,
italic: icons.ItalicIcon, italic: icons.ItalicIcon,
keyboard: icons.KeyboardIcon, keyboard: icons.KeyboardIcon,
link: icons.LinkIcon, link: icons.LinkIcon,
listed: icons.ListedIcon, listed: icons.ListedIcon,
lock: icons.LockIcon, lock: icons.LockIcon,
image: icons.ImageIcon,
markdown: icons.MarkdownIcon, markdown: icons.MarkdownIcon,
merge: icons.MergeIcon, merge: icons.MergeIcon,
more: icons.MoreIcon, more: icons.MoreIcon,
notes: icons.NotesIcon, notes: icons.NotesIcon,
outdent: icons.OutdentIcon,
paragraph: icons.TextParagraphLongIcon, paragraph: icons.TextParagraphLongIcon,
password: icons.PasswordIcon, password: icons.PasswordIcon,
pencil: icons.PencilIcon, pencil: icons.PencilIcon,

View File

@@ -19,7 +19,6 @@ import AutoEmbedPlugin from './Plugins/AutoEmbedPlugin'
import CollapsiblePlugin from './Plugins/CollapsiblePlugin' import CollapsiblePlugin from './Plugins/CollapsiblePlugin'
import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin' import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin'
import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin' import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin'
import FloatingTextFormatToolbarPlugin from './Plugins/ToolbarPlugins/FloatingTextFormatToolbarPlugin'
import { TabIndentationPlugin } from './Plugins/TabIndentationPlugin' import { TabIndentationPlugin } from './Plugins/TabIndentationPlugin'
import { handleEditorChange } from './Utils' import { handleEditorChange } from './Utils'
import { SuperEditorContentId } from './Constants' import { SuperEditorContentId } from './Constants'
@@ -110,7 +109,6 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<RemoveBrokenTablesPlugin /> <RemoveBrokenTablesPlugin />
{!readonly && floatingAnchorElem && ( {!readonly && floatingAnchorElem && (
<> <>
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<DraggableBlockPlugin anchorElem={floatingAnchorElem} /> <DraggableBlockPlugin anchorElem={floatingAnchorElem} />
<TableActionMenuPlugin anchorElem={floatingAnchorElem} cellMerge /> <TableActionMenuPlugin anchorElem={floatingAnchorElem} cellMerge />
</> </>

View File

@@ -4,13 +4,13 @@ export function GetIndentOutdentBlocks(editor: LexicalEditor) {
return [ return [
{ {
name: 'Indent', name: 'Indent',
iconName: 'arrow-right', iconName: 'indent',
keywords: ['indent'], keywords: ['indent'],
onSelect: () => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined), onSelect: () => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
}, },
{ {
name: 'Outdent', name: 'Outdent',
iconName: 'arrow-left', iconName: 'outdent',
keywords: ['outdent'], keywords: ['outdent'],
onSelect: () => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined), onSelect: () => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
}, },

View File

@@ -1,12 +1,11 @@
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { CloseIcon, CheckIcon, PencilFilledIcon, TrashFilledIcon } from '@standardnotes/icons'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { IconComponent } from '../../Lexical/Theme/IconComponent'
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl' import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
import { TOGGLE_LINK_COMMAND } from '@lexical/link' import { TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useCallback, useState, useRef, useEffect } from 'react' import { useCallback, useState, useRef, useEffect } from 'react'
import { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical' import { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
import { classNames } from '@standardnotes/snjs' import { classNames } from '@standardnotes/snjs'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
type Props = { type Props = {
linkUrl: string linkUrl: string
@@ -65,29 +64,27 @@ const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, lastSelection, i
}} }}
className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[40ch]" className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[40ch]"
/> />
<button <StyledTooltip showOnMobile showOnHover label="Cancel editing">
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed" <button
onClick={() => { className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
setEditMode(false) onClick={() => {
editor.focus() setEditMode(false)
}} editor.focus()
aria-label="Cancel editing link" }}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
> >
<IconComponent size={15}> <Icon type="close" size="medium" />
<CloseIcon /> </button>
</IconComponent> </StyledTooltip>
</button> <StyledTooltip showOnMobile showOnHover label="Save link">
<button <button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed" className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onClick={handleLinkSubmission} onClick={handleLinkSubmission}
aria-label="Save link" onMouseDown={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()} >
> <Icon type="check" size="medium" />
<IconComponent size={15}> </button>
<CheckIcon /> </StyledTooltip>
</IconComponent>
</button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -105,31 +102,29 @@ const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, lastSelection, i
</a> </a>
{!isAutoLink && ( {!isAutoLink && (
<> <>
<button <StyledTooltip showOnMobile showOnHover label="Edit link">
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed" <button
onClick={() => { className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
setEditedLinkUrl(linkUrl) onClick={() => {
setEditMode(true) setEditedLinkUrl(linkUrl)
}} setEditMode(true)
aria-label="Edit link" }}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
> >
<IconComponent size={15}> <Icon type="pencil-filled" size="medium" />
<PencilFilledIcon /> </button>
</IconComponent> </StyledTooltip>
</button> <StyledTooltip showOnMobile showOnHover label="Remove link">
<button <button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed" className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onClick={() => { onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}} }}
aria-label="Remove link" onMouseDown={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()} >
> <Icon type="trash-filled" size="medium" />
<IconComponent size={15}> </button>
<TrashFilledIcon /> </StyledTooltip>
</IconComponent>
</button>
</> </>
)} )}
</div> </div>

View File

@@ -1,17 +1,18 @@
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { CloseIcon, CheckIcon, PencilFilledIcon } from '@standardnotes/icons'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { IconComponent } from '../../Lexical/Theme/IconComponent'
import { $isRangeSelection, $isTextNode, GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical' import { $isRangeSelection, $isTextNode, GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { VisuallyHidden } from '@ariakit/react' import { VisuallyHidden } from '@ariakit/react'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import { $isLinkNode } from '@lexical/link' import { $isLinkNode } from '@lexical/link'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
type Props = { type Props = {
linkText: string linkText: string
editor: LexicalEditor editor: LexicalEditor
lastSelection: RangeSelection | GridSelection | NodeSelection | null lastSelection: RangeSelection | GridSelection | NodeSelection | null
isEditMode: boolean
setEditMode: (isEditMode: boolean) => void
} }
export const $isLinkTextNode = (node: ReturnType<typeof getSelectedNode>, selection: RangeSelection) => { export const $isLinkTextNode = (node: ReturnType<typeof getSelectedNode>, selection: RangeSelection) => {
@@ -19,9 +20,8 @@ export const $isLinkTextNode = (node: ReturnType<typeof getSelectedNode>, select
return $isLinkNode(parent) && $isTextNode(node) && selection.anchor.getNode() === selection.focus.getNode() return $isLinkNode(parent) && $isTextNode(node) && selection.anchor.getNode() === selection.focus.getNode()
} }
const LinkTextEditor = ({ linkText, editor, lastSelection }: Props) => { const LinkTextEditor = ({ linkText, editor, isEditMode, setEditMode, lastSelection }: Props) => {
const [editedLinkText, setEditedLinkText] = useState(() => linkText) const [editedLinkText, setEditedLinkText] = useState(() => linkText)
const [isEditMode, setEditMode] = useState(false)
const editModeContainer = useRef<HTMLDivElement>(null) const editModeContainer = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -72,50 +72,47 @@ const LinkTextEditor = ({ linkText, editor, lastSelection }: Props) => {
}} }}
className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[20ch]" className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[20ch]"
/> />
<button <StyledTooltip showOnMobile showOnHover label="Cancel editing">
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed" <button
onClick={() => { className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
setEditMode(false) onClick={() => {
editor.focus() setEditMode(false)
}} editor.focus()
aria-label="Cancel editing link" }}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
> >
<IconComponent size={15}> <Icon type="close" size="medium" />
<CloseIcon /> </button>
</IconComponent> </StyledTooltip>
</button> <StyledTooltip showOnMobile showOnHover label="Save link text">
<button <button
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed" className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onClick={handleLinkTextSubmission} onClick={handleLinkTextSubmission}
aria-label="Save link" onMouseDown={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()} >
> <Icon type="check" size="medium" />
<IconComponent size={15}> </button>
<CheckIcon /> </StyledTooltip>
</IconComponent>
</button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Icon type="plain-text" className="ml-1 mr-1 flex-shrink-0" /> <Icon type="plain-text" className="ml-1 mr-1 flex-shrink-0" />
<div className="flex-grow max-w-[35ch] overflow-hidden text-ellipsis whitespace-nowrap"> <div className="flex-grow overflow-hidden text-ellipsis whitespace-nowrap">
<VisuallyHidden>Link text:</VisuallyHidden> <VisuallyHidden>Link text:</VisuallyHidden>
{linkText} {linkText}
</div> </div>
<button <StyledTooltip showOnMobile showOnHover label="Edit link text">
className="flex rounded-lg p-3 hover:bg-contrast hover:text-text disabled:cursor-not-allowed ml-auto" <button
onClick={() => { className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
setEditedLinkText(linkText) onClick={() => {
setEditMode(true) setEditedLinkText(linkText)
}} setEditMode(true)
aria-label="Edit link" }}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
> >
<IconComponent size={15}> <Icon type="pencil-filled" size="medium" />
<PencilFilledIcon /> </button>
</IconComponent> </StyledTooltip>
</button>
</div> </div>
) )
} }

View File

@@ -18,8 +18,8 @@ import {
UNDO_COMMAND, UNDO_COMMAND,
} from 'lexical' } from 'lexical'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { $isLinkNode, $isAutoLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ComponentPropsWithoutRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { GetAlignmentBlocks } from '../Blocks/Alignment' import { GetAlignmentBlocks } from '../Blocks/Alignment'
import { GetBulletedListBlock } from '../Blocks/BulletedList' import { GetBulletedListBlock } from '../Blocks/BulletedList'
import { GetChecklistBlock } from '../Blocks/Checklist' import { GetChecklistBlock } from '../Blocks/Checklist'
@@ -37,25 +37,65 @@ import { GetQuoteBlock } from '../Blocks/Quote'
import { GetTableBlock } from '../Blocks/Table' import { GetTableBlock } from '../Blocks/Table'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { classNames } from '@standardnotes/snjs' import { classNames } from '@standardnotes/snjs'
import { SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services' import { SUPER_TOGGLE_SEARCH, SUPER_TOGGLE_TOOLBAR } from '@standardnotes/ui-services'
import { useApplication } from '@/Components/ApplicationProvider' import { useApplication } from '@/Components/ApplicationProvider'
import { GetRemoteImageBlock } from '../Blocks/RemoteImage' import { GetRemoteImageBlock } from '../Blocks/RemoteImage'
import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin' import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin'
import LinkEditor from '../LinkEditor/LinkEditor' import LinkEditor from './ToolbarLinkEditor'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo' import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip' import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
import LinkTextEditor, { $isLinkTextNode } from './ToolbarLinkTextEditor'
import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react'
const MobileToolbarPlugin = () => { interface ToolbarButtonProps extends ComponentPropsWithoutRef<'button'> {
name: string
active?: boolean
iconName: string
onSelect: () => void
}
const ToolbarButton = ({ name, active, iconName, onSelect, disabled, ...props }: ToolbarButtonProps) => {
const [editor] = useLexicalComposerContext()
return (
<StyledTooltip showOnMobile showOnHover label={name}>
<ToolbarItem
className="flex select-none items-center justify-center rounded p-0.5 focus:shadow-none focus:outline-none enabled:hover:bg-default enabled:focus-visible:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onMouseDown={(event) => {
event.preventDefault()
onSelect()
}}
onContextMenu={(event) => {
editor.focus()
event.preventDefault()
}}
disabled={disabled}
{...props}
>
<div
className={classNames(
'flex items-center justify-center rounded p-2 transition-colors duration-75',
active && 'bg-info text-info-contrast',
)}
>
<Icon type={iconName} size="medium" className="!text-current [&>path]:!text-current" />
</div>
</ToolbarItem>
</StyledTooltip>
)
}
const ToolbarPlugin = () => {
const application = useApplication() const application = useApplication()
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const [modal, showModal] = useModal() const [modal, showModal] = useModal()
const [isInEditor, setIsInEditor] = useState(false) const [isInEditor, setIsInEditor] = useState(false)
const [isInLinkEditor, setIsInLinkEditor] = useState(false)
const [isInToolbar, setIsInToolbar] = useState(false) const [isInToolbar, setIsInToolbar] = useState(false)
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const containerRef = useRef<HTMLDivElement>(null)
const toolbarRef = useRef<HTMLDivElement>(null) const toolbarRef = useRef<HTMLDivElement>(null)
const linkEditorRef = useRef<HTMLDivElement>(null) const linkEditorRef = useRef<HTMLDivElement>(null)
const backspaceButtonRef = useRef<HTMLButtonElement>(null) const backspaceButtonRef = useRef<HTMLButtonElement>(null)
@@ -77,9 +117,19 @@ const MobileToolbarPlugin = () => {
} }
}, [editor]) }, [editor])
const { isBold, isItalic, isUnderline, isSubscript, isSuperscript, isStrikethrough, blockType, isHighlighted } = const {
useSelectedTextFormatInfo() isBold,
const [isSelectionLink, setIsSelectionLink] = useState(false) isItalic,
isUnderline,
isSubscript,
isSuperscript,
isStrikethrough,
blockType,
isHighlighted,
isLink,
isLinkText,
isAutoLink,
} = useSelectedTextFormatInfo()
const [canUndo, setCanUndo] = useState(false) const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false) const [canRedo, setCanRedo] = useState(false)
@@ -193,7 +243,7 @@ const MobileToolbarPlugin = () => {
insertLink() insertLink()
}) })
}, },
active: isSelectionLink, active: isLink,
}, },
{ {
name: 'Search', name: 'Search',
@@ -205,6 +255,7 @@ const MobileToolbarPlugin = () => {
GetParagraphBlock(editor), GetParagraphBlock(editor),
...GetHeadingsBlocks(editor), ...GetHeadingsBlocks(editor),
...GetIndentOutdentBlocks(editor), ...GetIndentOutdentBlocks(editor),
...GetAlignmentBlocks(editor),
GetTableBlock(() => GetTableBlock(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />), showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
), ),
@@ -218,7 +269,6 @@ const MobileToolbarPlugin = () => {
GetCodeBlock(editor), GetCodeBlock(editor),
GetDividerBlock(editor), GetDividerBlock(editor),
...GetDatetimeBlocks(editor), ...GetDatetimeBlocks(editor),
...GetAlignmentBlocks(editor),
...[GetPasswordBlock(editor)], ...[GetPasswordBlock(editor)],
GetCollapsibleBlock(editor), GetCollapsibleBlock(editor),
...GetEmbedsBlocks(editor), ...GetEmbedsBlocks(editor),
@@ -233,7 +283,7 @@ const MobileToolbarPlugin = () => {
isBold, isBold,
isHighlighted, isHighlighted,
isItalic, isItalic,
isSelectionLink, isLink,
isStrikethrough, isStrikethrough,
isSubscript, isSubscript,
isSuperscript, isSuperscript,
@@ -243,41 +293,14 @@ const MobileToolbarPlugin = () => {
) )
useEffect(() => { useEffect(() => {
const container = containerRef.current
const rootElement = editor.getRootElement() const rootElement = editor.getRootElement()
if (!rootElement) { if (!rootElement) {
return return
} }
const handleFocus = () => setIsInEditor(true)
const handleBlur = (event: FocusEvent) => {
const elementToBeFocused = event.relatedTarget as Node
const toolbarContainsElementToFocus = toolbarRef.current && toolbarRef.current.contains(elementToBeFocused)
const linkEditorContainsElementToFocus =
linkEditorRef.current &&
(linkEditorRef.current.contains(elementToBeFocused) || elementToBeFocused === linkEditorRef.current)
const willFocusBackspaceButton = backspaceButtonRef.current && elementToBeFocused === backspaceButtonRef.current
if (toolbarContainsElementToFocus || linkEditorContainsElementToFocus || willFocusBackspaceButton) {
return
}
setIsInEditor(false)
}
rootElement.addEventListener('focus', handleFocus)
rootElement.addEventListener('blur', handleBlur)
return () => {
rootElement.removeEventListener('focus', handleFocus)
rootElement.removeEventListener('blur', handleBlur)
}
}, [editor])
useEffect(() => {
const toolbar = toolbarRef.current
const linkEditor = linkEditorRef.current
const handleToolbarFocus = () => setIsInToolbar(true) const handleToolbarFocus = () => setIsInToolbar(true)
const handleLinkEditorFocus = () => setIsInLinkEditor(true)
const handleToolbarBlur = (event: FocusEvent) => { const handleToolbarBlur = (event: FocusEvent) => {
const elementToBeFocused = event.relatedTarget as Node const elementToBeFocused = event.relatedTarget as Node
if (elementToBeFocused === backspaceButtonRef.current) { if (elementToBeFocused === backspaceButtonRef.current) {
@@ -285,34 +308,42 @@ const MobileToolbarPlugin = () => {
} }
setIsInToolbar(false) setIsInToolbar(false)
} }
const handleLinkEditorBlur = (event: FocusEvent) => {
const handleRootFocus = () => setIsInEditor(true)
const handleRootBlur = (event: FocusEvent) => {
const elementToBeFocused = event.relatedTarget as Node const elementToBeFocused = event.relatedTarget as Node
if (elementToBeFocused === backspaceButtonRef.current) {
const containerContainsElementToFocus = container?.contains(elementToBeFocused)
const willFocusBackspaceButton = backspaceButtonRef.current && elementToBeFocused === backspaceButtonRef.current
if (containerContainsElementToFocus || willFocusBackspaceButton) {
return return
} }
setIsInLinkEditor(false)
setIsInEditor(false)
} }
if (toolbar) { rootElement.addEventListener('focus', handleRootFocus)
toolbar.addEventListener('focus', handleToolbarFocus) rootElement.addEventListener('blur', handleRootBlur)
toolbar.addEventListener('blur', handleToolbarBlur)
}
if (linkEditor) { if (container) {
linkEditor.addEventListener('focus', handleLinkEditorFocus) container.addEventListener('focus', handleToolbarFocus)
linkEditor.addEventListener('blur', handleLinkEditorBlur) container.addEventListener('blur', handleToolbarBlur)
} }
return () => { return () => {
toolbar?.removeEventListener('focus', handleToolbarFocus) rootElement.removeEventListener('focus', handleRootFocus)
toolbar?.removeEventListener('blur', handleToolbarBlur) rootElement.removeEventListener('blur', handleRootBlur)
linkEditor?.removeEventListener('focus', handleLinkEditorFocus) container?.removeEventListener('focus', handleToolbarFocus)
linkEditor?.removeEventListener('blur', handleLinkEditorBlur) container?.removeEventListener('blur', handleToolbarBlur)
} }
}, []) }, [editor])
const [isSelectionAutoLink, setIsSelectionAutoLink] = useState(false)
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const [linkText, setLinkText] = useState('')
const [isLinkEditMode, setIsLinkEditMode] = useState(false) const [isLinkEditMode, setIsLinkEditMode] = useState(false)
const [isLinkTextEditMode, setIsLinkTextEditMode] = useState(false)
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null) const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
const updateEditorSelection = useCallback(() => { const updateEditorSelection = useCallback(() => {
@@ -329,18 +360,6 @@ const MobileToolbarPlugin = () => {
const node = getSelectedNode(selection) const node = getSelectedNode(selection)
const parent = node.getParent() const parent = node.getParent()
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsSelectionLink(true)
} else {
setIsSelectionLink(false)
}
if ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) {
setIsSelectionAutoLink(true)
} else {
setIsSelectionAutoLink(false)
}
if ($isLinkNode(parent)) { if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL()) setLinkUrl(parent.getURL())
} else if ($isLinkNode(node)) { } else if ($isLinkNode(node)) {
@@ -348,6 +367,11 @@ const MobileToolbarPlugin = () => {
} else { } else {
setLinkUrl('') setLinkUrl('')
} }
if ($isLinkTextNode(node, selection)) {
setLinkText(node.getTextContent())
} else {
setLinkText('')
}
if ( if (
selection !== null && selection !== null &&
@@ -380,76 +404,147 @@ const MobileToolbarPlugin = () => {
) )
}, [editor, updateEditorSelection]) }, [editor, updateEditorSelection])
const isFocusInEditorOrToolbar = isInEditor || isInToolbar || isInLinkEditor useEffect(() => {
const container = containerRef.current
const rootElement = editor.getRootElement()
if (!container || !rootElement) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (isMobile) {
return
}
const containerHeight = container.offsetHeight
rootElement.style.paddingBottom = containerHeight ? `${containerHeight + 16 * 2}px` : ''
})
resizeObserver.observe(container)
return () => {
resizeObserver.disconnect()
}
}, [editor, isMobile])
const isFocusInEditorOrToolbar = isInEditor || isInToolbar
const [isToolbarVisible, setIsToolbarVisible] = useState(true)
const canShowToolbar = isMobile ? isFocusInEditorOrToolbar : isToolbarVisible
const toolbarStore = useToolbarStore()
useEffect(() => {
return application.keyboardService.addCommandHandler({
command: SUPER_TOGGLE_TOOLBAR,
onKeyDown: (event) => {
if (isMobile) {
return
}
event.preventDefault()
const isFocusInContainer = containerRef.current?.contains(document.activeElement)
if (!isToolbarVisible) {
setIsToolbarVisible(true)
toolbarStore.move(toolbarStore.first())
return
}
if (isFocusInContainer) {
setIsToolbarVisible(false)
editor.focus()
} else {
toolbarStore.move(toolbarStore.first())
}
},
})
}, [application.keyboardService, editor, isMobile, isToolbarVisible, toolbarStore])
return ( return (
<> <>
{modal} {modal}
<div <div
className={classNames('bg-contrast', !isMobile || !isFocusInEditorOrToolbar ? 'hidden' : '')} className={classNames(
id="super-mobile-toolbar" 'bg-contrast',
> 'md:absolute md:bottom-4 md:left-1/2 md:max-w-[60%] md:-translate-x-1/2 md:rounded-lg md:border md:border-border md:px-2 md:py-1 md:translucent-ui:border-[--popover-border-color] md:translucent-ui:bg-[--popover-background-color] md:translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)]',
{isSelectionLink && ( !canShowToolbar ? 'hidden' : '',
<div
className="border-t border-border px-2 focus:shadow-none focus:outline-none"
ref={linkEditorRef}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<LinkEditor
linkUrl={linkUrl}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
isAutoLink={isSelectionAutoLink}
editor={editor}
lastSelection={lastSelection}
/>
</div>
)} )}
<div className="flex w-full flex-shrink-0 border-t border-border bg-contrast"> id="super-mobile-toolbar"
<div ref={containerRef}
tabIndex={-1} >
{isLinkText && !isAutoLink && (
<>
<div className="border-t border-border px-1 py-1 md:border-0 md:px-0 md:py-0">
<LinkTextEditor
linkText={linkText}
editor={editor}
lastSelection={lastSelection}
isEditMode={isLinkTextEditMode}
setEditMode={setIsLinkTextEditMode}
/>
</div>
<div
role="presentation"
className="my-1 hidden h-px bg-border translucent-ui:bg-[--popover-border-color] md:block"
/>
</>
)}
{isLink && (
<>
<div
className="border-t border-border px-1 py-1 focus:shadow-none focus:outline-none md:border-0 md:px-0 md:py-0"
ref={linkEditorRef}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<LinkEditor
linkUrl={linkUrl}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
isAutoLink={isAutoLink}
editor={editor}
lastSelection={lastSelection}
/>
</div>
<div
role="presentation"
className="my-1 hidden h-px bg-border translucent-ui:bg-[--popover-border-color] md:block"
/>
</>
)}
<div className="flex w-full flex-shrink-0 border-t border-border md:border-0">
<Toolbar
className="flex items-center gap-1 overflow-x-auto pl-1 [&::-webkit-scrollbar]:h-0" className="flex items-center gap-1 overflow-x-auto pl-1 [&::-webkit-scrollbar]:h-0"
ref={toolbarRef} ref={toolbarRef}
store={toolbarStore}
> >
{items.map((item) => { {items.map((item) => {
return ( return (
<StyledTooltip showOnMobile showOnHover label={item.name} key={item.name}> <ToolbarButton
<button name={item.name}
className="flex select-none items-center justify-center rounded p-0.5 hover:bg-default disabled:opacity-50" iconName={item.iconName}
aria-label={item.name} active={item.active}
onMouseDown={(event) => { disabled={item.disabled}
event.preventDefault() onSelect={item.onSelect}
item.onSelect() key={item.name}
}} />
onContextMenu={(event) => {
editor.focus()
event.preventDefault()
}}
disabled={item.disabled}
>
<div
className={classNames(
'flex items-center justify-center rounded p-2 transition-colors duration-75',
item.active && 'bg-info text-info-contrast',
)}
>
<Icon type={item.iconName} size="medium" className="!text-current [&>path]:!text-current" />
</div>
</button>
</StyledTooltip>
) )
})} })}
</div> </Toolbar>
<button {isMobile && (
className="flex flex-shrink-0 items-center justify-center rounded border-l border-border px-3 py-3" <button
aria-label="Dismiss keyboard" className="flex flex-shrink-0 items-center justify-center rounded border-l border-border px-3 py-3"
> aria-label="Dismiss keyboard"
<Icon type="keyboard-close" size="medium" /> >
</button> <Icon type="keyboard-close" size="medium" />
</button>
)}
</div> </div>
</div> </div>
</> </>
) )
} }
export default MobileToolbarPlugin export default ToolbarPlugin

View File

@@ -14,7 +14,7 @@ import { $isHeadingNode } from '@lexical/rich-text'
import { $isListNode, ListNode } from '@lexical/list' import { $isListNode, ListNode } from '@lexical/list'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import { $isLinkTextNode } from '../LinkEditor/LinkTextEditor' import { $isLinkTextNode } from './ToolbarLinkTextEditor'
const blockTypeToBlockName = { const blockTypeToBlockName = {
bullet: 'Bulleted List', bullet: 'Bulleted List',

View File

@@ -1,535 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$getSelection,
$isRangeSelection,
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
COMMAND_PRIORITY_LOW,
RangeSelection,
GridSelection,
NodeSelection,
KEY_MODIFIER_COMMAND,
COMMAND_PRIORITY_NORMAL,
createCommand,
} from 'lexical'
import { INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
LinkIcon,
SuperscriptIcon,
SubscriptIcon,
ListBulleted,
ListNumbered,
DrawIcon,
} from '@standardnotes/icons'
import { IconComponent } from '../../Lexical/Theme/IconComponent'
import { classNames } from '@standardnotes/snjs'
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles'
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
import LinkEditor from '../LinkEditor/LinkEditor'
import LinkTextEditor, { $isLinkTextNode } from '../LinkEditor/LinkTextEditor'
import { URL_REGEX } from '@/Constants/Constants'
import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
const IconSize = 15
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
const ToolbarButton = ({ active, ...props }: { active?: boolean } & ComponentPropsWithoutRef<'button'>) => {
return (
<button
className={classNames(
'flex rounded-lg p-3 hover:bg-default hover:text-text disabled:cursor-not-allowed',
active && 'bg-info text-info-contrast',
)}
{...props}
>
{props.children}
</button>
)
}
function TextFormatFloatingToolbar({
editor,
anchorElem,
isText,
isLink,
isLinkText,
isAutoLink,
isBold,
isItalic,
isUnderline,
isCode,
isStrikethrough,
isSubscript,
isSuperscript,
isBulletedList,
isNumberedList,
isHighlighted,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
isText: boolean
isBold: boolean
isCode: boolean
isItalic: boolean
isLink: boolean
isLinkText: boolean
isAutoLink: boolean
isStrikethrough: boolean
isSubscript: boolean
isSuperscript: boolean
isUnderline: boolean
isBulletedList: boolean
isNumberedList: boolean
isHighlighted: boolean
}) {
const toolbarRef = useRef<HTMLDivElement | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [linkText, setLinkText] = useState('')
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
useEffect(() => {
return editor.registerCommand(
TOGGLE_LINK_AND_EDIT_COMMAND,
(payload) => {
if (payload === null) {
return editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
} else if (typeof payload === 'string') {
const dispatched = editor.dispatchCommand(TOGGLE_LINK_COMMAND, payload)
setLinkUrl(payload)
setIsLinkEditMode(true)
return dispatched
}
return false
},
COMMAND_PRIORITY_LOW,
)
}, [editor])
const insertLink = useCallback(() => {
if (!isLink) {
editor.update(() => {
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
})
} else {
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, null)
}
}, [editor, isLink])
const formatBulletList = useCallback(() => {
if (!isBulletedList) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
}
}, [editor, isBulletedList])
const formatNumberedList = useCallback(() => {
if (!isNumberedList) {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
}
}, [editor, isNumberedList])
const updateToolbar = useCallback(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection)
const parent = node.getParent()
if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL())
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL())
} else {
setLinkUrl('')
}
if ($isLinkTextNode(node, selection)) {
setLinkText(node.getTextContent())
} else {
setLinkText('')
}
}
const toolbarElement = toolbarRef.current
if (!toolbarElement) {
return
}
const nativeSelection = window.getSelection()
const activeElement = document.activeElement
const rootElement = editor.getRootElement()
if (
selection !== null &&
nativeSelection !== null &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
setLastSelection(selection)
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
const toolbarRect = toolbarElement.getBoundingClientRect()
const rootElementRect = rootElement.getBoundingClientRect()
const calculatedStyles = getPositionedPopoverStyles({
align: 'start',
side: 'top',
anchorRect: rangeRect,
popoverRect: toolbarRect,
documentRect: rootElementRect,
offset: 12,
maxHeightFunction: () => 'none',
})
if (calculatedStyles) {
toolbarElement.style.setProperty('--offset', calculatedStyles['--offset'])
}
if (calculatedStyles) {
Object.assign(toolbarElement.style, calculatedStyles)
const adjustedStyles = getAdjustedStylesForNonPortalPopover(toolbarElement, calculatedStyles, rootElement)
toolbarElement.style.setProperty('--translate-x', adjustedStyles['--translate-x'])
toolbarElement.style.setProperty('--translate-y', adjustedStyles['--translate-y'])
}
} else if (!activeElement || activeElement.id !== 'link-input') {
setLastSelection(null)
setIsLinkEditMode(false)
setLinkUrl('')
}
return true
}, [editor])
useEffect(() => {
const scrollerElem = editor.getRootElement()
const update = () => {
editor.getEditorState().read(() => {
updateToolbar()
})
}
window.addEventListener('resize', update)
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update)
}
return () => {
window.removeEventListener('resize', update)
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update)
}
}
}, [editor, anchorElem, updateToolbar])
useEffect(() => {
editor.getEditorState().read(() => {
updateToolbar()
})
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar()
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateToolbar()
return false
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor, updateToolbar])
useEffect(() => {
return editor.registerCommand(
KEY_MODIFIER_COMMAND,
(payload) => {
const event: KeyboardEvent = payload
const { code, ctrlKey, metaKey } = event
if (code === 'KeyK' && (ctrlKey || metaKey)) {
event.preventDefault()
if ('readText' in navigator.clipboard) {
navigator.clipboard
.readText()
.then((text) => {
if (URL_REGEX.test(text)) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, text)
} else {
throw new Error('Not a valid URL')
}
})
.catch((error) => {
console.error(error)
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
setIsLinkEditMode(true)
})
} else {
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
setIsLinkEditMode(true)
}
return true
}
return false
},
COMMAND_PRIORITY_NORMAL,
)
}, [editor])
useEffect(() => {
editor.getEditorState().read(() => updateToolbar())
}, [editor, isLink, isText, updateToolbar])
if (!editor.isEditable()) {
return null
}
return (
<div
ref={toolbarRef}
className="absolute left-0 top-0 rounded-lg border border-border bg-contrast px-2 py-1 shadow-sm shadow-contrast translucent-ui:border-[--popover-border-color] translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)]"
>
{isLinkText && !isAutoLink && (
<>
<LinkTextEditor linkText={linkText} editor={editor} lastSelection={lastSelection} />
<div
role="presentation"
className="mb-1.5 mt-0.5 h-px bg-border translucent-ui:bg-[--popover-border-color]"
/>
</>
)}
{isLink && (
<LinkEditor
linkUrl={linkUrl}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
isAutoLink={isAutoLink}
editor={editor}
lastSelection={lastSelection}
/>
)}
{isText && isLink && (
<div role="presentation" className="mb-1.5 mt-0.5 h-px bg-border translucent-ui:bg-[--popover-border-color]" />
)}
{isText && (
<div className="flex gap-1">
<StyledTooltip label="Bold">
<ToolbarButton
active={isBold}
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
}}
aria-label="Format text as bold"
>
<IconComponent size={IconSize}>
<BoldIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Italicize">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
}}
active={isItalic}
aria-label="Format text as italics"
>
<IconComponent size={IconSize}>
<ItalicIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Underline">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
}}
active={isUnderline}
aria-label="Format text to underlined"
>
<IconComponent size={IconSize + 1}>
<UnderlineIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Strikethrough">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
}}
active={isStrikethrough}
aria-label="Format text with a strikethrough"
>
<IconComponent size={IconSize}>
<StrikethroughIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Subscript">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
}}
active={isSubscript}
title="Subscript"
aria-label="Format Subscript"
>
<IconComponent paddingTop={4} size={IconSize - 2}>
<SubscriptIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Superscript">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
}}
active={isSuperscript}
title="Superscript"
aria-label="Format Superscript"
>
<IconComponent paddingTop={1} size={IconSize - 2}>
<SuperscriptIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Code">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
}}
active={isCode}
aria-label="Insert code block"
>
<IconComponent size={IconSize}>
<CodeIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Highlight">
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'highlight')
}}
active={isHighlighted}
aria-label="Highlight text"
>
<IconComponent size={IconSize}>
<DrawIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Insert link">
<ToolbarButton onClick={insertLink} active={isLink} aria-label="Insert link">
<IconComponent size={IconSize}>
<LinkIcon />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Insert bulleted list">
<ToolbarButton onClick={formatBulletList} active={isBulletedList} aria-label="Insert bulleted list">
<IconComponent size={IconSize}>
<ListBulleted />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
<StyledTooltip label="Insert numbered list">
<ToolbarButton onClick={formatNumberedList} active={isNumberedList} aria-label="Insert numbered list">
<IconComponent size={IconSize}>
<ListNumbered />
</IconComponent>
</ToolbarButton>
</StyledTooltip>
</div>
)}
</div>
)
}
function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
const {
isText,
isLink,
isLinkText,
isAutoLink,
isBold,
isItalic,
isStrikethrough,
isSubscript,
isSuperscript,
isUnderline,
isCode,
isHighlighted,
blockType,
} = useSelectedTextFormatInfo()
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
if (isMobile) {
return null
}
if (!isText && !isLink) {
return null
}
return createPortal(
<TextFormatFloatingToolbar
editor={editor}
anchorElem={anchorElem}
isText={isText}
isLink={isLink}
isLinkText={isLinkText}
isAutoLink={isAutoLink}
isBold={isBold}
isItalic={isItalic}
isStrikethrough={isStrikethrough}
isSubscript={isSubscript}
isSuperscript={isSuperscript}
isUnderline={isUnderline}
isCode={isCode}
isHighlighted={isHighlighted}
isBulletedList={blockType === 'bullet'}
isNumberedList={blockType === 'number'}
/>,
anchorElem,
)
}
export default function FloatingTextFormatToolbarPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
return useFloatingTextFormatToolbar(editor, anchorElem)
}

View File

@@ -44,7 +44,7 @@ import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context' import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin' import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
import ModalOverlay from '@/Components/Modal/ModalOverlay' import ModalOverlay from '@/Components/Modal/ModalOverlay'
import MobileToolbarPlugin from './Plugins/ToolbarPlugins/MobileToolbarPlugin' import ToolbarPlugin from './Plugins/ToolbarPlugin/ToolbarPlugin'
import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions' import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions'
import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin' import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin'
import NotEntitledBanner from '../ComponentView/NotEntitledBanner' import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
@@ -250,7 +250,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<SuperSearchContextProvider> <SuperSearchContextProvider>
<SearchPlugin /> <SearchPlugin />
</SuperSearchContextProvider> </SuperSearchContextProvider>
<MobileToolbarPlugin /> <ToolbarPlugin />
<CodeOptionsPlugin /> <CodeOptionsPlugin />
<RemoteImagePlugin /> <RemoteImagePlugin />
</BlocksEditor> </BlocksEditor>