fix: Fixed performance regression in Super notes and improved toolbar (#2590)

This commit is contained in:
Aman Harwara
2023-10-17 22:49:19 +05:30
committed by GitHub
parent d254e1c4e3
commit 9e35e2eceb
45 changed files with 933 additions and 1034 deletions

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd" d="M0 4.13v1.428a.5.5 0 0 0 .725.446l.886-.446l.377-.19L2 5.362l1.404-.708l.07-.036l.662-.333l.603-.304a.5.5 0 0 0 0-.893l-.603-.305l-.662-.333l-.07-.036L2 1.706l-.012-.005l-.377-.19l-.886-.447A.5.5 0 0 0 0 1.51v2.62ZM7.25 2a.75.75 0 0 0 0 1.5h7a.25.25 0 0 1 .25.25v8.5a.25.25 0 0 1-.25.25h-9.5a.25.25 0 0 1-.25-.25V6.754a.75.75 0 0 0-1.5 0v5.496c0 .966.784 1.75 1.75 1.75h9.5A1.75 1.75 0 0 0 16 12.25v-8.5A1.75 1.75 0 0 0 14.25 2h-7Zm-.5 4a.75.75 0 0 0 0 1.5h5.5a.75.75 0 0 0 0-1.5h-5.5ZM6 9.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5A.75.75 0 0 1 6 9.25Z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -213,6 +213,7 @@ import UserSwitch from './ic-user-switch.svg'
import ViewIcon from './ic-view.svg'
import WarningIcon from './ic-warning.svg'
import WindowIcon from './ic-window.svg'
import DetailsBlockIcon from './ic-details-block.svg'
export {
AccessibilityIcon,
@@ -267,6 +268,7 @@ export {
CopyIcon,
CreateAccountIllustration,
DashboardIcon,
DetailsBlockIcon,
DiamondFilledIcon,
DiamondIcon,
DownloadIcon,

View File

@@ -189,3 +189,4 @@ export type IconType =
| 'view'
| 'warning'
| 'window'
| 'details-block'

View File

@@ -24,6 +24,8 @@ export const IconNameToSvgMapping = {
'chevron-up': icons.ChevronUpIcon,
'clear-circle-filled': icons.ClearCircleFilledIcon,
'cloud-off': icons.CloudOffIcon,
'code-tags': icons.CodeTagsIcon,
'details-block': icons.DetailsBlockIcon,
'diamond-filled': icons.DiamondFilledIcon,
'email-filled': icons.EmailFilledIcon,
'eye-off': icons.EyeOffIcon,

View File

@@ -3,18 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useApplication } from '../ApplicationProvider'
import { ApplicationEvent, PrefKey, PrefDefaults } from '@standardnotes/snjs'
function getScrollParent(node: HTMLElement | null): HTMLElement | null {
if (!node) {
return null
}
if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
return node
} else {
return getScrollParent(node.parentElement)
}
}
import { getScrollParent } from '@/Utils'
const supportsPassive = (() => {
let supportsPassive = false

View File

@@ -6,6 +6,8 @@ import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/u
import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles'
import { getAdjustedStylesForNonPortalPopover } from '../Popover/Utils/getAdjustedStylesForNonPortal'
import { useLongPressEvent } from '@/Hooks/useLongPress'
import { PopoverSide } from '../Popover/Types'
import { getScrollParent } from '@/Utils'
const StyledTooltip = ({
children,
@@ -15,6 +17,7 @@ const StyledTooltip = ({
showOnHover = true,
interactive = false,
type = 'label',
side,
...props
}: {
children: ReactNode
@@ -24,6 +27,7 @@ const StyledTooltip = ({
showOnHover?: boolean
interactive?: boolean
type?: TooltipStoreProps['type']
side?: PopoverSide
} & Partial<TooltipOptions>) => {
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
@@ -66,6 +70,27 @@ const StyledTooltip = ({
onClick: () => setForceOpen(false),
}
useEffect(() => {
const anchor = anchorRef.current
if (!anchor) {
return
}
const scrollParent = getScrollParent(anchor)
if (!scrollParent) {
return
}
const handleScroll = () => {
tooltip.hide()
}
scrollParent.addEventListener('scroll', handleScroll)
return () => {
scrollParent.removeEventListener('scroll', handleScroll)
}
}, [tooltip])
if (isMobile && !showOnMobile) {
return <>{children}</>
}
@@ -110,7 +135,7 @@ const StyledTooltip = ({
const styles = getPositionedPopoverStyles({
align: 'center',
side: 'bottom',
side: side || 'bottom',
anchorRect,
popoverRect,
documentRect,

View File

@@ -6,27 +6,30 @@ import useModal from '../../Lexical/Hooks/useModal'
import { InsertTableDialog } from '../../Plugins/TablePlugin'
import { BlockPickerOption } from './BlockPickerOption'
import { BlockPickerMenuItem } from './BlockPickerMenuItem'
import { GetNumberedListBlockOption } from './Options/NumberedList'
import { GetBulletedListBlockOption } from './Options/BulletedList'
import { GetChecklistBlockOption } from './Options/Checklist'
import { GetDividerBlockOption } from './Options/Divider'
import { GetCollapsibleBlockOption } from './Options/Collapsible'
import { GetDynamicPasswordBlocks, GetPasswordBlockOption } from './Options/Password'
import { GetParagraphBlockOption } from './Options/Paragraph'
import { GetHeadingsBlockOptions } from './Options/Headings'
import { GetQuoteBlockOption } from './Options/Quote'
import { GetAlignmentBlockOptions } from './Options/Alignment'
import { GetCodeBlockOption } from './Options/Code'
import { GetEmbedsBlockOptions } from './Options/Embeds'
import { GetDynamicTableBlocks, GetTableBlockOption } from './Options/Table'
import { GetDynamicPasswordBlocks, GetPasswordBlockOption } from '../Blocks/Password'
import { GetDynamicTableBlocks, GetTableBlockOption } from '../Blocks/Table'
import Popover from '@/Components/Popover/Popover'
import { PopoverClassNames } from '../ClassNames'
import { GetDatetimeBlockOptions } from './Options/DateTime'
import { GetDatetimeBlockOptions } from '../Blocks/DateTime'
import { isMobileScreen } from '@/Utils'
import { useApplication } from '@/Components/ApplicationProvider'
import { GetIndentOutdentBlockOptions } from './Options/IndentOutdent'
import { GetRemoteImageBlockOption } from './Options/RemoteImage'
import { GetRemoteImageBlockOption } from '../Blocks/RemoteImage'
import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin'
import { GetParagraphBlockOption } from '../Blocks/Paragraph'
import { GetH1BlockOption, GetH2BlockOption, GetH3BlockOption } from '../Blocks/Headings'
import { GetIndentBlockOption, GetOutdentBlockOption } from '../Blocks/IndentOutdent'
import {
GetCenterAlignBlockOption,
GetJustifyAlignBlockOption,
GetLeftAlignBlockOption,
GetRightAlignBlockOption,
} from '../Blocks/Alignment'
import { GetNumberedListBlockOption, GetBulletedListBlockOption, GetChecklistBlockOption } from '../Blocks/List'
import { GetCodeBlockOption } from '../Blocks/Code'
import { GetQuoteBlockOption } from '../Blocks/Quote'
import { GetDividerBlockOption } from '../Blocks/Divider'
import { GetCollapsibleBlockOption } from '../Blocks/Collapsible'
import { GetEmbedsBlockOptions } from '../Blocks/Embeds'
export default function BlockPickerMenuPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext()
@@ -39,11 +42,15 @@ export default function BlockPickerMenuPlugin(): JSX.Element {
})
const options = useMemo(() => {
const indentOutdentOptions = application.isNativeMobileWeb() ? GetIndentOutdentBlockOptions(editor) : []
const indentOutdentOptions = application.isNativeMobileWeb()
? [GetIndentBlockOption(editor), GetOutdentBlockOption(editor)]
: []
const baseOptions = [
GetParagraphBlockOption(editor),
...GetHeadingsBlockOptions(editor),
GetH1BlockOption(editor),
GetH2BlockOption(editor),
GetH3BlockOption(editor),
...indentOutdentOptions,
GetTableBlockOption(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
@@ -58,7 +65,10 @@ export default function BlockPickerMenuPlugin(): JSX.Element {
GetCodeBlockOption(editor),
GetDividerBlockOption(editor),
...GetDatetimeBlockOptions(editor),
...GetAlignmentBlockOptions(editor),
GetLeftAlignBlockOption(editor),
GetCenterAlignBlockOption(editor),
GetRightAlignBlockOption(editor),
GetJustifyAlignBlockOption(editor),
GetPasswordBlockOption(editor),
GetCollapsibleBlockOption(editor),
...GetEmbedsBlockOptions(editor),

View File

@@ -1,14 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetAlignmentBlocks } from '../../Blocks/Alignment'
export function GetAlignmentBlockOptions(editor: LexicalEditor) {
return GetAlignmentBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetBulletedListBlock } from '../../Blocks/BulletedList'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetBulletedListBlockOption(editor: LexicalEditor) {
const block = GetBulletedListBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetChecklistBlock } from '../../Blocks/Checklist'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetChecklistBlockOption(editor: LexicalEditor) {
const block = GetChecklistBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,12 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetCodeBlock } from '../../Blocks/Code'
export function GetCodeBlockOption(editor: LexicalEditor) {
const block = GetCodeBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,12 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetCollapsibleBlock } from '../../Blocks/Collapsible'
export function GetCollapsibleBlockOption(editor: LexicalEditor) {
const block = GetCollapsibleBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetDatetimeBlocks } from '../../Blocks/DateTime'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetDatetimeBlockOptions(editor: LexicalEditor) {
return GetDatetimeBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetDividerBlock } from '../../Blocks/Divider'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetDividerBlockOption(editor: LexicalEditor) {
const block = GetDividerBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { GetEmbedsBlocks } from '../../Blocks/Embeds'
export function GetEmbedsBlockOptions(editor: LexicalEditor) {
return GetEmbedsBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { GetHeadingsBlocks } from '../../Blocks/Headings'
export function GetHeadingsBlockOptions(editor: LexicalEditor) {
return GetHeadingsBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetIndentOutdentBlocks } from '../../Blocks/IndentOutdent'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetIndentOutdentBlockOptions(editor: LexicalEditor) {
return GetIndentOutdentBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetNumberedListBlock } from '../../Blocks/NumberedList'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetNumberedListBlockOption(editor: LexicalEditor) {
const block = GetNumberedListBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetParagraphBlock } from '../../Blocks/Paragraph'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetParagraphBlockOption(editor: LexicalEditor) {
const block = GetParagraphBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,42 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_PASSWORD_COMMAND } from '../../Commands'
import { GetPasswordBlock } from '../../Blocks/Password'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
const MIN_PASSWORD_LENGTH = 8
export function GetPasswordBlockOption(editor: LexicalEditor) {
const block = GetPasswordBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}
export function GetDynamicPasswordBlocks(editor: LexicalEditor, queryString: string) {
if (queryString == null) {
return []
}
const lengthRegex = /^\d+$/
const match = lengthRegex.exec(queryString)
if (!match) {
return []
}
const length = parseInt(match[0], 10)
if (length < MIN_PASSWORD_LENGTH) {
return []
}
return [
new BlockPickerOption(`Generate ${length}-character cryptographically secure password`, {
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, length.toString()),
}),
]
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetQuoteBlock } from '../../Blocks/Quote'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetQuoteBlockOption(editor: LexicalEditor) {
const block = GetQuoteBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,12 +0,0 @@
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { GetRemoteImageBlock } from '../../Blocks/RemoteImage'
import { BlockPickerOption } from '../BlockPickerOption'
export function GetRemoteImageBlockOption(onSelect: () => void) {
const block = GetRemoteImageBlock(onSelect)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,56 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_TABLE_COMMAND } from '@lexical/table'
import { GetTableBlock } from '../../Blocks/Table'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetTableBlockOption(onSelect: () => void) {
const block = GetTableBlock(onSelect)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}
export function GetDynamicTableBlocks(editor: LexicalEditor, queryString: string) {
const options: Array<BlockPickerOption> = []
if (queryString == null) {
return options
}
const fullTableRegex = new RegExp(/^([1-9]|10)x([1-9]|10)$/)
const partialTableRegex = new RegExp(/^([1-9]|10)x?$/)
const fullTableMatch = fullTableRegex.exec(queryString)
const partialTableMatch = partialTableRegex.exec(queryString)
if (fullTableMatch) {
const [rows, columns] = fullTableMatch[0].split('x').map((n: string) => parseInt(n, 10))
options.push(
new BlockPickerOption(`${rows}x${columns} Table`, {
iconName: 'table',
keywords: ['table'],
onSelect: () => editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
}),
)
} else if (partialTableMatch) {
const rows = parseInt(partialTableMatch[0], 10)
options.push(
...Array.from({ length: 5 }, (_, i) => i + 1).map(
(columns) =>
new BlockPickerOption(`${rows}x${columns} Table`, {
iconName: 'table',
keywords: ['table'],
onSelect: () =>
editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
}),
),
)
}
return options
}

View File

@@ -1,11 +1,63 @@
import { FORMAT_ELEMENT_COMMAND, LexicalEditor, ElementFormatType } from 'lexical'
import { FORMAT_ELEMENT_COMMAND, LexicalEditor } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
export function GetAlignmentBlocks(editor: LexicalEditor) {
return ['left', 'center', 'right', 'justify'].map((alignment) => ({
name: `Align ${alignment}`,
iconName: `align-${alignment}` as LexicalIconName,
keywords: ['align', 'justify', alignment],
onSelect: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment as ElementFormatType),
}))
export const LeftAlignBlock = {
name: 'Align left',
iconName: 'align-left',
keywords: ['align', 'justify', 'left'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'),
}
export const CenterAlignBlock = {
name: 'Align center',
iconName: 'align-center',
keywords: ['align', 'justify', 'center'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'),
}
export const RightAlignBlock = {
name: 'Align right',
iconName: 'align-right',
keywords: ['align', 'justify', 'right'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'),
}
export const JustifyAlignBlock = {
name: 'Align justify',
iconName: 'align-justify',
keywords: ['align', 'justify', 'justify'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'),
}
export function GetLeftAlignBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(LeftAlignBlock.name, {
iconName: LeftAlignBlock.iconName as LexicalIconName,
keywords: LeftAlignBlock.keywords,
onSelect: () => LeftAlignBlock.onSelect(editor),
})
}
export function GetCenterAlignBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(CenterAlignBlock.name, {
iconName: CenterAlignBlock.iconName as LexicalIconName,
keywords: CenterAlignBlock.keywords,
onSelect: () => CenterAlignBlock.onSelect(editor),
})
}
export function GetRightAlignBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(RightAlignBlock.name, {
iconName: RightAlignBlock.iconName as LexicalIconName,
keywords: RightAlignBlock.keywords,
onSelect: () => RightAlignBlock.onSelect(editor),
})
}
export function GetJustifyAlignBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(JustifyAlignBlock.name, {
iconName: JustifyAlignBlock.iconName as LexicalIconName,
keywords: JustifyAlignBlock.keywords,
onSelect: () => JustifyAlignBlock.onSelect(editor),
})
}

View File

@@ -1,12 +0,0 @@
import { LexicalEditor } from 'lexical'
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
export function GetBulletedListBlock(editor: LexicalEditor, isActive = false) {
return {
name: 'Bulleted List',
iconName: 'list-bulleted',
keywords: ['bulleted list', 'unordered list', 'ul'],
onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
active: isActive,
}
}

View File

@@ -1,11 +0,0 @@
import { LexicalEditor } from 'lexical'
import { INSERT_CHECK_LIST_COMMAND } from '@lexical/list'
export function GetChecklistBlock(editor: LexicalEditor) {
return {
name: 'Check List',
iconName: 'check',
keywords: ['check list', 'todo list'],
onSelect: () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
}
}

View File

@@ -1,26 +1,33 @@
import { $wrapNodes } from '@lexical/selection'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { $createCodeNode } from '@lexical/code'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
export function GetCodeBlock(editor: LexicalEditor) {
return {
name: 'Code',
iconName: 'code' as LexicalIconName,
keywords: ['javascript', 'python', 'js', 'codeblock'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
if (selection.isCollapsed()) {
$wrapNodes(selection, () => $createCodeNode())
} else {
const textContent = selection.getTextContent()
const codeNode = $createCodeNode()
selection.insertNodes([codeNode])
selection.insertRawText(textContent)
}
export const CodeBlock = {
name: 'Code Block',
iconName: 'code' as LexicalIconName,
keywords: ['javascript', 'python', 'js', 'codeblock'],
onSelect: (editor: LexicalEditor) =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
if (selection.isCollapsed()) {
$setBlocksType(selection, () => $createCodeNode())
} else {
const textContent = selection.getTextContent()
const codeNode = $createCodeNode()
selection.insertNodes([codeNode])
selection.insertRawText(textContent)
}
}),
}
}
}),
}
export function GetCodeBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(CodeBlock.name, {
iconName: CodeBlock.iconName,
keywords: CodeBlock.keywords,
onSelect: () => CodeBlock.onSelect(editor),
})
}

View File

@@ -1,12 +1,19 @@
import { LexicalEditor } from 'lexical'
import { INSERT_COLLAPSIBLE_COMMAND } from '../../Plugins/CollapsiblePlugin'
import { IconType } from '@standardnotes/snjs'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
export function GetCollapsibleBlock(editor: LexicalEditor) {
return {
name: 'Collapsible',
iconName: 'caret-right' as IconType,
keywords: ['collapse', 'collapsible', 'toggle'],
onSelect: () => editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
}
export const CollapsibleBlock = {
name: 'Collapsible',
iconName: 'details-block' as IconType,
keywords: ['collapse', 'collapsible', 'toggle'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
}
export function GetCollapsibleBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(CollapsibleBlock.name, {
iconName: CollapsibleBlock.iconName,
keywords: CollapsibleBlock.keywords,
onSelect: () => CollapsibleBlock.onSelect(editor),
})
}

View File

@@ -1,4 +1,6 @@
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { INSERT_DATETIME_COMMAND, INSERT_DATE_COMMAND, INSERT_TIME_COMMAND } from '../Commands'
export function GetDatetimeBlocks(editor: LexicalEditor) {
@@ -23,3 +25,14 @@ export function GetDatetimeBlocks(editor: LexicalEditor) {
},
]
}
export function GetDatetimeBlockOptions(editor: LexicalEditor) {
return GetDatetimeBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,11 +1,19 @@
import { LexicalEditor } from 'lexical'
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetDividerBlock(editor: LexicalEditor) {
return {
name: 'Divider',
iconName: 'horizontal-rule',
keywords: ['horizontal rule', 'divider', 'hr'],
onSelect: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
}
export const DividerBlock = {
name: 'Divider',
iconName: 'horizontal-rule',
keywords: ['horizontal rule', 'divider', 'hr'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
}
export function GetDividerBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(DividerBlock.name, {
iconName: DividerBlock.iconName as LexicalIconName,
keywords: DividerBlock.keywords,
onSelect: () => DividerBlock.onSelect(editor),
})
}

View File

@@ -2,6 +2,7 @@ import { LexicalEditor } from 'lexical'
import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPlugin'
import { EmbedConfigs } from '../AutoEmbedPlugin'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
export function GetEmbedsBlocks(editor: LexicalEditor) {
return EmbedConfigs.map((embedConfig) => ({
@@ -11,3 +12,14 @@ export function GetEmbedsBlocks(editor: LexicalEditor) {
onSelect: () => editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
}))
}
export function GetEmbedsBlockOptions(editor: LexicalEditor) {
return GetEmbedsBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,19 +1,68 @@
import { $wrapNodes } from '@lexical/selection'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { $createHeadingNode, HeadingTagType } from '@lexical/rich-text'
import { $createHeadingNode } from '@lexical/rich-text'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
export function GetHeadingsBlocks(editor: LexicalEditor) {
return Array.from({ length: 3 }, (_, i) => i + 1).map((n) => ({
name: `Heading ${n}`,
iconName: `h${n}` as LexicalIconName,
keywords: ['heading', 'header', `h${n}`],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode(`h${n}` as HeadingTagType))
}
}),
}))
export const H1Block = {
name: 'Heading 1',
iconName: 'h1',
keywords: ['heading', 'header', 'h1'],
onSelect: (editor: LexicalEditor) =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode('h1'))
}
}),
}
export function GetH1BlockOption(editor: LexicalEditor) {
return new BlockPickerOption(H1Block.name, {
iconName: H1Block.iconName as LexicalIconName,
keywords: H1Block.keywords,
onSelect: () => H1Block.onSelect(editor),
})
}
export const H2Block = {
name: 'Heading 2',
iconName: 'h2',
keywords: ['heading', 'header', 'h2'],
onSelect: (editor: LexicalEditor) =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode('h2'))
}
}),
}
export function GetH2BlockOption(editor: LexicalEditor) {
return new BlockPickerOption(H2Block.name, {
iconName: H2Block.iconName as LexicalIconName,
keywords: H2Block.keywords,
onSelect: () => H2Block.onSelect(editor),
})
}
export const H3Block = {
name: 'Heading 3',
iconName: 'h3',
keywords: ['heading', 'header', 'h3'],
onSelect: (editor: LexicalEditor) =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode('h3'))
}
}),
}
export function GetH3BlockOption(editor: LexicalEditor) {
return new BlockPickerOption(H3Block.name, {
iconName: H3Block.iconName as LexicalIconName,
keywords: H3Block.keywords,
onSelect: () => H3Block.onSelect(editor),
})
}

View File

@@ -1,18 +1,33 @@
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, LexicalEditor } from 'lexical'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetIndentOutdentBlocks(editor: LexicalEditor) {
return [
{
name: 'Indent',
iconName: 'indent',
keywords: ['indent'],
onSelect: () => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
},
{
name: 'Outdent',
iconName: 'outdent',
keywords: ['outdent'],
onSelect: () => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
},
]
export const IndentBlock = {
name: 'Indent',
iconName: 'indent',
keywords: ['indent'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
}
export const OutdentBlock = {
name: 'Outdent',
iconName: 'outdent',
keywords: ['outdent'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
}
export function GetIndentBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(IndentBlock.name, {
iconName: IndentBlock.iconName as LexicalIconName,
keywords: IndentBlock.keywords,
onSelect: () => IndentBlock.onSelect(editor),
})
}
export function GetOutdentBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(OutdentBlock.name, {
iconName: OutdentBlock.iconName as LexicalIconName,
keywords: OutdentBlock.keywords,
onSelect: () => OutdentBlock.onSelect(editor),
})
}

View File

@@ -0,0 +1,49 @@
import { LexicalEditor } from 'lexical'
import { INSERT_UNORDERED_LIST_COMMAND, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export const BulletedListBlock = {
name: 'Bulleted List',
iconName: 'list-bulleted' as LexicalIconName,
keywords: ['bulleted list', 'unordered list', 'ul'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
}
export const ChecklistBlock = {
name: 'Check List',
iconName: 'check' as LexicalIconName,
keywords: ['check list', 'todo list'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
}
export const NumberedListBlock = {
name: 'Numbered List',
iconName: 'list-numbered' as LexicalIconName,
keywords: ['numbered list', 'ordered list', 'ol'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
}
export function GetBulletedListBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(BulletedListBlock.name, {
iconName: BulletedListBlock.iconName,
keywords: BulletedListBlock.keywords,
onSelect: () => BulletedListBlock.onSelect(editor),
})
}
export function GetChecklistBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(ChecklistBlock.name, {
iconName: ChecklistBlock.iconName,
keywords: ChecklistBlock.keywords,
onSelect: () => ChecklistBlock.onSelect(editor),
})
}
export function GetNumberedListBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(NumberedListBlock.name, {
iconName: NumberedListBlock.iconName,
keywords: NumberedListBlock.keywords,
onSelect: () => NumberedListBlock.onSelect(editor),
})
}

View File

@@ -1,12 +0,0 @@
import { LexicalEditor } from 'lexical'
import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
export function GetNumberedListBlock(editor: LexicalEditor, isActive = false) {
return {
name: 'Numbered List',
iconName: 'list-numbered',
keywords: ['numbered list', 'ordered list', 'ol'],
onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
active: isActive,
}
}

View File

@@ -1,17 +1,25 @@
import { $wrapNodes } from '@lexical/selection'
import { $setBlocksType } from '@lexical/selection'
import { $createParagraphNode, $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetParagraphBlock(editor: LexicalEditor) {
return {
name: 'Paragraph',
iconName: 'paragraph',
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createParagraphNode())
}
}),
}
export const ParagraphBlock = {
name: 'Paragraph',
iconName: 'paragraph',
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: (editor: LexicalEditor) =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode())
}
}),
}
export function GetParagraphBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(ParagraphBlock.name, {
iconName: ParagraphBlock.iconName as LexicalIconName,
keywords: ParagraphBlock.keywords,
onSelect: () => ParagraphBlock.onSelect(editor),
})
}

View File

@@ -1,13 +1,48 @@
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_PASSWORD_COMMAND } from '../Commands'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
const MIN_PASSWORD_LENGTH = 8
const DEFAULT_PASSWORD_LENGTH = 16
export function GetPasswordBlock(editor: LexicalEditor) {
return {
name: 'Generate cryptographically secure password',
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, String(DEFAULT_PASSWORD_LENGTH)),
}
export const PasswordBlock = {
name: 'Generate cryptographically secure password',
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: (editor: LexicalEditor) => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, String(DEFAULT_PASSWORD_LENGTH)),
}
export function GetPasswordBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(PasswordBlock.name, {
iconName: PasswordBlock.iconName as LexicalIconName,
keywords: PasswordBlock.keywords,
onSelect: () => PasswordBlock.onSelect(editor),
})
}
export function GetDynamicPasswordBlocks(editor: LexicalEditor, queryString: string) {
if (queryString == null) {
return []
}
const lengthRegex = /^\d+$/
const match = lengthRegex.exec(queryString)
if (!match) {
return []
}
const length = parseInt(match[0], 10)
if (length < MIN_PASSWORD_LENGTH) {
return []
}
return [
new BlockPickerOption(`Generate ${length}-character cryptographically secure password`, {
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, length.toString()),
}),
]
}

View File

@@ -1,18 +1,26 @@
import { $wrapNodes } from '@lexical/selection'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { $createQuoteNode } from '@lexical/rich-text'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetQuoteBlock(editor: LexicalEditor) {
return {
name: 'Quote',
iconName: 'quote',
keywords: ['block quote'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createQuoteNode())
}
}),
}
export const QuoteBlock = {
name: 'Quote',
iconName: 'quote' as LexicalIconName,
keywords: ['block quote'],
onSelect: (editor: LexicalEditor) =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode())
}
}),
}
export function GetQuoteBlockOption(editor: LexicalEditor) {
return new BlockPickerOption(QuoteBlock.name, {
iconName: QuoteBlock.iconName,
keywords: QuoteBlock.keywords,
onSelect: () => QuoteBlock.onSelect(editor),
})
}

View File

@@ -1,8 +1,10 @@
export function GetRemoteImageBlock(onSelect: () => void) {
return {
name: 'Image from URL',
iconName: 'image',
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
export function GetRemoteImageBlockOption(onSelect: () => void) {
return new BlockPickerOption('Image from URL', {
iconName: 'image' as LexicalIconName,
keywords: ['image', 'url'],
onSelect,
}
onSelect: onSelect,
})
}

View File

@@ -1,3 +1,54 @@
export function GetTableBlock(onSelect: () => void) {
return { name: 'Table', iconName: 'table', keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], onSelect }
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_TABLE_COMMAND } from '@lexical/table'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetTableBlockOption(onSelect: () => void) {
return new BlockPickerOption('Table', {
iconName: 'table' as LexicalIconName,
keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
onSelect: onSelect,
})
}
export function GetDynamicTableBlocks(editor: LexicalEditor, queryString: string) {
const options: Array<BlockPickerOption> = []
if (queryString == null) {
return options
}
const fullTableRegex = new RegExp(/^([1-9]|10)x([1-9]|10)$/)
const partialTableRegex = new RegExp(/^([1-9]|10)x?$/)
const fullTableMatch = fullTableRegex.exec(queryString)
const partialTableMatch = partialTableRegex.exec(queryString)
if (fullTableMatch) {
const [rows, columns] = fullTableMatch[0].split('x').map((n: string) => parseInt(n, 10))
options.push(
new BlockPickerOption(`${rows}x${columns} Table`, {
iconName: 'table',
keywords: ['table'],
onSelect: () => editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
}),
)
} else if (partialTableMatch) {
const rows = parseInt(partialTableMatch[0], 10)
options.push(
...Array.from({ length: 5 }, (_, i) => i + 1).map(
(columns) =>
new BlockPickerOption(`${rows}x${columns} Table`, {
iconName: 'table',
keywords: ['table'],
onSelect: () =>
editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
}),
),
)
}
return options
}

View File

@@ -3,7 +3,7 @@ import { KeyboardKey } from '@standardnotes/ui-services'
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
import { TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useCallback, useState, useRef, useEffect } from 'react'
import { GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
import { LexicalEditor } from 'lexical'
import { classNames } from '@standardnotes/snjs'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
@@ -12,21 +12,18 @@ type Props = {
isEditMode: boolean
setEditMode: (isEditMode: boolean) => void
editor: LexicalEditor
lastSelection: RangeSelection | GridSelection | NodeSelection | null
isAutoLink: boolean
}
const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, lastSelection, isAutoLink }: Props) => {
const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, isAutoLink }: Props) => {
const [editedLinkUrl, setEditedLinkUrl] = useState('')
const editModeContainer = useRef<HTMLDivElement>(null)
const handleLinkSubmission = () => {
if (lastSelection !== null) {
if (editedLinkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl))
}
setEditMode(false)
if (editedLinkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl))
}
setEditMode(false)
}
const focusInput = useCallback((input: HTMLInputElement | null) => {

View File

@@ -1,6 +1,6 @@
import Icon from '@/Components/Icon/Icon'
import { KeyboardKey } from '@standardnotes/ui-services'
import { $isRangeSelection, $isTextNode, GridSelection, LexicalEditor, NodeSelection, RangeSelection } from 'lexical'
import { $getSelection, $isRangeSelection, $isTextNode, LexicalEditor, RangeSelection } from 'lexical'
import { useCallback, useEffect, useRef, useState } from 'react'
import { VisuallyHidden } from '@ariakit/react'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
@@ -10,7 +10,6 @@ import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
type Props = {
linkText: string
editor: LexicalEditor
lastSelection: RangeSelection | GridSelection | NodeSelection | null
isEditMode: boolean
setEditMode: (isEditMode: boolean) => void
}
@@ -20,7 +19,7 @@ export const $isLinkTextNode = (node: ReturnType<typeof getSelectedNode>, select
return $isLinkNode(parent) && $isTextNode(node) && selection.anchor.getNode() === selection.focus.getNode()
}
const LinkTextEditor = ({ linkText, editor, isEditMode, setEditMode, lastSelection }: Props) => {
const LinkTextEditor = ({ linkText, editor, isEditMode, setEditMode }: Props) => {
const [editedLinkText, setEditedLinkText] = useState(() => linkText)
const editModeContainer = useRef<HTMLDivElement>(null)
@@ -36,13 +35,15 @@ const LinkTextEditor = ({ linkText, editor, isEditMode, setEditMode, lastSelecti
const handleLinkTextSubmission = () => {
editor.update(() => {
if ($isRangeSelection(lastSelection)) {
const node = getSelectedNode(lastSelection)
if (!$isLinkTextNode(node, lastSelection)) {
return
}
node.setTextContent(editedLinkText)
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
const node = getSelectedNode(selection)
if (!$isLinkTextNode(node, selection)) {
return
}
node.setTextContent(editedLinkText)
})
setEditMode(false)
}

View File

@@ -9,51 +9,61 @@ import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_LOW,
COMMAND_PRIORITY_NORMAL,
FORMAT_TEXT_COMMAND,
GridSelection,
KEY_MODIFIER_COMMAND,
NodeSelection,
REDO_COMMAND,
RangeSelection,
SELECTION_CHANGE_COMMAND,
UNDO_COMMAND,
createCommand,
$isRootOrShadowRoot,
ElementFormatType,
$isElementNode,
COMMAND_PRIORITY_LOW,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { ComponentPropsWithoutRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { GetAlignmentBlocks } from '../Blocks/Alignment'
import { GetBulletedListBlock } from '../Blocks/BulletedList'
import { GetChecklistBlock } from '../Blocks/Checklist'
import { GetCodeBlock } from '../Blocks/Code'
import { GetCollapsibleBlock } from '../Blocks/Collapsible'
import { GetDatetimeBlocks } from '../Blocks/DateTime'
import { GetDividerBlock } from '../Blocks/Divider'
import { GetEmbedsBlocks } from '../Blocks/Embeds'
import { GetHeadingsBlocks } from '../Blocks/Headings'
import { GetIndentOutdentBlocks } from '../Blocks/IndentOutdent'
import { GetNumberedListBlock } from '../Blocks/NumberedList'
import { GetParagraphBlock } from '../Blocks/Paragraph'
import { GetPasswordBlock } from '../Blocks/Password'
import { GetQuoteBlock } from '../Blocks/Quote'
import { GetTableBlock } from '../Blocks/Table'
import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
import { $isLinkNode, $isAutoLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { $isListNode, ListNode } from '@lexical/list'
import { $isHeadingNode } from '@lexical/rich-text'
import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react'
import { CenterAlignBlock, JustifyAlignBlock, LeftAlignBlock, RightAlignBlock } from '../Blocks/Alignment'
import { BulletedListBlock, ChecklistBlock, NumberedListBlock } from '../Blocks/List'
import { CodeBlock } from '../Blocks/Code'
import { CollapsibleBlock } from '../Blocks/Collapsible'
import { DividerBlock } from '../Blocks/Divider'
import { H1Block, H2Block, H3Block } from '../Blocks/Headings'
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 { SUPER_TOGGLE_SEARCH, SUPER_TOGGLE_TOOLBAR } from '@standardnotes/ui-services'
import { useApplication } from '@/Components/ApplicationProvider'
import { GetRemoteImageBlock } from '../Blocks/RemoteImage'
import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react'
import { PasswordBlock } from '../Blocks/Password'
import LinkEditor from './ToolbarLinkEditor'
import { FOCUSABLE_BUT_NOT_TABBABLE, URL_REGEX } from '@/Constants/Constants'
import { useSelectedTextFormatInfo } from './useSelectedTextFormatInfo'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
import LinkTextEditor, { $isLinkTextNode } from './ToolbarLinkTextEditor'
import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react'
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
const blockTypeToBlockName = {
bullet: 'Bulleted List',
check: 'Check List',
code: 'Code Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
}
interface ToolbarButtonProps extends ComponentPropsWithoutRef<'button'> {
name: string
active?: boolean
@@ -65,7 +75,7 @@ const ToolbarButton = ({ name, active, iconName, onSelect, disabled, ...props }:
const [editor] = useLexicalComposerContext()
return (
<StyledTooltip showOnMobile showOnHover label={name}>
<StyledTooltip showOnMobile showOnHover label={name} side="top">
<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) => {
@@ -94,54 +104,124 @@ const ToolbarButton = ({ name, active, iconName, onSelect, disabled, ...props }:
const ToolbarPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const [modal, showModal] = useModal()
const [isInEditor, setIsInEditor] = useState(false)
const [isInToolbar, setIsInToolbar] = useState(false)
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const containerRef = useRef<HTMLDivElement>(null)
const toolbarRef = useRef<HTMLDivElement>(null)
const linkEditorRef = useRef<HTMLDivElement>(null)
const backspaceButtonRef = useRef<HTMLButtonElement>(null)
const [modal, showModal] = useModal()
const insertLink = useCallback(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
const node = getSelectedNode(selection)
const parent = node.getParent()
const isLink = $isLinkNode(parent) || $isLinkNode(node)
if (!isLink) {
editor.update(() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
})
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}
}, [editor])
const [editor] = useLexicalComposerContext()
const [activeEditor, setActiveEditor] = useState(editor)
const {
isBold,
isItalic,
isUnderline,
isSubscript,
isSuperscript,
isStrikethrough,
blockType,
isHighlighted,
isLink,
isLinkText,
isAutoLink,
} = useSelectedTextFormatInfo()
const [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
const [elementFormat, setElementFormat] = useState<ElementFormatType>('left')
const [isBold, setIsBold] = useState(false)
const [isItalic, setIsItalic] = useState(false)
const [isUnderline, setIsUnderline] = useState(false)
const [isStrikethrough, setIsStrikethrough] = useState(false)
const [isSubscript, setIsSubscript] = useState(false)
const [isSuperscript, setIsSuperscript] = useState(false)
const [isCode, setIsCode] = useState(false)
const [isHighlight, setIsHighlight] = useState(false)
const [isLink, setIsLink] = useState(false)
const [isAutoLink, setIsAutoLink] = useState(false)
const [isLinkText, setIsLinkText] = useState(false)
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
const [isLinkTextEditMode, setIsLinkTextEditMode] = useState(false)
const [linkText, setLinkText] = useState<string>('')
const [linkUrl, setLinkUrl] = useState<string>('')
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
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 (element === null) {
element = anchorNode.getTopLevelElementOrThrow()
}
const elementKey = element.getKey()
const elementDOM = activeEditor.getElementByKey(elementKey)
// 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<ListNode>(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)
}
}
}
setElementFormat(($isElementNode(node) ? node.getFormatType() : parent?.getFormatType()) || 'left')
}
}, [activeEditor])
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
$updateToolbar()
setActiveEditor(newEditor)
return false
},
COMMAND_PRIORITY_CRITICAL,
)
}, [editor, $updateToolbar])
useEffect(() => {
return mergeRegister(
editor.registerCommand<boolean>(
activeEditor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
$updateToolbar()
})
}),
activeEditor.registerCommand<boolean>(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload)
@@ -149,7 +229,7 @@ const ToolbarPlugin = () => {
},
COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand<boolean>(
activeEditor.registerCommand<boolean>(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload)
@@ -157,7 +237,7 @@ const ToolbarPlugin = () => {
},
COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
activeEditor.registerCommand(
TOGGLE_LINK_AND_EDIT_COMMAND,
(payload) => {
if (payload === null) {
@@ -172,180 +252,54 @@ const ToolbarPlugin = () => {
},
COMMAND_PRIORITY_LOW,
),
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])
}, [$updateToolbar, activeEditor, editor])
const items = useMemo(
(): {
name: string
iconName: string
keywords?: string[]
active?: boolean
disabled?: boolean
onSelect: () => void
}[] => [
{
name: 'Undo',
iconName: 'undo',
disabled: !canUndo,
onSelect: () => {
editor.dispatchCommand(UNDO_COMMAND, undefined)
},
useEffect(() => {
return activeEditor.registerCommand(
KEY_MODIFIER_COMMAND,
(payload) => {
const event: KeyboardEvent = payload
const { code, ctrlKey, metaKey, shiftKey } = event
if (code === 'KeyK' && (ctrlKey || metaKey) && !shiftKey) {
event.preventDefault()
if ('readText' in navigator.clipboard) {
navigator.clipboard
.readText()
.then((text) => {
if (URL_REGEX.test(text)) {
activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, text)
} else {
throw new Error('Not a valid URL')
}
})
.catch((error) => {
console.error(error)
activeEditor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
setIsLinkEditMode(true)
})
} else {
activeEditor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
setIsLinkEditMode(true)
}
return true
}
return false
},
{
name: 'Redo',
iconName: 'redo',
disabled: !canRedo,
onSelect: () => {
editor.dispatchCommand(REDO_COMMAND, undefined)
},
},
{
name: 'Bold',
iconName: 'bold',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
},
active: isBold,
},
{
name: 'Italic',
iconName: 'italic',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
},
active: isItalic,
},
{
name: 'Underline',
iconName: 'underline',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
},
active: isUnderline,
},
{
name: 'Highlight',
iconName: 'draw',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'highlight')
},
active: isHighlighted,
},
{
name: 'Strikethrough',
iconName: 'strikethrough',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
},
active: isStrikethrough,
},
{
name: 'Subscript',
iconName: 'subscript',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
},
active: isSubscript,
},
{
name: 'Superscript',
iconName: 'superscript',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
},
active: isSuperscript,
},
{
name: 'Link',
iconName: 'link',
onSelect: () => {
editor.update(() => {
insertLink()
})
},
active: isLink,
},
{
name: 'Search',
iconName: 'search',
onSelect: () => {
application.keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)
},
},
GetParagraphBlock(editor),
...GetHeadingsBlocks(editor),
...GetIndentOutdentBlocks(editor),
...GetAlignmentBlocks(editor),
GetTableBlock(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetRemoteImageBlock(() => {
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}),
GetNumberedListBlock(editor, blockType === 'number'),
GetBulletedListBlock(editor, blockType === 'bullet'),
GetChecklistBlock(editor),
GetQuoteBlock(editor),
GetCodeBlock(editor),
GetDividerBlock(editor),
...GetDatetimeBlocks(editor),
...[GetPasswordBlock(editor)],
GetCollapsibleBlock(editor),
...GetEmbedsBlocks(editor),
],
[
application.keyboardService,
blockType,
canRedo,
canUndo,
editor,
insertLink,
isBold,
isHighlighted,
isItalic,
isLink,
isStrikethrough,
isSubscript,
isSuperscript,
isUnderline,
showModal,
],
)
COMMAND_PRIORITY_NORMAL,
)
}, [activeEditor, isLink])
const containerRef = useRef<HTMLDivElement>(null)
const dismissButtonRef = useRef<HTMLButtonElement>(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
useEffect(() => {
const container = containerRef.current
@@ -355,28 +309,18 @@ const ToolbarPlugin = () => {
return
}
const handleToolbarFocus = () => setIsInToolbar(true)
const handleToolbarBlur = (event: FocusEvent) => {
const elementToBeFocused = event.relatedTarget as Node
if (elementToBeFocused === backspaceButtonRef.current) {
return
}
setIsInToolbar(false)
}
const handleToolbarFocus = () => setIsFocusInToolbar(true)
const handleToolbarBlur = () => setIsFocusInToolbar(false)
const handleRootFocus = () => setIsInEditor(true)
const handleRootFocus = () => setIsFocusInEditor(true)
const handleRootBlur = (event: FocusEvent) => {
const elementToBeFocused = event.relatedTarget as Node
const containerContainsElementToFocus = container?.contains(elementToBeFocused)
const willFocusBackspaceButton = backspaceButtonRef.current && elementToBeFocused === backspaceButtonRef.current
if (containerContainsElementToFocus || willFocusBackspaceButton) {
const willFocusDismissButton = dismissButtonRef.current === elementToBeFocused
if (containerContainsElementToFocus && !willFocusDismissButton) {
return
}
setIsInEditor(false)
setIsFocusInEditor(false)
}
rootElement.addEventListener('focus', handleRootFocus)
@@ -395,70 +339,33 @@ const ToolbarPlugin = () => {
}
}, [editor])
const [linkUrl, setLinkUrl] = useState('')
const [linkText, setLinkText] = useState('')
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
const [isLinkTextEditMode, setIsLinkTextEditMode] = useState(false)
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
const updateEditorSelection = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
const nativeSelection = window.getSelection()
const activeElement = document.activeElement
const rootElement = editor.getRootElement()
if (!$isRangeSelection(selection)) {
return
}
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('')
}
if (
selection !== null &&
nativeSelection !== null &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
setLastSelection(selection)
} else if (!activeElement || activeElement.id !== 'link-input') {
setLastSelection(null)
setIsLinkEditMode(false)
setLinkUrl('')
}
})
}, [editor])
const toolbarRef = useRef<HTMLDivElement>(null)
const toolbarStore = useToolbarStore()
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateEditorSelection()
return false
},
COMMAND_PRIORITY_CRITICAL,
),
editor.registerUpdateListener(() => {
updateEditorSelection()
}),
)
}, [editor, updateEditorSelection])
return application.keyboardService.addCommandHandler({
command: SUPER_TOGGLE_TOOLBAR,
onKeyDown(event) {
if (isMobile) {
return
}
event.preventDefault()
if (!isToolbarVisible) {
setIsToolbarVisible(true)
toolbarStore.move(toolbarStore.first())
return
}
const isFocusInContainer = containerRef.current?.contains(document.activeElement)
if (isFocusInContainer) {
setIsToolbarVisible(false)
editor.focus()
} else {
toolbarStore.move(toolbarStore.first())
}
},
})
}, [application.keyboardService, editor, isMobile, isToolbarVisible, toolbarStore])
useEffect(() => {
const container = containerRef.current
const rootElement = editor.getRootElement()
@@ -474,7 +381,7 @@ const ToolbarPlugin = () => {
const containerHeight = container.offsetHeight
rootElement.style.paddingBottom = containerHeight ? `${containerHeight + 16 * 2}px` : ''
rootElement.style.paddingBottom = containerHeight ? `${containerHeight + 8}px` : ''
})
resizeObserver.observe(container)
@@ -484,47 +391,13 @@ const ToolbarPlugin = () => {
}
}, [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 (
<>
{modal}
<div
className={classNames(
'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)]',
'md:absolute md:bottom-0 md:left-1/2 md:max-w-[60%] md:-translate-x-1/2 md:rounded-t-lg md:border md:border-b-0 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)]',
!canShowToolbar ? 'hidden' : '',
)}
id="super-mobile-toolbar"
@@ -536,7 +409,6 @@ const ToolbarPlugin = () => {
<LinkTextEditor
linkText={linkText}
editor={editor}
lastSelection={lastSelection}
isEditMode={isLinkTextEditMode}
setEditMode={setIsLinkTextEditMode}
/>
@@ -551,7 +423,6 @@ const ToolbarPlugin = () => {
<>
<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
@@ -560,7 +431,6 @@ const ToolbarPlugin = () => {
setEditMode={setIsLinkEditMode}
isAutoLink={isAutoLink}
editor={editor}
lastSelection={lastSelection}
/>
</div>
<div
@@ -571,27 +441,199 @@ const ToolbarPlugin = () => {
)}
<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 px-1 [&::-webkit-scrollbar]:h-0"
ref={toolbarRef}
store={toolbarStore}
>
{items.map((item) => {
return (
<ToolbarButton
name={item.name}
iconName={item.iconName}
active={item.active}
disabled={item.disabled}
onSelect={item.onSelect}
key={item.name}
/>
)
})}
<ToolbarButton
name="Undo"
iconName="undo"
disabled={!canUndo}
onSelect={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
/>
<ToolbarButton
name="Redo"
iconName="redo"
disabled={!canRedo}
onSelect={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
/>
<ToolbarButton
name="Bold"
iconName="bold"
active={isBold}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}
/>
<ToolbarButton
name="Italic"
iconName="italic"
active={isItalic}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')}
/>
<ToolbarButton
name="Underline"
iconName="underline"
active={isUnderline}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')}
/>
<ToolbarButton
name="Highlight"
iconName="draw"
active={isHighlight}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'highlight')}
/>
<ToolbarButton name="Link" iconName="link" active={isLink} onSelect={() => {}} />
<ToolbarButton
name="Strikethrough"
iconName="strikethrough"
active={isStrikethrough}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')}
/>
<ToolbarButton
name="Subscript"
iconName="subscript"
active={isSubscript}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')}
/>
<ToolbarButton
name="Superscript"
iconName="superscript"
active={isSuperscript}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')}
/>
<ToolbarButton
name="Inline Code"
iconName="code-tags"
active={isCode}
onSelect={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')}
/>
<ToolbarButton
name="Search"
iconName="search"
onSelect={() => application.keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)}
/>
<ToolbarButton
name={ParagraphBlock.name}
iconName={ParagraphBlock.iconName}
active={blockType === 'paragraph'}
onSelect={() => ParagraphBlock.onSelect(editor)}
/>
<ToolbarButton
name={H1Block.name}
iconName={H1Block.iconName}
active={blockType === 'h1'}
onSelect={() => H1Block.onSelect(editor)}
/>
<ToolbarButton
name={H2Block.name}
iconName={H2Block.iconName}
active={blockType === 'h2'}
onSelect={() => H2Block.onSelect(editor)}
/>
<ToolbarButton
name={H3Block.name}
iconName={H3Block.iconName}
active={blockType === 'h3'}
onSelect={() => H3Block.onSelect(editor)}
/>
<ToolbarButton
name={IndentBlock.name}
iconName={IndentBlock.iconName}
onSelect={() => IndentBlock.onSelect(editor)}
/>
<ToolbarButton
name={OutdentBlock.name}
iconName={OutdentBlock.iconName}
onSelect={() => OutdentBlock.onSelect(editor)}
/>
<ToolbarButton
name={LeftAlignBlock.name}
iconName={LeftAlignBlock.iconName}
active={elementFormat === 'left'}
onSelect={() => LeftAlignBlock.onSelect(editor)}
/>
<ToolbarButton
name={CenterAlignBlock.name}
iconName={CenterAlignBlock.iconName}
active={elementFormat === 'center'}
onSelect={() => CenterAlignBlock.onSelect(editor)}
/>
<ToolbarButton
name={RightAlignBlock.name}
iconName={RightAlignBlock.iconName}
active={elementFormat === 'right'}
onSelect={() => RightAlignBlock.onSelect(editor)}
/>
<ToolbarButton
name={JustifyAlignBlock.name}
iconName={JustifyAlignBlock.iconName}
active={elementFormat === 'justify'}
onSelect={() => JustifyAlignBlock.onSelect(editor)}
/>
<ToolbarButton
name={BulletedListBlock.name}
iconName={BulletedListBlock.iconName}
active={blockType === 'bullet'}
onSelect={() => BulletedListBlock.onSelect(editor)}
/>
<ToolbarButton
name={NumberedListBlock.name}
iconName={NumberedListBlock.iconName}
active={blockType === 'number'}
onSelect={() => NumberedListBlock.onSelect(editor)}
/>
<ToolbarButton
name={ChecklistBlock.name}
iconName={ChecklistBlock.iconName}
active={blockType === 'check'}
onSelect={() => ChecklistBlock.onSelect(editor)}
/>
<ToolbarButton
name="Table"
iconName="table"
onSelect={() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />)
}
/>
<ToolbarButton
name="Image from URL"
iconName="image"
onSelect={() =>
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}
/>
<ToolbarButton
name={CodeBlock.name}
iconName={CodeBlock.iconName}
active={blockType === 'code'}
onSelect={() => CodeBlock.onSelect(editor)}
/>
<ToolbarButton
name={QuoteBlock.name}
iconName={QuoteBlock.iconName}
active={blockType === 'quote'}
onSelect={() => QuoteBlock.onSelect(editor)}
/>
<ToolbarButton
name={DividerBlock.name}
iconName={DividerBlock.iconName}
onSelect={() => DividerBlock.onSelect(editor)}
/>
<ToolbarButton
name={CollapsibleBlock.name}
iconName={CollapsibleBlock.iconName}
onSelect={() => CollapsibleBlock.onSelect(editor)}
/>
<ToolbarButton
name={PasswordBlock.name}
iconName={PasswordBlock.iconName}
onSelect={() => PasswordBlock.onSelect(editor)}
/>
</Toolbar>
{isMobile && (
<button
className="flex flex-shrink-0 items-center justify-center rounded border-l border-border px-3 py-3"
aria-label="Dismiss keyboard"
ref={dismissButtonRef}
>
<Icon type="keyboard-close" size="medium" />
</button>

View File

@@ -1,180 +0,0 @@
import { $isCodeHighlightNode } from '@lexical/code'
import { $isLinkNode, $isAutoLinkNode } from '@lexical/link'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
import {
$getSelection,
$isRangeSelection,
$isTextNode,
SELECTION_CHANGE_COMMAND,
$isRootOrShadowRoot,
COMMAND_PRIORITY_CRITICAL,
} from 'lexical'
import { $isHeadingNode } from '@lexical/rich-text'
import { $isListNode, ListNode } from '@lexical/list'
import { useCallback, useEffect, useState } from 'react'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import { $isLinkTextNode } from './ToolbarLinkTextEditor'
const blockTypeToBlockName = {
bullet: 'Bulleted List',
check: 'Check List',
code: 'Code Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
}
export function useSelectedTextFormatInfo() {
const [editor] = useLexicalComposerContext()
const [activeEditor, setActiveEditor] = useState(editor)
const [isText, setIsText] = useState(false)
const [isLink, setIsLink] = useState(false)
const [isAutoLink, setIsAutoLink] = useState(false)
const [isLinkText, setIsLinkText] = useState(false)
const [isBold, setIsBold] = useState(false)
const [isItalic, setIsItalic] = useState(false)
const [isUnderline, setIsUnderline] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
const [isStrikethrough, setIsStrikethrough] = useState(false)
const [isSubscript, setIsSubscript] = useState(false)
const [isSuperscript, setIsSuperscript] = useState(false)
const [isCode, setIsCode] = useState(false)
const [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
const updateTextFormatInfo = useCallback(() => {
editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) {
return
}
const selection = $getSelection()
const nativeSelection = window.getSelection()
const rootElement = editor.getRootElement()
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) || rootElement === null || !rootElement.contains(nativeSelection.anchorNode))
) {
setIsText(false)
return
}
if (!$isRangeSelection(selection)) {
return
}
const anchorNode = selection.anchor.getNode()
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent()
return parent !== null && $isRootOrShadowRoot(parent)
})
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow()
}
const elementKey = element.getKey()
const elementDOM = activeEditor.getElementByKey(elementKey)
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>(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 node = getSelectedNode(selection)
// 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'))
setIsHighlighted(selection.hasFormat('highlight'))
// Update links
const parent = node.getParent()
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true)
} else {
setIsLink(false)
}
if ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) {
setIsAutoLink(true)
} else {
setIsAutoLink(false)
}
if ($isLinkTextNode(node, selection)) {
setIsLinkText(true)
} else {
setIsLinkText(false)
}
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
setIsText($isTextNode(node))
} else {
setIsText(false)
}
})
}, [editor, activeEditor])
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
setActiveEditor(newEditor)
updateTextFormatInfo()
return false
},
COMMAND_PRIORITY_CRITICAL,
)
}, [editor, updateTextFormatInfo])
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updateTextFormatInfo()
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false)
}
}),
)
}, [editor, updateTextFormatInfo])
return {
isText,
isLink,
isAutoLink,
isLinkText,
isBold,
isItalic,
isUnderline,
isStrikethrough,
isSubscript,
isSuperscript,
isHighlighted,
isCode,
blockType,
}
}

View File

@@ -243,3 +243,15 @@ export const getBlobFromBase64 = (b64Data: string, contentType = '', sliceSize =
const blob = new Blob(byteArrays, { type: contentType })
return blob
}
export function getScrollParent(node: HTMLElement | null): HTMLElement | null {
if (!node) {
return null
}
if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
return node
} else {
return getScrollParent(node.parentElement)
}
}