refactor: merge blocks-editor package with web (#2217)
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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 type { LexicalEditor } from 'lexical'
|
||||
|
||||
import {
|
||||
AutoEmbedOption,
|
||||
EmbedConfig,
|
||||
EmbedMatchResult,
|
||||
LexicalAutoEmbedPlugin,
|
||||
URL_MATCHER,
|
||||
} from '@lexical/react/LexicalAutoEmbedPlugin'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useState } from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
|
||||
import useModal from '../../Lexical/Hooks/useModal'
|
||||
import Button from '../../Lexical/UI/Button'
|
||||
import { DialogActions } from '../../Lexical/UI/Dialog'
|
||||
import { INSERT_TWEET_COMMAND } from '../TwitterPlugin'
|
||||
import { INSERT_YOUTUBE_COMMAND } from '../YouTubePlugin'
|
||||
|
||||
interface PlaygroundEmbedConfig extends EmbedConfig {
|
||||
// Human readable name of the embeded content e.g. Tweet or Google Map.
|
||||
contentName: string
|
||||
|
||||
// Icon for display.
|
||||
icon?: JSX.Element
|
||||
iconName: string
|
||||
|
||||
// An example of a matching url https://twitter.com/jack/status/20
|
||||
exampleUrl: string
|
||||
|
||||
// For extra searching.
|
||||
keywords: Array<string>
|
||||
|
||||
// Embed a Figma Project.
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
|
||||
contentName: 'Youtube Video',
|
||||
|
||||
exampleUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon youtube" />,
|
||||
iconName: 'youtube',
|
||||
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id)
|
||||
},
|
||||
|
||||
keywords: ['youtube', 'video'],
|
||||
|
||||
// Determine if a given URL is a match and return url data.
|
||||
parseUrl: (url: string) => {
|
||||
const match = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url)
|
||||
|
||||
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
type: 'youtube-video',
|
||||
}
|
||||
|
||||
export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
// e.g. Tweet or Google Map.
|
||||
contentName: 'Tweet',
|
||||
|
||||
exampleUrl: 'https://twitter.com/jack/status/20',
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon tweet" />,
|
||||
iconName: 'tweet',
|
||||
|
||||
// Create the Lexical embed node from the url data.
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id)
|
||||
},
|
||||
|
||||
// For extra searching.
|
||||
keywords: ['tweet', 'twitter'],
|
||||
|
||||
// Determine if a given URL is a match and return url data.
|
||||
parseUrl: (text: string) => {
|
||||
const match = /^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text)
|
||||
|
||||
if (match != null) {
|
||||
return {
|
||||
id: match[4],
|
||||
url: match[0],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
type: 'tweet',
|
||||
}
|
||||
|
||||
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]
|
||||
|
||||
function AutoEmbedMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: AutoEmbedOption
|
||||
}) {
|
||||
let className = 'item'
|
||||
if (isSelected) {
|
||||
className += ' selected'
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text">{option.title}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoEmbedMenu({
|
||||
options,
|
||||
selectedItemIndex,
|
||||
onOptionClick,
|
||||
onOptionMouseEnter,
|
||||
}: {
|
||||
selectedItemIndex: number | null
|
||||
onOptionClick: (option: AutoEmbedOption, index: number) => void
|
||||
onOptionMouseEnter: (index: number) => void
|
||||
options: Array<AutoEmbedOption>
|
||||
}) {
|
||||
return (
|
||||
<div className="typeahead-popover">
|
||||
<ul>
|
||||
{options.map((option: AutoEmbedOption, i: number) => (
|
||||
<AutoEmbedMenuItem
|
||||
index={i}
|
||||
isSelected={selectedItemIndex === i}
|
||||
onClick={() => onOptionClick(option, i)}
|
||||
onMouseEnter={() => onOptionMouseEnter(i)}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutoEmbedDialog({
|
||||
embedConfig,
|
||||
onClose,
|
||||
}: {
|
||||
embedConfig: PlaygroundEmbedConfig
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [text, setText] = useState('')
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const urlMatch = URL_MATCHER.exec(text)
|
||||
const embedResult = text != null && urlMatch != null ? embedConfig.parseUrl(text) : null
|
||||
|
||||
const onClick = async () => {
|
||||
const result = await embedResult
|
||||
if (result != null) {
|
||||
embedConfig.insertNode(editor, result)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[600px] max-w-[90vw]">
|
||||
<div className="Input__wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="Input__input"
|
||||
placeholder={embedConfig.exampleUrl}
|
||||
value={text}
|
||||
data-test-id={`${embedConfig.type}-embed-modal-url`}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogActions>
|
||||
<Button disabled={!embedResult} onClick={onClick} data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
|
||||
Embed
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AutoEmbedPlugin(): JSX.Element {
|
||||
const [modal, showModal] = useModal()
|
||||
|
||||
const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
|
||||
showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
|
||||
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
|
||||
))
|
||||
}
|
||||
|
||||
const getMenuOptions = (activeEmbedConfig: PlaygroundEmbedConfig, embedFn: () => void, dismissFn: () => void) => {
|
||||
return [
|
||||
new AutoEmbedOption('Dismiss', {
|
||||
onSelect: dismissFn,
|
||||
}),
|
||||
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
|
||||
onSelect: embedFn,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<LexicalAutoEmbedPlugin<PlaygroundEmbedConfig>
|
||||
embedConfigs={EmbedConfigs}
|
||||
onOpenEmbedModalForConfig={openEmbedModal}
|
||||
getMenuOptions={getMenuOptions}
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
return anchorElementRef.current
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
className="typeahead-popover auto-embed-menu"
|
||||
style={{
|
||||
marginLeft: anchorElementRef.current.style.width,
|
||||
}}
|
||||
>
|
||||
<AutoEmbedMenu
|
||||
options={options}
|
||||
selectedItemIndex={selectedIndex}
|
||||
onOptionClick={(option: AutoEmbedOption, index: number) => {
|
||||
setHighlightedIndex(index)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onOptionMouseEnter={(index: number) => {
|
||||
setHighlightedIndex(index)
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { AutoLinkPlugin as LexicalAutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
|
||||
import { COMMAND_PRIORITY_EDITOR, KEY_MODIFIER_COMMAND, $getSelection } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
|
||||
const URL_MATCHER =
|
||||
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
|
||||
|
||||
const MATCHERS = [
|
||||
(text: string) => {
|
||||
const match = URL_MATCHER.exec(text)
|
||||
if (match === null) {
|
||||
return null
|
||||
}
|
||||
const fullMatch = match[0]
|
||||
return {
|
||||
index: match.index,
|
||||
length: fullMatch.length,
|
||||
text: fullMatch,
|
||||
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
export default function AutoLinkPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
KEY_MODIFIER_COMMAND,
|
||||
(event: KeyboardEvent) => {
|
||||
const isCmdK = event.key === 'k' && !event.altKey && (event.metaKey || event.ctrlKey)
|
||||
if (isCmdK) {
|
||||
const selection = $getSelection()
|
||||
if (selection) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, selection.getTextContent())
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LexicalAutoLinkPlugin matchers={MATCHERS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
|
||||
import { BlockPickerOption } from './BlockPickerOption'
|
||||
|
||||
export function BlockPickerMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: BlockPickerOption
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={`border-bottom gap-3 border-[0.5px] border-border ${PopoverItemClassNames} ${
|
||||
isSelected ? PopoverItemSelectedClassNames : ''
|
||||
}`}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon type={option.iconName} className="mt-1.5 h-5 w-5" />
|
||||
<div className="text-editor">{option.title}</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
export class BlockPickerOption extends TypeaheadOption {
|
||||
title: string
|
||||
iconName: IconType | LexicalIconName
|
||||
keywords: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
options: {
|
||||
iconName: IconType | LexicalIconName
|
||||
keywords?: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
},
|
||||
) {
|
||||
super(title)
|
||||
this.title = title
|
||||
this.keywords = options.keywords || []
|
||||
this.iconName = options.iconName
|
||||
this.keyboardShortcut = options.keyboardShortcut
|
||||
this.onSelect = options.onSelect.bind(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { TextNode } from 'lexical'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
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 Popover from '@/Components/Popover/Popover'
|
||||
import { PopoverClassNames } from '../ClassNames'
|
||||
import { GetDatetimeBlockOptions } from './Options/DateTime'
|
||||
import { isMobileScreen } from '@/Utils'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { GetIndentOutdentBlockOptions } from './Options/IndentOutdent'
|
||||
|
||||
export default function BlockPickerMenuPlugin(): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const application = useApplication()
|
||||
const [modal, showModal] = useModal()
|
||||
const [queryString, setQueryString] = useState<string | null>(null)
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
})
|
||||
|
||||
const options = useMemo(() => {
|
||||
const indentOutdentOptions = application.isNativeMobileWeb() ? GetIndentOutdentBlockOptions(editor) : []
|
||||
|
||||
const baseOptions = [
|
||||
GetParagraphBlockOption(editor),
|
||||
...GetHeadingsBlockOptions(editor),
|
||||
...indentOutdentOptions,
|
||||
GetTableBlockOption(() =>
|
||||
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
|
||||
),
|
||||
GetNumberedListBlockOption(editor),
|
||||
GetBulletedListBlockOption(editor),
|
||||
GetChecklistBlockOption(editor),
|
||||
GetQuoteBlockOption(editor),
|
||||
GetCodeBlockOption(editor),
|
||||
GetDividerBlockOption(editor),
|
||||
...GetDatetimeBlockOptions(editor),
|
||||
...GetAlignmentBlockOptions(editor),
|
||||
GetPasswordBlockOption(editor),
|
||||
GetCollapsibleBlockOption(editor),
|
||||
...GetEmbedsBlockOptions(editor),
|
||||
]
|
||||
|
||||
const dynamicOptions = [
|
||||
...GetDynamicTableBlocks(editor, queryString || ''),
|
||||
...GetDynamicPasswordBlocks(editor, queryString || ''),
|
||||
]
|
||||
|
||||
return queryString
|
||||
? [
|
||||
...dynamicOptions,
|
||||
...baseOptions.filter((option) => {
|
||||
return new RegExp(queryString, 'gi').exec(option.title) || option.keywords != null
|
||||
? option.keywords.some((keyword) => new RegExp(queryString, 'gi').exec(keyword))
|
||||
: false
|
||||
}),
|
||||
]
|
||||
: baseOptions
|
||||
}, [editor, queryString, showModal, application])
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: BlockPickerOption,
|
||||
nodeToRemove: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
matchingString: string,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove()
|
||||
}
|
||||
selectedOption.onSelect(matchingString)
|
||||
closeMenu()
|
||||
})
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<LexicalTypeaheadMenuPlugin<BlockPickerOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={options}
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
if (!anchorElementRef.current || !options.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title="Block picker"
|
||||
align="start"
|
||||
anchorElement={anchorElementRef.current}
|
||||
open={true}
|
||||
disableMobileFullscreenTakeover={true}
|
||||
side={isMobileScreen() ? 'top' : 'bottom'}
|
||||
maxHeight={(mh) => mh / 2}
|
||||
>
|
||||
<div className={PopoverClassNames}>
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<BlockPickerMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i)
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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()),
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FORMAT_ELEMENT_COMMAND, LexicalEditor, ElementFormatType } from 'lexical'
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
|
||||
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),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
|
||||
|
||||
export function GetBulletedListBlock(editor: LexicalEditor) {
|
||||
return {
|
||||
name: 'Bulleted List',
|
||||
iconName: 'list-bulleted',
|
||||
keywords: ['bulleted list', 'unordered list', 'ul'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { $createCodeNode } from '@lexical/code'
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_COLLAPSIBLE_COMMAND } from '../../Plugins/CollapsiblePlugin'
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
|
||||
export function GetCollapsibleBlock(editor: LexicalEditor) {
|
||||
return {
|
||||
name: 'Collapsible',
|
||||
iconName: 'caret-right-fill' as LexicalIconName,
|
||||
keywords: ['collapse', 'collapsible', 'toggle'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_DATETIME_COMMAND, INSERT_DATE_COMMAND, INSERT_TIME_COMMAND } from '../Commands'
|
||||
|
||||
export function GetDatetimeBlocks(editor: LexicalEditor) {
|
||||
return [
|
||||
{
|
||||
name: 'Current date and time',
|
||||
iconName: 'authenticator',
|
||||
keywords: ['date', 'current'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_DATETIME_COMMAND, 'datetime'),
|
||||
},
|
||||
{
|
||||
name: 'Current time',
|
||||
iconName: 'authenticator',
|
||||
keywords: ['time', 'current'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_TIME_COMMAND, 'datetime'),
|
||||
},
|
||||
{
|
||||
name: 'Current date',
|
||||
iconName: 'authenticator',
|
||||
keywords: ['date', 'current'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_DATE_COMMAND, 'datetime'),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
|
||||
export function GetDividerBlock(editor: LexicalEditor) {
|
||||
return {
|
||||
name: 'Divider',
|
||||
iconName: 'horizontal-rule',
|
||||
keywords: ['horizontal rule', 'divider', 'hr'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPlugin'
|
||||
import { EmbedConfigs } from '../AutoEmbedPlugin'
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
|
||||
export function GetEmbedsBlocks(editor: LexicalEditor) {
|
||||
return EmbedConfigs.map((embedConfig) => ({
|
||||
name: `Embed ${embedConfig.contentName}`,
|
||||
iconName: embedConfig.iconName as LexicalIconName,
|
||||
keywords: [...embedConfig.keywords, 'embed'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { $createHeadingNode, HeadingTagType } from '@lexical/rich-text'
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
|
||||
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))
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, LexicalEditor } from 'lexical'
|
||||
|
||||
export function GetIndentOutdentBlocks(editor: LexicalEditor) {
|
||||
return [
|
||||
{
|
||||
name: 'Indent',
|
||||
iconName: 'arrow-right',
|
||||
keywords: ['indent'],
|
||||
onSelect: () => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
|
||||
},
|
||||
{
|
||||
name: 'Outdent',
|
||||
iconName: 'arrow-left',
|
||||
keywords: ['outdent'],
|
||||
onSelect: () => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
|
||||
|
||||
export function GetNumberedListBlock(editor: LexicalEditor) {
|
||||
return {
|
||||
name: 'Numbered List',
|
||||
iconName: 'list-numbered',
|
||||
keywords: ['numbered list', 'ordered list', 'ol'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $createParagraphNode, $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
|
||||
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())
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_PASSWORD_COMMAND } from '../Commands'
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { $wrapNodes } from '@lexical/selection'
|
||||
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { $createQuoteNode } from '@lexical/rich-text'
|
||||
|
||||
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())
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function GetTableBlock(onSelect: () => void) {
|
||||
return { name: 'Table', iconName: 'table', keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], onSelect }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type ChangeEditorFunction = (jsonContent: string) => void
|
||||
type ChangeEditorFunctionProvider = (changeEditorFunction: ChangeEditorFunction) => void
|
||||
|
||||
export function ChangeContentCallbackPlugin({
|
||||
providerCallback,
|
||||
}: {
|
||||
providerCallback: ChangeEditorFunctionProvider
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const changeContents: ChangeEditorFunction = (jsonContent: string) => {
|
||||
editor.update(() => {
|
||||
const editorState = editor.parseEditorState(jsonContent)
|
||||
editor.setEditorState(editorState)
|
||||
})
|
||||
}
|
||||
|
||||
providerCallback(changeContents)
|
||||
}, [editor, providerCallback])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
|
||||
export const PopoverClassNames = classNames(
|
||||
'z-dropdown-menu w-full',
|
||||
'cursor-auto flex-col overflow-y-auto rounded bg-default h-auto',
|
||||
)
|
||||
|
||||
export const PopoverItemClassNames = classNames(
|
||||
'flex w-full items-center text-base overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground',
|
||||
'focus:bg-info-backdrop cursor-pointer m-0 focus:bg-contrast focus:text-foreground',
|
||||
)
|
||||
|
||||
export const PopoverItemSelectedClassNames = classNames('bg-contrast text-foreground')
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 { registerCodeHighlighting } from '@lexical/code'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function CodeHighlightPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return registerCodeHighlighting(editor)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import { $getNodeByKey, $getSelection, $isRangeSelection, $isRootOrShadowRoot, NodeKey } from 'lexical'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { $isCodeNode, CODE_LANGUAGE_MAP, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from '@lexical/code'
|
||||
import Dropdown from '@/Components/Dropdown/Dropdown'
|
||||
|
||||
function getCodeLanguageOptions(): [string, string][] {
|
||||
const options: [string, string][] = []
|
||||
|
||||
for (const [lang, friendlyName] of Object.entries(CODE_LANGUAGE_FRIENDLY_NAME_MAP)) {
|
||||
options.push([lang, friendlyName])
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const CODE_LANGUAGE_OPTIONS = getCodeLanguageOptions()
|
||||
|
||||
const CodeOptionsPlugin = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [isCode, setIsCode] = useState(false)
|
||||
const [codeLanguage, setCodeLanguage] = useState<keyof typeof CODE_LANGUAGE_MAP>('')
|
||||
const [selectedElementKey, setSelectedElementKey] = useState<NodeKey | null>(null)
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
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 = editor.getElementByKey(elementKey)
|
||||
|
||||
if (elementDOM !== null) {
|
||||
setSelectedElementKey(elementKey)
|
||||
if ($isCodeNode(element)) {
|
||||
setIsCode(true)
|
||||
const language = element.getLanguage() as keyof typeof CODE_LANGUAGE_MAP
|
||||
setCodeLanguage(language ? CODE_LANGUAGE_MAP[language] || language : '')
|
||||
} else {
|
||||
setIsCode(false)
|
||||
}
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateToolbar()
|
||||
})
|
||||
}),
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
const onCodeLanguageSelect = useCallback(
|
||||
(value: string) => {
|
||||
editor.update(() => {
|
||||
if (selectedElementKey !== null) {
|
||||
const node = $getNodeByKey(selectedElementKey)
|
||||
if ($isCodeNode(node)) {
|
||||
node.setLanguage(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[editor, selectedElementKey],
|
||||
)
|
||||
|
||||
if (!isCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-2 right-6 rounded border border-border bg-default p-2">
|
||||
<Dropdown
|
||||
id="code-language-dropdown"
|
||||
label="Change code block language"
|
||||
items={CODE_LANGUAGE_OPTIONS.map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
value={normalizeCodeLang(codeLanguage)}
|
||||
onChange={(value: string) => {
|
||||
onCodeLanguageSelect(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeOptionsPlugin
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.Collapsible__container {
|
||||
background: var(--sn-stylekit-contrast-background-color);
|
||||
border: 1px solid var(--sn-stylekit-contrast-border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.Collapsible__title {
|
||||
cursor: pointer;
|
||||
padding: 5px 5px 5px 20px;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.Collapsible__title::marker,
|
||||
.Collapsible__title::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Collapsible__title:before {
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-width: 4px 6px 4px 6px;
|
||||
border-left-color: #000;
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.Collapsible__container[open] .Collapsible__title:before {
|
||||
border-color: transparent;
|
||||
border-width: 6px 4px 0 4px;
|
||||
border-top-color: var(--sn-stylekit-contrast-color);
|
||||
}
|
||||
|
||||
.Collapsible__content {
|
||||
padding: 0 5px 5px 20px;
|
||||
}
|
||||
|
||||
.Collapsible__collapsed .Collapsible__content {
|
||||
display: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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 {
|
||||
DOMConversionMap,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical'
|
||||
|
||||
type SerializedCollapsibleContainerNode = Spread<
|
||||
{
|
||||
type: 'collapsible-container'
|
||||
version: 1
|
||||
open: boolean
|
||||
},
|
||||
SerializedElementNode
|
||||
>
|
||||
|
||||
export class CollapsibleContainerNode extends ElementNode {
|
||||
__open: boolean
|
||||
|
||||
constructor(open: boolean, key?: NodeKey) {
|
||||
super(key)
|
||||
this.__open = open ?? false
|
||||
}
|
||||
|
||||
static override getType(): string {
|
||||
return 'collapsible-container'
|
||||
}
|
||||
|
||||
static override clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(node.__open, node.__key)
|
||||
}
|
||||
|
||||
override createDOM(_: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('details')
|
||||
dom.classList.add('Collapsible__container')
|
||||
dom.open = this.__open
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(prevNode: CollapsibleContainerNode, dom: HTMLDetailsElement): boolean {
|
||||
if (prevNode.__open !== this.__open) {
|
||||
dom.open = this.__open
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {}
|
||||
}
|
||||
|
||||
static override importJSON(serializedNode: SerializedCollapsibleContainerNode): CollapsibleContainerNode {
|
||||
const node = $createCollapsibleContainerNode(serializedNode.open)
|
||||
return node
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedCollapsibleContainerNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-container',
|
||||
version: 1,
|
||||
open: this.__open,
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(open: boolean): void {
|
||||
const writable = this.getWritable()
|
||||
writable.__open = open
|
||||
}
|
||||
|
||||
getOpen(): boolean {
|
||||
return this.__open
|
||||
}
|
||||
|
||||
toggleOpen(): void {
|
||||
this.setOpen(!this.getOpen())
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleContainerNode(open: boolean): CollapsibleContainerNode {
|
||||
return new CollapsibleContainerNode(open)
|
||||
}
|
||||
|
||||
export function $isCollapsibleContainerNode(node: LexicalNode | null | undefined): node is CollapsibleContainerNode {
|
||||
return node instanceof CollapsibleContainerNode
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 { DOMConversionMap, EditorConfig, ElementNode, LexicalNode, SerializedElementNode, Spread } from 'lexical'
|
||||
|
||||
type SerializedCollapsibleContentNode = Spread<
|
||||
{
|
||||
type: 'collapsible-content'
|
||||
version: 1
|
||||
},
|
||||
SerializedElementNode
|
||||
>
|
||||
|
||||
export class CollapsibleContentNode extends ElementNode {
|
||||
static override getType(): string {
|
||||
return 'collapsible-content'
|
||||
}
|
||||
|
||||
static override clone(node: CollapsibleContentNode): CollapsibleContentNode {
|
||||
return new CollapsibleContentNode(node.__key)
|
||||
}
|
||||
|
||||
override createDOM(_config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement('div')
|
||||
dom.classList.add('Collapsible__content')
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(_prevNode: CollapsibleContentNode, _dom: HTMLElement): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {}
|
||||
}
|
||||
|
||||
static override importJSON(_serializedNode: SerializedCollapsibleContentNode): CollapsibleContentNode {
|
||||
return $createCollapsibleContentNode()
|
||||
}
|
||||
|
||||
override isShadowRoot(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedCollapsibleContentNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-content',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleContentNode(): CollapsibleContentNode {
|
||||
return new CollapsibleContentNode()
|
||||
}
|
||||
|
||||
export function $isCollapsibleContentNode(node: LexicalNode | null | undefined): node is CollapsibleContentNode {
|
||||
return node instanceof CollapsibleContentNode
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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 {
|
||||
$createParagraphNode,
|
||||
$isElementNode,
|
||||
DOMConversionMap,
|
||||
EditorConfig,
|
||||
ElementFormatType,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
RangeSelection,
|
||||
SerializedElementNode,
|
||||
Spread,
|
||||
} from 'lexical'
|
||||
|
||||
import { $isCollapsibleContainerNode } from './CollapsibleContainerNode'
|
||||
import { $isCollapsibleContentNode } from './CollapsibleContentNode'
|
||||
|
||||
type SerializedCollapsibleTitleNode = Spread<
|
||||
{
|
||||
type: 'collapsible-title'
|
||||
version: 1
|
||||
},
|
||||
SerializedElementNode
|
||||
>
|
||||
|
||||
export class CollapsibleTitleNode extends ElementNode {
|
||||
static override getType(): string {
|
||||
return 'collapsible-title'
|
||||
}
|
||||
|
||||
constructor({ formatType, key }: { formatType?: ElementFormatType; key?: NodeKey }) {
|
||||
super(key)
|
||||
if (formatType) {
|
||||
super.setFormat(formatType)
|
||||
}
|
||||
}
|
||||
|
||||
static override clone(node: CollapsibleTitleNode): CollapsibleTitleNode {
|
||||
return new CollapsibleTitleNode({ key: node.__key })
|
||||
}
|
||||
|
||||
override createDOM(_config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
||||
const dom = document.createElement('summary')
|
||||
dom.classList.add('Collapsible__title')
|
||||
const format = this.getFormatType()
|
||||
dom.style.textAlign = format
|
||||
dom.onclick = (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
editor.update(() => {
|
||||
const containerNode = this.getParentOrThrow()
|
||||
if ($isCollapsibleContainerNode(containerNode)) {
|
||||
containerNode.toggleOpen()
|
||||
}
|
||||
})
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(_prevNode: CollapsibleTitleNode, _dom: HTMLElement): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {}
|
||||
}
|
||||
|
||||
static override importJSON(serializedNode: SerializedCollapsibleTitleNode): CollapsibleTitleNode {
|
||||
return $createCollapsibleTitleNode(serializedNode.format)
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedCollapsibleTitleNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'collapsible-title',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
override collapseAtStart(_selection: RangeSelection): boolean {
|
||||
this.getParentOrThrow().insertBefore(this)
|
||||
return true
|
||||
}
|
||||
|
||||
override insertNewAfter(): ElementNode {
|
||||
const containerNode = this.getParentOrThrow()
|
||||
|
||||
if (!$isCollapsibleContainerNode(containerNode)) {
|
||||
throw new Error('CollapsibleTitleNode expects to be child of CollapsibleContainerNode')
|
||||
}
|
||||
|
||||
if (containerNode.getOpen()) {
|
||||
const contentNode = this.getNextSibling()
|
||||
if (!$isCollapsibleContentNode(contentNode)) {
|
||||
throw new Error('CollapsibleTitleNode expects to have CollapsibleContentNode sibling')
|
||||
}
|
||||
|
||||
const firstChild = contentNode.getFirstChild()
|
||||
if ($isElementNode(firstChild)) {
|
||||
return firstChild
|
||||
} else {
|
||||
const paragraph = $createParagraphNode()
|
||||
contentNode.append(paragraph)
|
||||
return paragraph
|
||||
}
|
||||
} else {
|
||||
const paragraph = $createParagraphNode()
|
||||
containerNode.insertAfter(paragraph)
|
||||
return paragraph
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCollapsibleTitleNode(formatType?: ElementFormatType): CollapsibleTitleNode {
|
||||
return new CollapsibleTitleNode({
|
||||
formatType,
|
||||
})
|
||||
}
|
||||
|
||||
export function $isCollapsibleTitleNode(node: LexicalNode | null | undefined): node is CollapsibleTitleNode {
|
||||
return node instanceof CollapsibleTitleNode
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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 './Collapsible.css'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getNodeByKey,
|
||||
$getPreviousSelection,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
createCommand,
|
||||
DELETE_CHARACTER_COMMAND,
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
NodeKey,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
$createCollapsibleContainerNode,
|
||||
$isCollapsibleContainerNode,
|
||||
CollapsibleContainerNode,
|
||||
} from './CollapsibleContainerNode'
|
||||
import {
|
||||
$createCollapsibleContentNode,
|
||||
$isCollapsibleContentNode,
|
||||
CollapsibleContentNode,
|
||||
} from './CollapsibleContentNode'
|
||||
import { $createCollapsibleTitleNode, $isCollapsibleTitleNode, CollapsibleTitleNode } from './CollapsibleTitleNode'
|
||||
|
||||
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>()
|
||||
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>()
|
||||
|
||||
export default function CollapsiblePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([CollapsibleContainerNode, CollapsibleTitleNode, CollapsibleContentNode])) {
|
||||
throw new Error(
|
||||
'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
|
||||
)
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
// Structure enforcing transformers for each node type. In case nesting structure is not
|
||||
// "Container > Title + Content" it'll unwrap nodes and convert it back
|
||||
// to regular content.
|
||||
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
|
||||
const parent = node.getParent()
|
||||
if (!$isCollapsibleContainerNode(parent)) {
|
||||
const children = node.getChildren()
|
||||
for (const child of children) {
|
||||
node.insertAfter(child)
|
||||
}
|
||||
node.remove()
|
||||
}
|
||||
}),
|
||||
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
|
||||
const parent = node.getParent()
|
||||
if (!$isCollapsibleContainerNode(parent)) {
|
||||
node.replace($createParagraphNode().append(...node.getChildren()))
|
||||
}
|
||||
}),
|
||||
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
|
||||
const children = node.getChildren()
|
||||
if (children.length !== 2 || !$isCollapsibleTitleNode(children[0]) || !$isCollapsibleContentNode(children[1])) {
|
||||
for (const child of children) {
|
||||
node.insertAfter(child)
|
||||
}
|
||||
node.remove()
|
||||
}
|
||||
}),
|
||||
// This handles the case when container is collapsed and we delete its previous sibling
|
||||
// into it, it would cause collapsed content deleted (since it's display: none, and selection
|
||||
// swallows it when deletes single char). Instead we expand container, which is although
|
||||
// not perfect, but avoids bigger problem
|
||||
editor.registerCommand(
|
||||
DELETE_CHARACTER_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const topLevelElement = anchorNode.getTopLevelElement()
|
||||
if (topLevelElement === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const container = topLevelElement.getPreviousSibling()
|
||||
if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
|
||||
return false
|
||||
}
|
||||
|
||||
container.setOpen(true)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
// When collapsible is the last child pressing down arrow will insert paragraph
|
||||
// below it to allow adding more content. It's similar what $insertBlockNode
|
||||
// (mainly for decorators), except it'll always be possible to continue adding
|
||||
// new content even if trailing paragraph is accidentally deleted
|
||||
editor.registerCommand(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode)
|
||||
|
||||
if (container === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parent = container.getParent()
|
||||
if (parent !== null && parent.getLastChild() === container) {
|
||||
parent.append($createParagraphNode())
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
// Handling CMD+Enter to toggle collapsible element collapsed state
|
||||
editor.registerCommand(
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const windowEvent: KeyboardEvent | undefined = editor._window?.event
|
||||
|
||||
if (windowEvent && (windowEvent.ctrlKey || windowEvent.metaKey) && windowEvent.key === 'Enter') {
|
||||
const selection = $getPreviousSelection()
|
||||
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
||||
const parent = $findMatchingParent(
|
||||
selection.anchor.getNode(),
|
||||
(node) => $isElementNode(node) && !node.isInline(),
|
||||
)
|
||||
|
||||
if ($isCollapsibleTitleNode(parent)) {
|
||||
const container = parent.getParent()
|
||||
if ($isCollapsibleContainerNode(container)) {
|
||||
container.toggleOpen()
|
||||
$setSelection(selection.clone())
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
INSERT_COLLAPSIBLE_COMMAND,
|
||||
() => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return
|
||||
}
|
||||
|
||||
const title = $createCollapsibleTitleNode()
|
||||
const content = $createCollapsibleContentNode().append($createParagraphNode())
|
||||
const container = $createCollapsibleContainerNode(true).append(title, content)
|
||||
selection.insertNodes([container])
|
||||
title.selectStart()
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
TOGGLE_COLLAPSIBLE_COMMAND,
|
||||
(key: NodeKey) => {
|
||||
editor.update(() => {
|
||||
const containerNode = $getNodeByKey(key)
|
||||
if ($isCollapsibleContainerNode(containerNode)) {
|
||||
containerNode.toggleOpen()
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createCommand, LexicalCommand } from 'lexical'
|
||||
|
||||
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand('INSERT_FILE_COMMAND')
|
||||
export const INSERT_BUBBLE_COMMAND: LexicalCommand<string> = createCommand('INSERT_BUBBLE_COMMAND')
|
||||
export const INSERT_TIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_TIME_COMMAND')
|
||||
export const INSERT_DATE_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATE_COMMAND')
|
||||
export const INSERT_DATETIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATETIME_COMMAND')
|
||||
export const INSERT_PASSWORD_COMMAND: LexicalCommand<string> = createCommand('INSERT_PASSWORD_COMMAND')
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$createParagraphNode,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { INSERT_DATETIME_COMMAND, INSERT_TIME_COMMAND, INSERT_DATE_COMMAND } from '../Commands'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $createHeadingNode } from '@lexical/rich-text'
|
||||
import { formatDateAndTimeForNote, dateToHoursAndMinutesTimeString } from '@/Utils/DateUtils'
|
||||
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
|
||||
export default function DatetimePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand<string>(
|
||||
INSERT_DATETIME_COMMAND,
|
||||
() => {
|
||||
const now = new Date()
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const heading = $createHeadingNode('h1')
|
||||
const dateString = $createTextNode(formatDateAndTimeForNote(now, false))
|
||||
dateString.setFormat('italic')
|
||||
heading.append(dateString)
|
||||
|
||||
const timeNode = $createTextNode(dateToHoursAndMinutesTimeString(now))
|
||||
timeNode.toggleFormat('superscript')
|
||||
timeNode.toggleFormat('italic')
|
||||
heading.append(timeNode)
|
||||
|
||||
const newLineNode = $createParagraphNode()
|
||||
|
||||
selection.insertNodes([heading, newLineNode])
|
||||
|
||||
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand<string>(
|
||||
INSERT_DATE_COMMAND,
|
||||
() => {
|
||||
const now = new Date()
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const heading = $createHeadingNode('h1')
|
||||
const dateString = $createTextNode(formatDateAndTimeForNote(now, false))
|
||||
dateString.setFormat('italic')
|
||||
heading.append(dateString)
|
||||
|
||||
const newLineNode = $createParagraphNode()
|
||||
|
||||
selection.insertNodes([heading, newLineNode])
|
||||
|
||||
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand<string>(
|
||||
INSERT_TIME_COMMAND,
|
||||
() => {
|
||||
const now = new Date()
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const heading = $createHeadingNode('h2')
|
||||
const dateString = $createTextNode(dateToHoursAndMinutesTimeString(now))
|
||||
dateString.setFormat('italic')
|
||||
heading.append(dateString)
|
||||
|
||||
const newLineNode = $createParagraphNode()
|
||||
|
||||
selection.insertNodes([heading, newLineNode])
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.draggable-block-menu {
|
||||
border-radius: 4px;
|
||||
padding: 3px 1px;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.draggable-block-menu .icon {
|
||||
width: 0.8rem;
|
||||
height: 1.1rem;
|
||||
opacity: 0.2;
|
||||
padding-left: 4.75px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.draggable-block-menu:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.draggable-block-menu:hover {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
}
|
||||
|
||||
.draggable-block-target-line {
|
||||
pointer-events: none;
|
||||
background: var(--sn-stylekit-info-color);
|
||||
height: 0.25rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* 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 { $createListNode, $isListNode } from '@lexical/list'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { eventFiles } from '@lexical/rich-text'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DRAGOVER_COMMAND,
|
||||
DROP_COMMAND,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical'
|
||||
import { DragEvent as ReactDragEvent, TouchEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BlockIcon } from '@standardnotes/icons'
|
||||
|
||||
import { isHTMLElement } from '../../Lexical/Utils/guard'
|
||||
import { Point } from '../../Lexical/Utils/point'
|
||||
import { ContainsPointReturn, Rect } from '../../Lexical/Utils/rect'
|
||||
|
||||
const DRAGGABLE_BLOCK_MENU_LEFT_SPACE = -2
|
||||
const TARGET_LINE_HALF_HEIGHT = 2
|
||||
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
|
||||
const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block'
|
||||
const TEXT_BOX_HORIZONTAL_PADDING = 24
|
||||
|
||||
const Downward = 1
|
||||
const Upward = -1
|
||||
const Indeterminate = 0
|
||||
|
||||
let prevIndex = Infinity
|
||||
|
||||
function getCurrentIndex(keysLength: number): number {
|
||||
if (keysLength === 0) {
|
||||
return Infinity
|
||||
}
|
||||
if (prevIndex >= 0 && prevIndex < keysLength) {
|
||||
return prevIndex
|
||||
}
|
||||
|
||||
return Math.floor(keysLength / 2)
|
||||
}
|
||||
|
||||
function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
|
||||
return editor.getEditorState().read(() => $getRoot().getChildrenKeys())
|
||||
}
|
||||
|
||||
function elementContainingEventLocation(
|
||||
anchorElem: HTMLElement,
|
||||
element: HTMLElement,
|
||||
eventLocation: Point,
|
||||
): { contains: ContainsPointReturn; element: HTMLElement } {
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
|
||||
const elementDomRect = Rect.fromDOM(element)
|
||||
const { marginTop, marginBottom } = window.getComputedStyle(element)
|
||||
|
||||
const rect = elementDomRect.generateNewRect({
|
||||
bottom: elementDomRect.bottom + parseFloat(marginBottom),
|
||||
left: anchorElementRect.left,
|
||||
right: anchorElementRect.right,
|
||||
top: elementDomRect.top - parseFloat(marginTop),
|
||||
})
|
||||
|
||||
const children = Array.from(element.children)
|
||||
|
||||
const shouldRecurseIntoChildren = ['UL', 'OL', 'LI'].includes(element.tagName)
|
||||
|
||||
if (shouldRecurseIntoChildren) {
|
||||
for (const child of children) {
|
||||
const isLeaf = child.children.length === 0
|
||||
if (isLeaf) {
|
||||
continue
|
||||
}
|
||||
const childResult = elementContainingEventLocation(anchorElem, child as HTMLElement, eventLocation)
|
||||
|
||||
if (childResult.contains.result) {
|
||||
return childResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { contains: rect.contains(eventLocation), element: element }
|
||||
}
|
||||
|
||||
function getBlockElement(anchorElem: HTMLElement, editor: LexicalEditor, eventLocation: Point): HTMLElement | null {
|
||||
const topLevelNodeKeys = getTopLevelNodeKeys(editor)
|
||||
|
||||
let blockElem: HTMLElement | null = null
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
let index = getCurrentIndex(topLevelNodeKeys.length)
|
||||
let direction = Indeterminate
|
||||
|
||||
while (index >= 0 && index < topLevelNodeKeys.length) {
|
||||
const key = topLevelNodeKeys[index]
|
||||
const elem = editor.getElementByKey(key)
|
||||
if (elem === null) {
|
||||
break
|
||||
}
|
||||
const { contains, element } = elementContainingEventLocation(anchorElem, elem, eventLocation)
|
||||
|
||||
if (contains.result) {
|
||||
blockElem = element
|
||||
prevIndex = index
|
||||
break
|
||||
}
|
||||
|
||||
if (direction === Indeterminate) {
|
||||
if (contains.reason.isOnTopSide) {
|
||||
direction = Upward
|
||||
} else if (contains.reason.isOnBottomSide) {
|
||||
direction = Downward
|
||||
} else {
|
||||
// stop search block element
|
||||
direction = Infinity
|
||||
}
|
||||
}
|
||||
|
||||
index += direction
|
||||
}
|
||||
})
|
||||
|
||||
return blockElem
|
||||
}
|
||||
|
||||
function isOnMenu(element: HTMLElement): boolean {
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
|
||||
}
|
||||
|
||||
function setMenuPosition(targetElem: HTMLElement | null, floatingElem: HTMLElement, anchorElem: HTMLElement) {
|
||||
if (!targetElem) {
|
||||
floatingElem.style.opacity = '0'
|
||||
return
|
||||
}
|
||||
|
||||
const targetRect = targetElem.getBoundingClientRect()
|
||||
const targetStyle = window.getComputedStyle(targetElem)
|
||||
const floatingElemRect = floatingElem.getBoundingClientRect()
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
|
||||
const top =
|
||||
targetRect.top + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - anchorElementRect.top
|
||||
|
||||
const left = DRAGGABLE_BLOCK_MENU_LEFT_SPACE
|
||||
|
||||
floatingElem.style.opacity = '1'
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`
|
||||
}
|
||||
|
||||
function setDragImage(dataTransfer: DataTransfer, draggableBlockElem: HTMLElement) {
|
||||
const { transform } = draggableBlockElem.style
|
||||
|
||||
// Remove dragImage borders
|
||||
draggableBlockElem.style.transform = 'translateZ(0)'
|
||||
dataTransfer.setDragImage(draggableBlockElem, 0, 0)
|
||||
|
||||
setTimeout(() => {
|
||||
draggableBlockElem.style.transform = transform
|
||||
})
|
||||
}
|
||||
|
||||
function setTargetLine(
|
||||
targetLineElem: HTMLElement,
|
||||
targetBlockElem: HTMLElement,
|
||||
mouseY: number,
|
||||
anchorElem: HTMLElement,
|
||||
) {
|
||||
const targetStyle = window.getComputedStyle(targetBlockElem)
|
||||
const { top: targetBlockElemTop, height: targetBlockElemHeight } = targetBlockElem.getBoundingClientRect()
|
||||
const { top: anchorTop, width: anchorWidth } = anchorElem.getBoundingClientRect()
|
||||
|
||||
let lineTop = targetBlockElemTop
|
||||
// At the bottom of the target
|
||||
if (mouseY - targetBlockElemTop > targetBlockElemHeight / 2) {
|
||||
lineTop += targetBlockElemHeight + parseFloat(targetStyle.marginBottom)
|
||||
} else {
|
||||
lineTop -= parseFloat(targetStyle.marginTop)
|
||||
}
|
||||
|
||||
const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT
|
||||
const left = TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE
|
||||
|
||||
targetLineElem.style.transform = `translate(${left}px, ${top}px)`
|
||||
targetLineElem.style.width = `${anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - DRAGGABLE_BLOCK_MENU_LEFT_SPACE) * 2}px`
|
||||
targetLineElem.style.opacity = '.6'
|
||||
}
|
||||
|
||||
function hideTargetLine(targetLineElem: HTMLElement | null) {
|
||||
if (targetLineElem) {
|
||||
targetLineElem.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
|
||||
function useDraggableBlockMenu(editor: LexicalEditor, anchorElem: HTMLElement, isEditable: boolean): JSX.Element {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const targetLineRef = useRef<HTMLDivElement>(null)
|
||||
const [draggableBlockElem, setDraggableBlockElem] = useState<HTMLElement | null>(null)
|
||||
const dragDataRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const target = event.target
|
||||
if (!isHTMLElement(target)) {
|
||||
setDraggableBlockElem(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (isOnMenu(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const _draggableBlockElem = getBlockElement(anchorElem, editor, new Point(event.clientX, event.clientY))
|
||||
|
||||
setDraggableBlockElem(_draggableBlockElem)
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
setDraggableBlockElem(null)
|
||||
}
|
||||
|
||||
scrollerElem?.addEventListener('mousemove', onMouseMove)
|
||||
scrollerElem?.addEventListener('mouseleave', onMouseLeave)
|
||||
|
||||
return () => {
|
||||
scrollerElem?.removeEventListener('mousemove', onMouseMove)
|
||||
scrollerElem?.removeEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
}, [scrollerElem, anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
setMenuPosition(draggableBlockElem, menuRef.current, anchorElem)
|
||||
}
|
||||
}, [anchorElem, draggableBlockElem])
|
||||
|
||||
const insertDraggedNode = useCallback(
|
||||
(draggedNode: LexicalNode, targetNode: LexicalNode, targetBlockElem: HTMLElement, pageY: number) => {
|
||||
let nodeToInsert = draggedNode
|
||||
const targetParent = targetNode.getParent()
|
||||
const sourceParent = draggedNode.getParent()
|
||||
|
||||
if ($isListNode(sourceParent) && !$isListNode(targetParent)) {
|
||||
const newList = $createListNode(sourceParent.getListType())
|
||||
newList.append(draggedNode)
|
||||
nodeToInsert = newList
|
||||
}
|
||||
|
||||
const { top, height } = targetBlockElem.getBoundingClientRect()
|
||||
const shouldInsertAfter = pageY - top > height / 2
|
||||
if (shouldInsertAfter) {
|
||||
targetNode.insertAfter(nodeToInsert)
|
||||
} else {
|
||||
targetNode.insertBefore(nodeToInsert)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function onDragover(event: DragEvent): boolean {
|
||||
const [isFileTransfer] = eventFiles(event)
|
||||
if (isFileTransfer) {
|
||||
return false
|
||||
}
|
||||
const { pageY, target } = event
|
||||
if (!isHTMLElement(target)) {
|
||||
return false
|
||||
}
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
|
||||
const targetLineElem = targetLineRef.current
|
||||
if (targetBlockElem === null || targetLineElem === null) {
|
||||
return false
|
||||
}
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
|
||||
// Prevent default event to be able to trigger onDrop events
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): boolean {
|
||||
const [isFileTransfer] = eventFiles(event)
|
||||
if (isFileTransfer) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { target, dataTransfer, pageY } = event
|
||||
if (!isHTMLElement(target)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ''
|
||||
const draggedNode = $getNodeByKey(dragData)
|
||||
if (!draggedNode) {
|
||||
return false
|
||||
}
|
||||
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(event.pageX, pageY))
|
||||
if (!targetBlockElem) {
|
||||
return false
|
||||
}
|
||||
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
|
||||
if (!targetNode) {
|
||||
return false
|
||||
}
|
||||
if (targetNode === draggedNode) {
|
||||
return true
|
||||
}
|
||||
|
||||
insertDraggedNode(draggedNode, targetNode, targetBlockElem, event.pageY)
|
||||
|
||||
setDraggableBlockElem(null)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
DRAGOVER_COMMAND,
|
||||
(event) => {
|
||||
return onDragover(event)
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
return onDrop(event)
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
)
|
||||
}, [anchorElem, editor, insertDraggedNode])
|
||||
|
||||
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer || !draggableBlockElem) {
|
||||
return
|
||||
}
|
||||
setDragImage(dataTransfer, draggableBlockElem)
|
||||
let nodeKey = ''
|
||||
editor.update(() => {
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem)
|
||||
if (node) {
|
||||
nodeKey = node.getKey()
|
||||
}
|
||||
})
|
||||
dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey)
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
hideTargetLine(targetLineRef.current)
|
||||
}
|
||||
|
||||
function onTouchStart(): void {
|
||||
if (!draggableBlockElem) {
|
||||
return
|
||||
}
|
||||
editor.update(() => {
|
||||
const node = $getNearestNodeFromDOMNode(draggableBlockElem)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const nodeKey = node.getKey()
|
||||
dragDataRef.current = nodeKey
|
||||
})
|
||||
}
|
||||
|
||||
function onTouchMove(event: TouchEvent) {
|
||||
const { pageX, pageY } = event.targetTouches[0]
|
||||
const rootElement = editor.getRootElement()
|
||||
if (rootElement) {
|
||||
const { top, bottom } = rootElement.getBoundingClientRect()
|
||||
const scrollOffset = 20
|
||||
if (pageY - top < scrollOffset) {
|
||||
rootElement.scrollTop -= scrollOffset
|
||||
} else if (bottom - pageY < scrollOffset) {
|
||||
rootElement.scrollTop += scrollOffset
|
||||
}
|
||||
}
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
|
||||
const targetLineElem = targetLineRef.current
|
||||
if (targetBlockElem === null || targetLineElem === null) {
|
||||
return
|
||||
}
|
||||
setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem)
|
||||
}
|
||||
|
||||
function onTouchEnd(event: TouchEvent): void {
|
||||
hideTargetLine(targetLineRef.current)
|
||||
|
||||
editor.update(() => {
|
||||
const { pageX, pageY } = event.changedTouches[0]
|
||||
|
||||
const dragData = dragDataRef.current || ''
|
||||
const draggedNode = $getNodeByKey(dragData)
|
||||
if (!draggedNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetBlockElem = getBlockElement(anchorElem, editor, new Point(pageX, pageY))
|
||||
if (!targetBlockElem) {
|
||||
return
|
||||
}
|
||||
const targetNode = $getNearestNodeFromDOMNode(targetBlockElem)
|
||||
|
||||
if (!targetNode) {
|
||||
return
|
||||
}
|
||||
if (targetNode === draggedNode) {
|
||||
return
|
||||
}
|
||||
|
||||
insertDraggedNode(draggedNode, targetNode, targetBlockElem, pageY)
|
||||
})
|
||||
|
||||
setDraggableBlockElem(null)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="icon draggable-block-menu"
|
||||
ref={menuRef}
|
||||
draggable={true}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div className={isEditable ? 'icon' : ''}>
|
||||
<BlockIcon className="pointer-events-none text-text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="draggable-block-target-line" ref={targetLineRef} />
|
||||
</>,
|
||||
anchorElem,
|
||||
)
|
||||
}
|
||||
|
||||
export default function DraggableBlockPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useDraggableBlockMenu(editor, anchorElem, editor._editable)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { INSERT_FILE_COMMAND } from '../Commands'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { FileNode } from './Nodes/FileNode'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$insertNodes,
|
||||
$isRootOrShadowRoot,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
PASTE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { $createFileNode } from './Nodes/FileUtils'
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
|
||||
import { useFilesController } from '@/Controllers/FilesControllerProvider'
|
||||
import { FilesControllerEvent } from '@/Controllers/FilesController'
|
||||
|
||||
export default function FilePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const filesController = useFilesController()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([FileNode])) {
|
||||
throw new Error('FilePlugin: FileNode not registered on editor')
|
||||
}
|
||||
|
||||
const uploadFilesList = (files: FileList) => {
|
||||
Array.from(files).forEach(async (file) => {
|
||||
try {
|
||||
const uploadedFile = await filesController.uploadNewFile(file)
|
||||
if (uploadedFile) {
|
||||
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand<string>(
|
||||
INSERT_FILE_COMMAND,
|
||||
(payload) => {
|
||||
const fileNode = $createFileNode(payload)
|
||||
$insertNodes([fileNode])
|
||||
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
|
||||
}
|
||||
const newLineNode = $createParagraphNode()
|
||||
$insertNodes([newLineNode])
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(payload) => {
|
||||
const files = payload instanceof ClipboardEvent ? payload.clipboardData?.files : null
|
||||
if (files?.length) {
|
||||
uploadFilesList(files)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
),
|
||||
)
|
||||
}, [editor, filesController])
|
||||
|
||||
useEffect(() => {
|
||||
const disposer = filesController.addEventObserver((event, data) => {
|
||||
if (event === FilesControllerEvent.FileUploadedToNote) {
|
||||
const fileUuid = data[FilesControllerEvent.FileUploadedToNote].uuid
|
||||
editor.dispatchCommand(INSERT_FILE_COMMAND, fileUuid)
|
||||
}
|
||||
})
|
||||
|
||||
return disposer
|
||||
}, [filesController, editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ElementFormatType, NodeKey } from 'lexical'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import FilePreview from '@/Components/FilePreview/FilePreview'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
|
||||
export type FileComponentProps = Readonly<{
|
||||
className: Readonly<{
|
||||
base: string
|
||||
focus: string
|
||||
}>
|
||||
format: ElementFormatType | null
|
||||
nodeKey: NodeKey
|
||||
fileUuid: string
|
||||
zoomLevel: number
|
||||
setZoomLevel: (zoomLevel: number) => void
|
||||
}>
|
||||
|
||||
export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
|
||||
|
||||
const [canLoad, setCanLoad] = useState(false)
|
||||
|
||||
const blockWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const blockObserver = useMemo(
|
||||
() =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setCanLoad(true)
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.25,
|
||||
},
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = blockWrapperRef.current
|
||||
|
||||
if (!wrapper) {
|
||||
return
|
||||
}
|
||||
|
||||
blockObserver.observe(wrapper)
|
||||
|
||||
return () => {
|
||||
blockObserver.unobserve(wrapper)
|
||||
}
|
||||
}, [blockObserver])
|
||||
|
||||
const setImageZoomLevel = useCallback(
|
||||
(zoomLevel: number) => {
|
||||
editor.update(() => {
|
||||
setZoomLevel(zoomLevel)
|
||||
})
|
||||
},
|
||||
[editor, setZoomLevel],
|
||||
)
|
||||
|
||||
if (!file) {
|
||||
return <div>Unable to find file {fileUuid}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
<div ref={blockWrapperRef}>
|
||||
{canLoad && (
|
||||
<FilePreview
|
||||
isEmbeddedInSuper={true}
|
||||
file={file}
|
||||
application={application}
|
||||
imageZoomLevel={zoomLevel}
|
||||
setImageZoomLevel={setImageZoomLevel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DOMConversionMap, DOMExportOutput, EditorConfig, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
|
||||
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
import { $createFileNode, convertToFileElement } from './FileUtils'
|
||||
import { FileComponent } from './FileComponent'
|
||||
import { SerializedFileNode } from './SerializedFileNode'
|
||||
import { ItemNodeInterface } from '../../ItemNodeInterface'
|
||||
|
||||
export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
__id: string
|
||||
__zoomLevel: number
|
||||
|
||||
static getType(): string {
|
||||
return 'snfile'
|
||||
}
|
||||
|
||||
static clone(node: FileNode): FileNode {
|
||||
return new FileNode(node.__id, node.__format, node.__key, node.__zoomLevel)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedFileNode): FileNode {
|
||||
const node = $createFileNode(serializedNode.fileUuid)
|
||||
node.setFormat(serializedNode.format)
|
||||
node.setZoomLevel(serializedNode.zoomLevel)
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedFileNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
fileUuid: this.getId(),
|
||||
version: 1,
|
||||
type: 'snfile',
|
||||
zoomLevel: this.__zoomLevel,
|
||||
}
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
|
||||
return {
|
||||
div: (domNode: HTMLDivElement) => {
|
||||
if (!domNode.hasAttribute('data-lexical-file-uuid')) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: convertToFileElement,
|
||||
priority: 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('span')
|
||||
element.setAttribute('data-lexical-file-uuid', this.__id)
|
||||
const text = document.createTextNode(this.getTextContent())
|
||||
element.append(text)
|
||||
return { element }
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey, zoomLevel?: number) {
|
||||
super(format, key)
|
||||
this.__id = id
|
||||
this.__zoomLevel = zoomLevel || 100
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.__id
|
||||
}
|
||||
|
||||
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
|
||||
return `[File: ${this.__id}]`
|
||||
}
|
||||
|
||||
setZoomLevel(zoomLevel: number): void {
|
||||
const writable = this.getWritable()
|
||||
writable.__zoomLevel = zoomLevel
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
}
|
||||
|
||||
return (
|
||||
<FileComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
nodeKey={this.getKey()}
|
||||
fileUuid={this.__id}
|
||||
zoomLevel={this.__zoomLevel}
|
||||
setZoomLevel={this.setZoomLevel.bind(this)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DOMConversionOutput, LexicalNode } from 'lexical'
|
||||
|
||||
import { FileNode } from './FileNode'
|
||||
|
||||
export function convertToFileElement(domNode: HTMLDivElement): DOMConversionOutput | null {
|
||||
const fileUuid = domNode.getAttribute('data-lexical-file-uuid')
|
||||
if (fileUuid) {
|
||||
const node = $createFileNode(fileUuid)
|
||||
return { node }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function $createFileNode(fileUuid: string): FileNode {
|
||||
return new FileNode(fileUuid)
|
||||
}
|
||||
|
||||
export function $isFileNode(node: FileNode | LexicalNode | null | undefined): node is FileNode {
|
||||
return node instanceof FileNode
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Spread } from 'lexical'
|
||||
import { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
|
||||
export type SerializedFileNode = Spread<
|
||||
{
|
||||
fileUuid: string
|
||||
version: 1
|
||||
type: 'snfile'
|
||||
zoomLevel: number
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { downloadBlobOnAndroid } from '@/NativeMobileWeb/DownloadBlobOnAndroid'
|
||||
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { Platform } from '@standardnotes/snjs'
|
||||
import {
|
||||
sanitizeFileName,
|
||||
SUPER_EXPORT_HTML,
|
||||
SUPER_EXPORT_JSON,
|
||||
SUPER_EXPORT_MARKDOWN,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { $convertToMarkdownString } from '@lexical/markdown'
|
||||
import { MarkdownTransformers } from '../../MarkdownTransformers'
|
||||
import { $generateHtmlFromNodes } from '@lexical/html'
|
||||
import { useCommandService } from '@/Components/CommandProvider'
|
||||
|
||||
export const ExportPlugin = () => {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const commandService = useCommandService()
|
||||
|
||||
const downloadData = useCallback(
|
||||
(data: Blob, fileName: string) => {
|
||||
if (!application.isNativeMobileWeb()) {
|
||||
application.getArchiveService().downloadData(data, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (application.platform === Platform.Android) {
|
||||
downloadBlobOnAndroid(application, data, fileName).catch(console.error)
|
||||
} else {
|
||||
shareBlobOnMobile(application, data, fileName).catch(console.error)
|
||||
}
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const exportJson = useCallback(
|
||||
(title: string) => {
|
||||
const content = JSON.stringify(editor.toJSON())
|
||||
const blob = new Blob([content], { type: 'application/json' })
|
||||
downloadData(blob, `${sanitizeFileName(title)}.json`)
|
||||
},
|
||||
[downloadData, editor],
|
||||
)
|
||||
|
||||
const exportMarkdown = useCallback(
|
||||
(title: string) => {
|
||||
editor.getEditorState().read(() => {
|
||||
const content = $convertToMarkdownString(MarkdownTransformers)
|
||||
const blob = new Blob([content], { type: 'text/markdown' })
|
||||
downloadData(blob, `${sanitizeFileName(title)}.md`)
|
||||
})
|
||||
},
|
||||
[downloadData, editor],
|
||||
)
|
||||
|
||||
const exportHtml = useCallback(
|
||||
(title: string) => {
|
||||
editor.getEditorState().read(() => {
|
||||
const content = $generateHtmlFromNodes(editor)
|
||||
const blob = new Blob([content], { type: 'text/html' })
|
||||
downloadData(blob, `${sanitizeFileName(title)}.html`)
|
||||
})
|
||||
},
|
||||
[downloadData, editor],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return commandService.addCommandHandler({
|
||||
command: SUPER_EXPORT_JSON,
|
||||
onKeyDown: (_, data) => {
|
||||
if (!data) {
|
||||
throw new Error('No data provided for export command')
|
||||
}
|
||||
|
||||
const title = data as string
|
||||
exportJson(title)
|
||||
},
|
||||
})
|
||||
}, [commandService, exportJson])
|
||||
|
||||
useEffect(() => {
|
||||
return commandService.addCommandHandler({
|
||||
command: SUPER_EXPORT_MARKDOWN,
|
||||
onKeyDown: (_, data) => {
|
||||
if (!data) {
|
||||
throw new Error('No data provided for export command')
|
||||
}
|
||||
|
||||
const title = data as string
|
||||
exportMarkdown(title)
|
||||
},
|
||||
})
|
||||
}, [commandService, exportMarkdown])
|
||||
|
||||
useEffect(() => {
|
||||
return commandService.addCommandHandler({
|
||||
command: SUPER_EXPORT_HTML,
|
||||
onKeyDown: (_, data) => {
|
||||
if (!data) {
|
||||
throw new Error('No data provided for export command')
|
||||
}
|
||||
|
||||
const title = data as string
|
||||
exportHtml(title)
|
||||
},
|
||||
})
|
||||
}, [commandService, exportHtml])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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 { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
GridSelection,
|
||||
LexicalEditor,
|
||||
NodeSelection,
|
||||
RangeSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import LinkPreview from '../../Lexical/UI/LinkPreview'
|
||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
|
||||
import { setFloatingElemPosition } from '../../Lexical/Utils/setFloatingElemPosition'
|
||||
import { LexicalPencilFill } from '@standardnotes/icons'
|
||||
import { IconComponent } from '../../Lexical/../Lexical/Theme/IconComponent'
|
||||
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
|
||||
|
||||
function FloatingLinkEditor({ editor, anchorElem }: { editor: LexicalEditor; anchorElem: HTMLElement }): JSX.Element {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isEditMode, setEditMode] = useState(false)
|
||||
const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null)
|
||||
|
||||
const updateLinkEditor = 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('')
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (editorElem === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const rect = getDOMRangeRect(nativeSelection, rootElement)
|
||||
|
||||
setFloatingElemPosition(rect, editorElem, anchorElem)
|
||||
setLastSelection(selection)
|
||||
} else if (!activeElement || activeElement.className !== 'link-input') {
|
||||
if (rootElement !== null) {
|
||||
setFloatingElemPosition(null, editorElem, anchorElem)
|
||||
}
|
||||
setLastSelection(null)
|
||||
setEditMode(false)
|
||||
setLinkUrl('')
|
||||
}
|
||||
|
||||
return true
|
||||
}, [anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update)
|
||||
}
|
||||
}
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
)
|
||||
}, [editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}, [editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditMode])
|
||||
|
||||
return (
|
||||
<div ref={editorRef} className="link-editor">
|
||||
{isEditMode ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="link-input"
|
||||
value={linkUrl}
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (lastSelection !== null) {
|
||||
if (linkUrl !== '') {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(linkUrl))
|
||||
}
|
||||
setEditMode(false)
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setEditMode(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="link-input">
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{linkUrl}
|
||||
</a>
|
||||
<div
|
||||
className="link-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setEditMode(true)
|
||||
}}
|
||||
>
|
||||
<IconComponent size={15}>
|
||||
<LexicalPencilFill />
|
||||
</IconComponent>
|
||||
</div>
|
||||
</div>
|
||||
<LinkPreview url={linkUrl} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useFloatingLinkEditorToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor)
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode)
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
|
||||
|
||||
if (linkParent != null || autoLinkParent != null) {
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar()
|
||||
setActiveEditor(newEditor)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
return isLink ? createPortal(<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />, anchorElem) : null
|
||||
}
|
||||
|
||||
export default function FloatingLinkEditorPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useFloatingLinkEditorToolbar(editor, anchorElem)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
.floating-text-format-popup {
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
box-shadow: 0px 5px 10px var(--sn-stylekit-shadow-color);
|
||||
border-radius: 8px;
|
||||
transition: opacity 0.5s;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item {
|
||||
border: 0;
|
||||
display: flex;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item.spaced {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item i.format {
|
||||
background-size: contain;
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
margin-top: 2px;
|
||||
vertical-align: -0.25em;
|
||||
display: flex;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item:disabled i.format {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item.active {
|
||||
background-color: rgba(223, 232, 250, 0.3);
|
||||
}
|
||||
|
||||
.floating-text-format-popup button.popup-item.active i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .popup-item:hover:not([disabled]) {
|
||||
background-color: var(--sn-stylekit-info-color);
|
||||
color: var(--sn-stylekit-info-contrast-color);
|
||||
}
|
||||
|
||||
.floating-text-format-popup select.popup-item {
|
||||
border: 0;
|
||||
display: flex;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 70px;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floating-text-format-popup select.code-language {
|
||||
text-transform: capitalize;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .popup-item .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
width: 70px;
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .popup-item .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.floating-text-format-popup i.chevron-down {
|
||||
margin-top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.floating-text-format-popup i.chevron-down.inside {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
margin-left: -25px;
|
||||
margin-top: 11px;
|
||||
margin-right: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-text-format-popup .divider {
|
||||
width: 1px;
|
||||
background-color: #eee;
|
||||
margin: 0 4px;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* 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 './index.css'
|
||||
|
||||
import { $isCodeHighlightNode } from '@lexical/code'
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister, $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
LexicalEditor,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
$isRootOrShadowRoot,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
} from 'lexical'
|
||||
import { $isHeadingNode } from '@lexical/rich-text'
|
||||
import {
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
$isListNode,
|
||||
ListNode,
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
} from '@lexical/list'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
|
||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||
import { setFloatingElemPosition } from '../../Lexical/Utils/setFloatingElemPosition'
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
UnderlineIcon,
|
||||
StrikethroughIcon,
|
||||
CodeIcon,
|
||||
LinkIcon,
|
||||
SuperscriptIcon,
|
||||
SubscriptIcon,
|
||||
ListBulleted,
|
||||
ListNumbered,
|
||||
} from '@standardnotes/icons'
|
||||
import { IconComponent } from '../../Lexical/Theme/IconComponent'
|
||||
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
const IconSize = 15
|
||||
|
||||
function TextFormatFloatingToolbar({
|
||||
editor,
|
||||
anchorElem,
|
||||
isLink,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
isCode,
|
||||
isStrikethrough,
|
||||
isSubscript,
|
||||
isSuperscript,
|
||||
isBulletedList,
|
||||
isNumberedList,
|
||||
}: {
|
||||
editor: LexicalEditor
|
||||
anchorElem: HTMLElement
|
||||
isBold: boolean
|
||||
isCode: boolean
|
||||
isItalic: boolean
|
||||
isLink: boolean
|
||||
isStrikethrough: boolean
|
||||
isSubscript: boolean
|
||||
isSuperscript: boolean
|
||||
isUnderline: boolean
|
||||
isBulletedList: boolean
|
||||
isNumberedList: boolean
|
||||
}): JSX.Element {
|
||||
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
if (!isLink) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
const textContent = selection?.getTextContent()
|
||||
if (!textContent) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
|
||||
return
|
||||
}
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
|
||||
})
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_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 updateTextFormatFloatingToolbar = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
const popupCharStylesEditorElem = popupCharStylesEditorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
|
||||
if (popupCharStylesEditorElem === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement()
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
!nativeSelection.isCollapsed &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode)
|
||||
) {
|
||||
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
|
||||
|
||||
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem)
|
||||
}
|
||||
}, [editor, anchorElem])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update)
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update)
|
||||
}
|
||||
}
|
||||
}, [editor, updateTextFormatFloatingToolbar, anchorElem])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateTextFormatFloatingToolbar()
|
||||
})
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateTextFormatFloatingToolbar()
|
||||
})
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateTextFormatFloatingToolbar()
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
)
|
||||
}, [editor, updateTextFormatFloatingToolbar])
|
||||
|
||||
return (
|
||||
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
|
||||
{editor.isEditable() && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isBold ? 'active' : '')}
|
||||
aria-label="Format text as bold"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<BoldIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
|
||||
aria-label="Format text as italics"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<ItalicIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
|
||||
aria-label="Format text to underlined"
|
||||
>
|
||||
<IconComponent size={IconSize + 1}>
|
||||
<UnderlineIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
|
||||
aria-label="Format text with a strikethrough"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<StrikethroughIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
|
||||
title="Subscript"
|
||||
aria-label="Format Subscript"
|
||||
>
|
||||
<IconComponent paddingTop={4} size={IconSize - 2}>
|
||||
<SubscriptIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
|
||||
title="Superscript"
|
||||
aria-label="Format Superscript"
|
||||
>
|
||||
<IconComponent paddingTop={1} size={IconSize - 2}>
|
||||
<SuperscriptIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
|
||||
}}
|
||||
className={'popup-item spaced ' + (isCode ? 'active' : '')}
|
||||
aria-label="Insert code block"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<CodeIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className={'popup-item spaced ' + (isLink ? 'active' : '')}
|
||||
aria-label="Insert link"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<LinkIcon />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={formatBulletList}
|
||||
className={'popup-item spaced ' + (isBulletedList ? 'active' : '')}
|
||||
aria-label="Insert bulleted list"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<ListBulleted />
|
||||
</IconComponent>
|
||||
</button>
|
||||
<button
|
||||
onClick={formatNumberedList}
|
||||
className={'popup-item spaced ' + (isNumberedList ? 'active' : '')}
|
||||
aria-label="Insert numbered list"
|
||||
>
|
||||
<IconComponent size={IconSize}>
|
||||
<ListNumbered />
|
||||
</IconComponent>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useFloatingTextFormatToolbar(editor: LexicalEditor, anchorElem: HTMLElement): JSX.Element | null {
|
||||
const [activeEditor, setActiveEditor] = useState(editor)
|
||||
const [isText, setIsText] = useState(false)
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
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 [blockType, setBlockType] = useState<keyof typeof blockTypeToBlockName>('paragraph')
|
||||
|
||||
const updatePopup = 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()
|
||||
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches
|
||||
|
||||
if (isMobile) {
|
||||
return
|
||||
}
|
||||
|
||||
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'))
|
||||
|
||||
// Update links
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(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)
|
||||
updatePopup()
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
)
|
||||
}, [editor, updatePopup])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(() => {
|
||||
updatePopup()
|
||||
}),
|
||||
editor.registerRootListener(() => {
|
||||
if (editor.getRootElement() === null) {
|
||||
setIsText(false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}, [editor, updatePopup])
|
||||
|
||||
if (!isText || isLink) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<TextFormatFloatingToolbar
|
||||
editor={editor}
|
||||
anchorElem={anchorElem}
|
||||
isLink={isLink}
|
||||
isBold={isBold}
|
||||
isItalic={isItalic}
|
||||
isStrikethrough={isStrikethrough}
|
||||
isSubscript={isSubscript}
|
||||
isSuperscript={isSuperscript}
|
||||
isUnderline={isUnderline}
|
||||
isCode={isCode}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle } from 'react'
|
||||
import { $convertToMarkdownString } from '@lexical/markdown'
|
||||
import { MarkdownTransformers } from '../../MarkdownTransformers'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
|
||||
export type GetMarkdownPluginInterface = {
|
||||
getMarkdown: () => string
|
||||
}
|
||||
|
||||
const GetMarkdownPlugin = forwardRef<GetMarkdownPluginInterface>((_, ref) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown() {
|
||||
return getMarkdown()
|
||||
},
|
||||
}))
|
||||
|
||||
const getMarkdown = useCallback(() => {
|
||||
return editor.getEditorState().read(() => {
|
||||
return $convertToMarkdownString(MarkdownTransformers)
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
export default GetMarkdownPlugin
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $createHorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function HorizontalRulePlugin(): null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INSERT_HORIZONTAL_RULE_COMMAND,
|
||||
(_type) => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const focusNode = selection.focus.getNode()
|
||||
|
||||
if (focusNode !== null) {
|
||||
const horizontalRuleNode = $createHorizontalRuleNode()
|
||||
selection.focus.getNode().getTopLevelElementOrThrow().insertBefore(horizontalRuleNode)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
import { $convertFromMarkdownString, TRANSFORMERS } from '@lexical/markdown'
|
||||
import { $generateNodesFromDOM } from '@lexical/html'
|
||||
import { $createParagraphNode, $createRangeSelection } from 'lexical'
|
||||
import { handleEditorChange } from '../../Utils'
|
||||
import { SuperNotePreviewCharLimit } from '../../SuperEditor'
|
||||
|
||||
/** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */
|
||||
export default function ImportPlugin({
|
||||
text,
|
||||
format,
|
||||
onChange,
|
||||
}: {
|
||||
text: string
|
||||
format: 'md' | 'html'
|
||||
onChange: (value: string, preview: string) => void
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const dontAllowConversionOfEmptyStringWhichWouldResultInError = text.length === 0
|
||||
if (dontAllowConversionOfEmptyStringWhichWouldResultInError) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
if (format === 'md') {
|
||||
$convertFromMarkdownString(text, [...TRANSFORMERS])
|
||||
} else {
|
||||
const parser = new DOMParser()
|
||||
const dom = parser.parseFromString(text, 'text/html')
|
||||
const nodes = $generateNodesFromDOM(editor, dom)
|
||||
const selection = $createRangeSelection()
|
||||
const newLineNode = $createParagraphNode()
|
||||
selection.insertNodes([newLineNode, ...nodes])
|
||||
}
|
||||
})
|
||||
}, [editor, text, format])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
handleEditorChange(editorState, SuperNotePreviewCharLimit, onChange)
|
||||
})
|
||||
})
|
||||
}, [editor, onChange])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $wrapNodeInElement } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, $createParagraphNode, $insertNodes, $isRootOrShadowRoot } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { INSERT_BUBBLE_COMMAND } from '../Commands'
|
||||
import { BubbleNode } from './Nodes/BubbleNode'
|
||||
import { $createBubbleNode } from './Nodes/BubbleUtils'
|
||||
|
||||
export default function ItemBubblePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([BubbleNode])) {
|
||||
throw new Error('ItemBubblePlugin: BubbleNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_BUBBLE_COMMAND,
|
||||
(payload) => {
|
||||
const bubbleNode = $createBubbleNode(payload)
|
||||
$insertNodes([bubbleNode])
|
||||
if ($isRootOrShadowRoot(bubbleNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(bubbleNode, $createParagraphNode).selectEnd()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import LinkedItemBubble from '@/Components/LinkedItems/LinkedItemBubble'
|
||||
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
|
||||
import { useLinkingController } from '@/Controllers/LinkingControllerProvider'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { useResponsiveAppPane } from '@/Components/Panes/ResponsivePaneProvider'
|
||||
import { LexicalNode } from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
|
||||
export type BubbleComponentProps = Readonly<{
|
||||
itemUuid: string
|
||||
node: LexicalNode
|
||||
}>
|
||||
|
||||
export function BubbleComponent({ itemUuid, node }: BubbleComponentProps) {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const linkingController = useLinkingController()
|
||||
const item = useMemo(() => application.items.findItem(itemUuid), [application, itemUuid])
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
|
||||
const activateItemAndTogglePane = useCallback(
|
||||
async (item: LinkableItem) => {
|
||||
const paneId = await linkingController.activateItem(item)
|
||||
if (paneId) {
|
||||
toggleAppPane(paneId)
|
||||
}
|
||||
},
|
||||
[toggleAppPane, linkingController],
|
||||
)
|
||||
|
||||
const unlinkPressed = useCallback(
|
||||
async (itemToUnlink: LinkableItem) => {
|
||||
linkingController.unlinkItemFromSelectedItem(itemToUnlink).catch(console.error)
|
||||
editor.update(() => {
|
||||
node.remove()
|
||||
})
|
||||
},
|
||||
[linkingController, node, editor],
|
||||
)
|
||||
|
||||
if (!item) {
|
||||
return <div>Unable to find item {itemUuid}</div>
|
||||
}
|
||||
|
||||
const link = createLinkFromItem(item, 'linked')
|
||||
|
||||
return (
|
||||
<LinkedItemBubble
|
||||
className="m-1"
|
||||
link={link}
|
||||
key={link.id}
|
||||
activateItem={activateItemAndTogglePane}
|
||||
unlinkItem={unlinkPressed}
|
||||
isBidirectional={false}
|
||||
inlineFlex={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { DOMConversionMap, DOMExportOutput, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
|
||||
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
import { $createBubbleNode, convertToBubbleElement } from './BubbleUtils'
|
||||
import { BubbleComponent } from './BubbleComponent'
|
||||
import { SerializedBubbleNode } from './SerializedBubbleNode'
|
||||
import { ItemNodeInterface } from '../../ItemNodeInterface'
|
||||
|
||||
export class BubbleNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
__id: string
|
||||
|
||||
static getType(): string {
|
||||
return 'snbubble'
|
||||
}
|
||||
|
||||
static clone(node: BubbleNode): BubbleNode {
|
||||
return new BubbleNode(node.__id, node.__format, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedBubbleNode): BubbleNode {
|
||||
const node = $createBubbleNode(serializedNode.itemUuid)
|
||||
node.setFormat(serializedNode.format)
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedBubbleNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
itemUuid: this.getId(),
|
||||
version: 1,
|
||||
type: 'snbubble',
|
||||
}
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
|
||||
return {
|
||||
div: (domNode: HTMLDivElement) => {
|
||||
if (!domNode.hasAttribute('data-lexical-item-uuid')) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: convertToBubbleElement,
|
||||
priority: 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
return document.createElement('span')
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('span')
|
||||
element.setAttribute('data-lexical-item-uuid', this.__id)
|
||||
const text = document.createTextNode(this.getTextContent())
|
||||
element.append(text)
|
||||
return { element }
|
||||
}
|
||||
|
||||
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
|
||||
super(format, key)
|
||||
this.__id = id
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.__id
|
||||
}
|
||||
|
||||
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
|
||||
return `[Item: ${this.__id}]`
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor): JSX.Element {
|
||||
return <BubbleComponent node={this} itemUuid={this.__id} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DOMConversionOutput, LexicalNode } from 'lexical'
|
||||
|
||||
import { BubbleNode } from './BubbleNode'
|
||||
|
||||
export function convertToBubbleElement(domNode: HTMLDivElement): DOMConversionOutput | null {
|
||||
const itemUuid = domNode.getAttribute('data-lexical-item-uuid')
|
||||
if (itemUuid) {
|
||||
const node = $createBubbleNode(itemUuid)
|
||||
return { node }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function $createBubbleNode(itemUuid: string): BubbleNode {
|
||||
return new BubbleNode(itemUuid)
|
||||
}
|
||||
|
||||
export function $isBubbleNode(node: BubbleNode | LexicalNode | null | undefined): node is BubbleNode {
|
||||
return node instanceof BubbleNode
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Spread } from 'lexical'
|
||||
import { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
|
||||
export type SerializedBubbleNode = Spread<
|
||||
{
|
||||
itemUuid: string
|
||||
version: 1
|
||||
type: 'snbubble'
|
||||
},
|
||||
SerializedDecoratorBlockNode
|
||||
>
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ItemNodeInterface {
|
||||
getId(): string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
|
||||
export class ItemOption extends TypeaheadOption {
|
||||
constructor(
|
||||
public item: LinkableItem | undefined,
|
||||
public label: string,
|
||||
public options: {
|
||||
keywords?: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
},
|
||||
) {
|
||||
super(label || '')
|
||||
this.key = item?.uuid || label
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta'
|
||||
import { LinkedItemSearchResultsAddTagOption } from '@/Components/LinkedItems/LinkedItemSearchResultsAddTagOption'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
|
||||
import { ItemOption } from './ItemOption'
|
||||
|
||||
type Props = {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: ItemOption
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export function ItemSelectionItemComponent({ index, isSelected, onClick, onMouseEnter, option, searchQuery }: Props) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={`gap-4 ${PopoverItemClassNames} ${isSelected ? PopoverItemSelectedClassNames : ''}`}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
{option.item && <LinkedItemMeta item={option.item} searchQuery={searchQuery} />}
|
||||
{!option.item && (
|
||||
<LinkedItemSearchResultsAddTagOption
|
||||
searchQuery={searchQuery}
|
||||
onClickCallback={onClick}
|
||||
isFocused={isSelected}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { TextNode } from 'lexical'
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
|
||||
import { ItemSelectionItemComponent } from './ItemSelectionItemComponent'
|
||||
import { ItemOption } from './ItemOption'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { ContentType, SNNote } from '@standardnotes/snjs'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
import Popover from '@/Components/Popover/Popover'
|
||||
import { INSERT_BUBBLE_COMMAND, INSERT_FILE_COMMAND } from '../Commands'
|
||||
import { useLinkingController } from '../../../../Controllers/LinkingControllerProvider'
|
||||
import { PopoverClassNames } from '../ClassNames'
|
||||
import { isMobileScreen } from '@/Utils'
|
||||
import { useTypeaheadAllowingSpacesAndPunctuation } from './useTypeaheadAllowingSpacesAndPunctuation'
|
||||
|
||||
type Props = {
|
||||
currentNote: SNNote
|
||||
}
|
||||
|
||||
export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const linkingController = useLinkingController()
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>('')
|
||||
|
||||
const checkForTriggerMatch = useTypeaheadAllowingSpacesAndPunctuation('@', {
|
||||
minLength: 0,
|
||||
})
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(selectedOption: ItemOption, nodeToRemove: TextNode | null, closeMenu: () => void, matchingString: string) => {
|
||||
editor.update(() => {
|
||||
if (nodeToRemove) {
|
||||
nodeToRemove.remove()
|
||||
}
|
||||
selectedOption.options.onSelect(matchingString)
|
||||
closeMenu()
|
||||
})
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const { linkedItems, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(
|
||||
queryString || '',
|
||||
application,
|
||||
currentNote,
|
||||
{
|
||||
returnEmptyIfQueryEmpty: false,
|
||||
},
|
||||
)
|
||||
|
||||
const items = [...linkedItems, ...unlinkedItems]
|
||||
|
||||
const options = items.map((item) => {
|
||||
return new ItemOption(item, item.title || '', {
|
||||
onSelect: (_queryString: string) => {
|
||||
void linkingController.linkItems(currentNote, item)
|
||||
if (item.content_type === ContentType.File) {
|
||||
editor.dispatchCommand(INSERT_FILE_COMMAND, item.uuid)
|
||||
} else {
|
||||
editor.dispatchCommand(INSERT_BUBBLE_COMMAND, item.uuid)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (shouldShowCreateTag) {
|
||||
options.push(
|
||||
new ItemOption(undefined, '', {
|
||||
onSelect: async (queryString: string) => {
|
||||
const newTag = await linkingController.createAndAddNewTag(queryString || '')
|
||||
editor.dispatchCommand(INSERT_BUBBLE_COMMAND, newTag.uuid)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}, [application, editor, currentNote, queryString, linkingController])
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<ItemOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={options}
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
if (!anchorElementRef.current || !options.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title="Select item"
|
||||
align="start"
|
||||
anchorElement={anchorElementRef.current}
|
||||
open={true}
|
||||
disableMobileFullscreenTakeover={true}
|
||||
side={isMobileScreen() ? 'top' : 'bottom'}
|
||||
maxHeight={(mh) => mh / 2}
|
||||
>
|
||||
<div className={PopoverClassNames}>
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<ItemSelectionItemComponent
|
||||
searchQuery={queryString || ''}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i)
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export type QueryMatch = {
|
||||
leadOffset: number
|
||||
matchingString: string
|
||||
replaceableString: string
|
||||
}
|
||||
type TriggerFn = (text: string, editor: LexicalEditor) => QueryMatch | null
|
||||
|
||||
/**
|
||||
* Derived from
|
||||
* https://github.com/facebook/lexical/blob/main/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx#L545
|
||||
*/
|
||||
export function useTypeaheadAllowingSpacesAndPunctuation(
|
||||
trigger: string,
|
||||
{ minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
|
||||
): TriggerFn {
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const validChars = '[^' + trigger + ']'
|
||||
const TypeaheadTriggerRegex = new RegExp(
|
||||
'(^|\\s|\\()(' + '[' + trigger + ']' + '((?:' + validChars + '){0,' + maxLength + '})' + ')$',
|
||||
)
|
||||
const match = TypeaheadTriggerRegex.exec(text)
|
||||
if (match !== null) {
|
||||
const maybeLeadingWhitespace = match[1]
|
||||
const matchingString = match[3]
|
||||
if (matchingString.length >= minLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: match[2],
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[maxLength, minLength, trigger],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
RangeSelection,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
ElementNode,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Props = Readonly<{
|
||||
maxDepth: number | null | undefined
|
||||
}>
|
||||
|
||||
function getElementNodesInSelection(selection: RangeSelection): Set<ElementNode> {
|
||||
const nodesInSelection = selection.getNodes()
|
||||
|
||||
if (nodesInSelection.length === 0) {
|
||||
return new Set([selection.anchor.getNode().getParentOrThrow(), selection.focus.getNode().getParentOrThrow()])
|
||||
}
|
||||
|
||||
return new Set(nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())))
|
||||
}
|
||||
|
||||
function isIndentPermitted(maxDepth: number): boolean {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const elementNodesInSelection: Set<ElementNode> = getElementNodesInSelection(selection)
|
||||
|
||||
let totalDepth = 0
|
||||
|
||||
for (const elementNode of elementNodesInSelection) {
|
||||
if ($isListNode(elementNode)) {
|
||||
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
|
||||
} else if ($isListItemNode(elementNode)) {
|
||||
const parent = elementNode.getParent()
|
||||
|
||||
if (!$isListNode(parent)) {
|
||||
throw new Error('ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.')
|
||||
}
|
||||
|
||||
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
|
||||
}
|
||||
}
|
||||
|
||||
return totalDepth <= maxDepth
|
||||
}
|
||||
|
||||
export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INDENT_CONTENT_COMMAND,
|
||||
() => !isIndentPermitted(maxDepth ?? 7),
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
)
|
||||
}, [editor, maxDepth])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
import { $createCodeNode } from '@lexical/code'
|
||||
import { $createTextNode, $getRoot } from 'lexical'
|
||||
import { $convertToMarkdownString } from '@lexical/markdown'
|
||||
import { MarkdownTransformers } from '../../MarkdownTransformers'
|
||||
|
||||
type Props = {
|
||||
onMarkdown: (markdown: string) => void
|
||||
}
|
||||
|
||||
export default function MarkdownPreviewPlugin({ onMarkdown }: Props): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
const markdown = $convertToMarkdownString(MarkdownTransformers)
|
||||
root.clear().append($createCodeNode('markdown').append($createTextNode(markdown)))
|
||||
root.selectEnd()
|
||||
onMarkdown(markdown)
|
||||
})
|
||||
}, [editor, onMarkdown])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import useModal from '../../Lexical/Hooks/useModal'
|
||||
import { InsertTableDialog } from '../../Plugins/TablePlugin'
|
||||
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
|
||||
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
|
||||
import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { 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 { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||
import { classNames } from '@standardnotes/snjs'
|
||||
import { SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
|
||||
const MobileToolbarPlugin = () => {
|
||||
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 toolbarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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(() => {
|
||||
const selection = $getSelection()
|
||||
const textContent = selection?.getTextContent()
|
||||
if (!textContent) {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
|
||||
return
|
||||
}
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
|
||||
})
|
||||
} else {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'Bold',
|
||||
iconName: 'bold',
|
||||
onSelect: () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Italic',
|
||||
iconName: 'italic',
|
||||
onSelect: () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Underline',
|
||||
iconName: 'underline',
|
||||
onSelect: () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Strikethrough',
|
||||
iconName: 'strikethrough',
|
||||
onSelect: () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Subscript',
|
||||
iconName: 'subscript',
|
||||
onSelect: () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Superscript',
|
||||
iconName: 'superscript',
|
||||
onSelect: () => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Link',
|
||||
iconName: 'link',
|
||||
onSelect: () => {
|
||||
editor.update(() => {
|
||||
insertLink()
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
iconName: 'search',
|
||||
onSelect: () => {
|
||||
application.keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)
|
||||
},
|
||||
},
|
||||
GetParagraphBlock(editor),
|
||||
...GetHeadingsBlocks(editor),
|
||||
...GetIndentOutdentBlocks(editor),
|
||||
GetTableBlock(() =>
|
||||
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
|
||||
),
|
||||
GetNumberedListBlock(editor),
|
||||
GetBulletedListBlock(editor),
|
||||
GetChecklistBlock(editor),
|
||||
GetQuoteBlock(editor),
|
||||
GetCodeBlock(editor),
|
||||
GetDividerBlock(editor),
|
||||
...GetDatetimeBlocks(editor),
|
||||
...GetAlignmentBlocks(editor),
|
||||
...[GetPasswordBlock(editor)],
|
||||
GetCollapsibleBlock(editor),
|
||||
...GetEmbedsBlocks(editor),
|
||||
],
|
||||
[application.keyboardService, editor, insertLink, showModal],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (!rootElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleFocus = () => setIsInEditor(true)
|
||||
const handleBlur = (event: FocusEvent) => {
|
||||
if (toolbarRef.current?.contains(event.relatedTarget as Node)) {
|
||||
return
|
||||
}
|
||||
setIsInEditor(false)
|
||||
}
|
||||
|
||||
rootElement.addEventListener('focus', handleFocus)
|
||||
rootElement.addEventListener('blur', handleBlur)
|
||||
|
||||
return () => {
|
||||
rootElement.removeEventListener('focus', handleFocus)
|
||||
rootElement.removeEventListener('blur', handleBlur)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolbarRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const toolbar = toolbarRef.current
|
||||
|
||||
const handleFocus = () => setIsInToolbar(true)
|
||||
const handleBlur = () => setIsInToolbar(false)
|
||||
|
||||
toolbar.addEventListener('focus', handleFocus)
|
||||
toolbar.addEventListener('blur', handleBlur)
|
||||
|
||||
return () => {
|
||||
toolbar?.removeEventListener('focus', handleFocus)
|
||||
toolbar?.removeEventListener('blur', handleBlur)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isFocusInEditorOrToolbar = isInEditor || isInToolbar
|
||||
if (!isMobile || !isFocusInEditorOrToolbar) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<div tabIndex={-1} className="flex w-full flex-shrink-0 border-t border-border bg-contrast" ref={toolbarRef}>
|
||||
<div className={classNames('flex items-center gap-1 overflow-x-auto', '[&::-webkit-scrollbar]:h-0')}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center justify-center rounded py-3 px-3"
|
||||
aria-label={item.name}
|
||||
onClick={item.onSelect}
|
||||
key={item.name}
|
||||
>
|
||||
<Icon type={item.iconName} size="medium" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="flex flex-shrink-0 items-center justify-center rounded border-l border-border py-3 px-3"
|
||||
aria-label="Dismiss keyboard"
|
||||
>
|
||||
<Icon type="keyboard-close" size="medium" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileToolbarPlugin
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $getNodeByKey, Klass, LexicalNode } from 'lexical'
|
||||
import { ItemNodeInterface } from '../ItemNodeInterface'
|
||||
|
||||
type NodeKey = string
|
||||
type ItemUuid = string
|
||||
|
||||
type ObserverProps = {
|
||||
nodeType: Klass<LexicalNode>
|
||||
onRemove: (itemUuid: string) => void
|
||||
}
|
||||
|
||||
export function NodeObserverPlugin({ nodeType, onRemove }: ObserverProps) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const map = useRef<Map<NodeKey, ItemUuid>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
const removeMutationListener = editor.registerMutationListener(nodeType, (mutatedNodes) => {
|
||||
editor.getEditorState().read(() => {
|
||||
for (const [nodeKey, mutation] of mutatedNodes) {
|
||||
if (mutation === 'updated' || mutation === 'created') {
|
||||
const node = $getNodeByKey(nodeKey) as unknown as ItemNodeInterface
|
||||
|
||||
if (node) {
|
||||
const uuid = node.getId()
|
||||
map.current.set(nodeKey, uuid)
|
||||
}
|
||||
} else if (mutation === 'destroyed') {
|
||||
const uuid = map.current.get(nodeKey)
|
||||
if (uuid) {
|
||||
onRemove(uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeMutationListener()
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const LOWER_CASE_LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('')
|
||||
const UPPER_CASE_LETTERS = LOWER_CASE_LETTERS.map((l) => l.toUpperCase())
|
||||
const SPECIAL_SYMBOLS = '!£$%^&*()@~:;,./?{}=-_'.split('')
|
||||
const CHARACTER_SET = [...LOWER_CASE_LETTERS, ...UPPER_CASE_LETTERS, ...SPECIAL_SYMBOLS]
|
||||
const CHARACTER_SET_LENGTH = CHARACTER_SET.length
|
||||
|
||||
function isValidPassword(password: string) {
|
||||
const containsSymbols = SPECIAL_SYMBOLS.some((symbol) => password.includes(symbol))
|
||||
const containsUpperCase = UPPER_CASE_LETTERS.some((upperLetter) => password.includes(upperLetter))
|
||||
const containsLowerCase = LOWER_CASE_LETTERS.some((lowerLetter) => password.includes(lowerLetter))
|
||||
|
||||
return containsLowerCase && containsUpperCase && containsSymbols
|
||||
}
|
||||
|
||||
export function generatePassword(length: number): string {
|
||||
const buffer = new Uint8Array(length)
|
||||
|
||||
let generatedPassword = ''
|
||||
|
||||
do {
|
||||
window.crypto.getRandomValues(buffer)
|
||||
generatedPassword = [...buffer].map((x) => CHARACTER_SET[x % CHARACTER_SET_LENGTH]).join('')
|
||||
} while (!isValidPassword(generatedPassword))
|
||||
|
||||
return generatedPassword
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$createParagraphNode,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { INSERT_PASSWORD_COMMAND } from '../Commands'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { generatePassword } from './Generator'
|
||||
|
||||
export default function PasswordPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand<string>(
|
||||
INSERT_PASSWORD_COMMAND,
|
||||
(lengthString) => {
|
||||
const length = Number(lengthString)
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const paragraph = $createParagraphNode()
|
||||
const password = generatePassword(length)
|
||||
paragraph.append($createTextNode(password))
|
||||
selection.insertNodes([paragraph])
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { SNNote, ContentType } from '@standardnotes/snjs'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const ReadonlyPlugin = ({ note }: { note: SNNote }) => {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [readOnly, setReadOnly] = useState(note.locked)
|
||||
|
||||
useEffect(() => {
|
||||
return application.streamItems<SNNote>(ContentType.Note, ({ changed }) => {
|
||||
const changedNoteItem = changed.find((changedItem) => changedItem.uuid === note.uuid)
|
||||
|
||||
if (changedNoteItem) {
|
||||
setReadOnly(changedNoteItem.locked)
|
||||
}
|
||||
})
|
||||
}, [application, note.uuid])
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
editor.setEditable(!readOnly)
|
||||
})
|
||||
}, [editor, readOnly])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default ReadonlyPlugin
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createContext, ReactNode, useCallback, useContext, useMemo, useReducer, useRef } from 'react'
|
||||
import { SuperSearchContextAction, SuperSearchContextState, SuperSearchReplaceEvent } from './Types'
|
||||
|
||||
type SuperSearchContextData = SuperSearchContextState & {
|
||||
dispatch: React.Dispatch<SuperSearchContextAction>
|
||||
addReplaceEventListener: (listener: (type: SuperSearchReplaceEvent) => void) => () => void
|
||||
dispatchReplaceEvent: (type: SuperSearchReplaceEvent) => void
|
||||
}
|
||||
|
||||
const SuperSearchContext = createContext<SuperSearchContextData | undefined>(undefined)
|
||||
|
||||
export const useSuperSearchContext = () => {
|
||||
const context = useContext(SuperSearchContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSuperSearchContext must be used within a SuperSearchContextProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const initialState: SuperSearchContextState = {
|
||||
query: '',
|
||||
results: [],
|
||||
currentResultIndex: -1,
|
||||
isCaseSensitive: false,
|
||||
isSearchActive: false,
|
||||
isReplaceMode: false,
|
||||
}
|
||||
|
||||
const searchContextReducer = (
|
||||
state: SuperSearchContextState,
|
||||
action: SuperSearchContextAction,
|
||||
): SuperSearchContextState => {
|
||||
switch (action.type) {
|
||||
case 'set-query':
|
||||
return {
|
||||
...state,
|
||||
query: action.query,
|
||||
}
|
||||
case 'set-results':
|
||||
return {
|
||||
...state,
|
||||
results: action.results,
|
||||
currentResultIndex: action.results.length > 0 ? 0 : -1,
|
||||
}
|
||||
case 'clear-results':
|
||||
return {
|
||||
...state,
|
||||
results: [],
|
||||
currentResultIndex: -1,
|
||||
}
|
||||
case 'set-current-result-index':
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex: action.index,
|
||||
}
|
||||
case 'toggle-search':
|
||||
return {
|
||||
...initialState,
|
||||
isSearchActive: !state.isSearchActive,
|
||||
}
|
||||
case 'toggle-case-sensitive':
|
||||
return {
|
||||
...state,
|
||||
isCaseSensitive: !state.isCaseSensitive,
|
||||
}
|
||||
case 'toggle-replace-mode': {
|
||||
const toggledValue = !state.isReplaceMode
|
||||
|
||||
return {
|
||||
...state,
|
||||
isSearchActive: toggledValue && !state.isSearchActive ? true : state.isSearchActive,
|
||||
isReplaceMode: toggledValue,
|
||||
}
|
||||
}
|
||||
case 'go-to-next-result':
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex:
|
||||
state.results.length < 1
|
||||
? -1
|
||||
: state.currentResultIndex + 1 < state.results.length
|
||||
? state.currentResultIndex + 1
|
||||
: 0,
|
||||
}
|
||||
case 'go-to-previous-result':
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex:
|
||||
state.results.length < 1
|
||||
? -1
|
||||
: state.currentResultIndex - 1 >= 0
|
||||
? state.currentResultIndex - 1
|
||||
: state.results.length - 1,
|
||||
}
|
||||
case 'reset-search':
|
||||
return { ...initialState }
|
||||
}
|
||||
}
|
||||
|
||||
export const SuperSearchContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(searchContextReducer, initialState)
|
||||
|
||||
const replaceEventListeners = useRef(new Set<(type: SuperSearchReplaceEvent) => void>())
|
||||
|
||||
const addReplaceEventListener = useCallback((listener: (type: SuperSearchReplaceEvent) => void) => {
|
||||
replaceEventListeners.current.add(listener)
|
||||
|
||||
return () => {
|
||||
replaceEventListeners.current.delete(listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dispatchReplaceEvent = useCallback((type: SuperSearchReplaceEvent) => {
|
||||
replaceEventListeners.current.forEach((listener) => listener(type))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
dispatch,
|
||||
addReplaceEventListener,
|
||||
dispatchReplaceEvent,
|
||||
}),
|
||||
[addReplaceEventListener, dispatchReplaceEvent, state],
|
||||
)
|
||||
|
||||
return <SuperSearchContext.Provider value={value}>{children}</SuperSearchContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import Button from '@/Components/Button/Button'
|
||||
import { useCommandService } from '@/Components/CommandProvider'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '@/Constants/AnimationConfigs'
|
||||
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
|
||||
import { ArrowDownIcon, ArrowUpIcon, CloseIcon, ArrowRightIcon } from '@standardnotes/icons'
|
||||
import {
|
||||
keyboardStringForShortcut,
|
||||
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
SUPER_TOGGLE_SEARCH,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useSuperSearchContext } from './Context'
|
||||
|
||||
export const SearchDialog = ({ open, closeDialog }: { open: boolean; closeDialog: () => void }) => {
|
||||
const { query, results, currentResultIndex, isCaseSensitive, isReplaceMode, dispatch, dispatchReplaceEvent } =
|
||||
useSuperSearchContext()
|
||||
|
||||
const [replaceQuery, setReplaceQuery] = useState('')
|
||||
|
||||
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [isMounted, setElement] = useLifecycleAnimation({
|
||||
open,
|
||||
enter: TranslateFromTopAnimation,
|
||||
exit: TranslateToTopAnimation,
|
||||
})
|
||||
|
||||
const commandService = useCommandService()
|
||||
const searchToggleShortcut = useMemo(
|
||||
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
|
||||
[commandService],
|
||||
)
|
||||
const toggleReplaceShortcut = useMemo(
|
||||
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
|
||||
[commandService],
|
||||
)
|
||||
const caseSensitivityShortcut = useMemo(
|
||||
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
|
||||
[commandService],
|
||||
)
|
||||
|
||||
if (!isMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-6 top-4 z-10 flex select-none rounded border border-border bg-default"
|
||||
ref={setElement}
|
||||
>
|
||||
<button
|
||||
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info"
|
||||
onClick={() => {
|
||||
dispatch({ type: 'toggle-replace-mode' })
|
||||
}}
|
||||
title={`Toggle Replace Mode (${toggleReplaceShortcut})`}
|
||||
>
|
||||
{isReplaceMode ? (
|
||||
<ArrowDownIcon className="h-4 w-4 fill-text" />
|
||||
) : (
|
||||
<ArrowRightIcon className="h-4 w-4 fill-text" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 px-2"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDialog()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DecoratedInput
|
||||
placeholder="Search"
|
||||
className={{
|
||||
container: classNames('flex-grow !text-[length:inherit]', !query.length && '!py-1'),
|
||||
right: '!py-1',
|
||||
}}
|
||||
value={query}
|
||||
onChange={(query) => {
|
||||
dispatch({
|
||||
type: 'set-query',
|
||||
query,
|
||||
})
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && results.length) {
|
||||
if (event.shiftKey) {
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
}
|
||||
}}
|
||||
ref={focusOnMount}
|
||||
right={[
|
||||
<div className="min-w-[7ch] max-w-[7ch] flex-shrink-0 whitespace-nowrap text-right">
|
||||
{query.length > 0 && (
|
||||
<>
|
||||
{currentResultIndex > -1 ? currentResultIndex + 1 + ' / ' : null}
|
||||
{results.length}
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
]}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
'relative flex items-center rounded border py-1 px-1.5 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
|
||||
isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
|
||||
)}
|
||||
title={`Case sensitive (${caseSensitivityShortcut})`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute top-0 left-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
|
||||
checked={isCaseSensitive}
|
||||
onChange={() => {
|
||||
dispatch({
|
||||
type: 'toggle-case-sensitive',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden>Aa</span>
|
||||
<span className="sr-only">Case sensitive</span>
|
||||
</label>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1}
|
||||
title="Previous result (Shift + Enter)"
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1}
|
||||
title="Next result (Enter)"
|
||||
>
|
||||
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
|
||||
onClick={() => {
|
||||
closeDialog()
|
||||
}}
|
||||
title={`Close (${searchToggleShortcut})`}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
</div>
|
||||
{isReplaceMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Replace"
|
||||
onChange={(e) => {
|
||||
setReplaceQuery(e.target.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && replaceQuery && results.length) {
|
||||
if (event.ctrlKey && event.altKey) {
|
||||
dispatchReplaceEvent({
|
||||
type: 'all',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
dispatchReplaceEvent({
|
||||
type: 'next',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
className="rounded border border-border bg-default p-1 px-2"
|
||||
ref={focusOnMount}
|
||||
/>
|
||||
<Button
|
||||
small
|
||||
onClick={() => {
|
||||
dispatchReplaceEvent({
|
||||
type: 'next',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1 || replaceQuery.length < 1}
|
||||
title="Replace (Ctrl + Enter)"
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
small
|
||||
onClick={() => {
|
||||
dispatchReplaceEvent({
|
||||
type: 'all',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1 || replaceQuery.length < 1}
|
||||
title="Replace all (Ctrl + Alt + Enter)"
|
||||
>
|
||||
Replace all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $getNearestNodeFromDOMNode, TextNode } from 'lexical'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||
import { createSearchHighlightElement } from './createSearchHighlightElement'
|
||||
import { useSuperSearchContext } from './Context'
|
||||
import { SearchDialog } from './SearchDialog'
|
||||
import { getAllTextNodesInElement } from './getAllTextNodesInElement'
|
||||
import { SuperSearchResult } from './Types'
|
||||
import { debounce } from '@standardnotes/utils'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import {
|
||||
SUPER_SEARCH_NEXT_RESULT,
|
||||
SUPER_SEARCH_PREVIOUS_RESULT,
|
||||
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
SUPER_TOGGLE_SEARCH,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { useStateRef } from '@/Hooks/useStateRef'
|
||||
|
||||
export const SearchPlugin = () => {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } =
|
||||
useSuperSearchContext()
|
||||
const queryRef = useStateRef(query)
|
||||
const currentResultIndexRef = useStateRef(currentResultIndex)
|
||||
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
|
||||
const resultsRef = useStateRef(results)
|
||||
|
||||
useEffect(() => {
|
||||
const isFocusInEditor = () => {
|
||||
if (!document.activeElement || !document.activeElement.closest('.blocks-editor')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return application.keyboardService.addCommandHandlers([
|
||||
{
|
||||
command: SUPER_TOGGLE_SEARCH,
|
||||
onKeyDown: (event) => {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle-search' })
|
||||
editor.focus()
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
onKeyDown: (event) => {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle-replace-mode' })
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
onKeyDown() {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'toggle-case-sensitive',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_NEXT_RESULT,
|
||||
onKeyDown(event) {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_PREVIOUS_RESULT,
|
||||
onKeyDown(event) {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}, [application.keyboardService, dispatch, editor])
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string, isCaseSensitive: boolean) => {
|
||||
document.querySelectorAll('.search-highlight').forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
|
||||
if (!query) {
|
||||
dispatch({ type: 'clear-results' })
|
||||
return
|
||||
}
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (!rootElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const textNodes = getAllTextNodesInElement(rootElement)
|
||||
|
||||
const results: SuperSearchResult[] = []
|
||||
|
||||
textNodes.forEach((node) => {
|
||||
const text = node.textContent || ''
|
||||
|
||||
const indices: number[] = []
|
||||
let index = -1
|
||||
|
||||
const textWithCase = isCaseSensitive ? text : text.toLowerCase()
|
||||
const queryWithCase = isCaseSensitive ? query : query.toLowerCase()
|
||||
|
||||
while ((index = textWithCase.indexOf(queryWithCase, index + 1)) !== -1) {
|
||||
indices.push(index)
|
||||
}
|
||||
|
||||
indices.forEach((index) => {
|
||||
const startIndex = index
|
||||
const endIndex = startIndex + query.length
|
||||
|
||||
results.push({
|
||||
node,
|
||||
startIndex,
|
||||
endIndex,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'set-results',
|
||||
results,
|
||||
})
|
||||
})
|
||||
},
|
||||
[dispatch, editor],
|
||||
)
|
||||
|
||||
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
|
||||
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
dispatch({ type: 'clear-results' })
|
||||
dispatch({ type: 'set-current-result-index', index: -1 })
|
||||
return
|
||||
}
|
||||
|
||||
void handleQueryChange(query, isCaseSensitiveRef.current)
|
||||
}, [dispatch, handleQueryChange, isCaseSensitiveRef, query])
|
||||
|
||||
useEffect(() => {
|
||||
const handleCaseSensitiveChange = () => {
|
||||
void handleSearch(queryRef.current, isCaseSensitive)
|
||||
}
|
||||
handleCaseSensitiveChange()
|
||||
}, [handleSearch, isCaseSensitive, queryRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
|
||||
if (
|
||||
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
|
||||
tags.has('history-merge') ||
|
||||
prevEditorState.isEmpty()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void handleEditorChange(queryRef.current, isCaseSensitiveRef.current)
|
||||
})
|
||||
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
|
||||
|
||||
useEffect(() => {
|
||||
return addReplaceEventListener((event) => {
|
||||
const { replace, type } = event
|
||||
|
||||
const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => {
|
||||
const { node, startIndex, endIndex } = result
|
||||
const lexicalNode = $getNearestNodeFromDOMNode(node)
|
||||
if (!lexicalNode) {
|
||||
return
|
||||
}
|
||||
if (lexicalNode instanceof TextNode) {
|
||||
lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true)
|
||||
}
|
||||
if (scrollIntoView && node.parentElement) {
|
||||
node.parentElement.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
if (type === 'next') {
|
||||
const result = resultsRef.current[currentResultIndexRef.current]
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
replaceResult(result, true)
|
||||
} else if (type === 'all') {
|
||||
resultsRef.current.forEach((result) => replaceResult(result))
|
||||
}
|
||||
|
||||
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
|
||||
})
|
||||
})
|
||||
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelectorAll('.search-highlight').forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
if (currentResultIndex === -1) {
|
||||
return
|
||||
}
|
||||
const result = results[currentResultIndex]
|
||||
editor.getEditorState().read(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
const containerElement = rootElement?.parentElement?.getElementsByClassName('search-highlight-container')[0]
|
||||
result.node.parentElement?.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
createSearchHighlightElement(result, rootElement, containerElement)
|
||||
})
|
||||
}, [currentResultIndex, editor, results])
|
||||
|
||||
useEffect(() => {
|
||||
let containerElement: HTMLElement | null | undefined
|
||||
let rootElement: HTMLElement | null | undefined
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
rootElement = editor.getRootElement()
|
||||
containerElement = rootElement?.parentElement?.querySelector('.search-highlight-container')
|
||||
})
|
||||
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
|
||||
containerElement.style.height = `${rootElement.scrollHeight}px`
|
||||
containerElement.style.overflow = 'visible'
|
||||
})
|
||||
resizeObserver.observe(rootElement)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
|
||||
containerElement.style.top = `-${rootElement.scrollTop}px`
|
||||
}
|
||||
|
||||
rootElement.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
rootElement?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchDialog
|
||||
open={isSearchActive}
|
||||
closeDialog={() => {
|
||||
dispatch({ type: 'toggle-search' })
|
||||
dispatch({ type: 'reset-search' })
|
||||
editor.focus()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type SuperSearchResult = {
|
||||
node: Text
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
export type SuperSearchContextState = {
|
||||
query: string
|
||||
results: SuperSearchResult[]
|
||||
currentResultIndex: number
|
||||
isCaseSensitive: boolean
|
||||
isSearchActive: boolean
|
||||
isReplaceMode: boolean
|
||||
}
|
||||
|
||||
export type SuperSearchContextAction =
|
||||
| { type: 'set-query'; query: string }
|
||||
| { type: 'set-results'; results: SuperSearchResult[] }
|
||||
| { type: 'clear-results' }
|
||||
| { type: 'set-current-result-index'; index: number }
|
||||
| { type: 'go-to-next-result' }
|
||||
| { type: 'go-to-previous-result' }
|
||||
| { type: 'toggle-case-sensitive' }
|
||||
| { type: 'toggle-replace-mode' }
|
||||
| { type: 'toggle-search' }
|
||||
| { type: 'reset-search' }
|
||||
|
||||
export type SuperSearchReplaceEvent = {
|
||||
type: 'next' | 'all'
|
||||
replace: string
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SuperSearchResult } from './Types'
|
||||
|
||||
export const createSearchHighlightElement = (
|
||||
result: SuperSearchResult,
|
||||
rootElement: Element,
|
||||
containerElement: Element,
|
||||
) => {
|
||||
const rootElementRect = rootElement.getBoundingClientRect()
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(result.node, result.startIndex)
|
||||
range.setEnd(result.node, result.endIndex)
|
||||
|
||||
const rects = range.getClientRects()
|
||||
|
||||
Array.from(rects).forEach((rect, index) => {
|
||||
const id = `search-${result.startIndex}-${result.endIndex}-${index}`
|
||||
|
||||
const existingHighlightElement = document.getElementById(id)
|
||||
|
||||
if (existingHighlightElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const highlightElement = document.createElement('div')
|
||||
highlightElement.style.position = 'absolute'
|
||||
highlightElement.style.zIndex = '1000'
|
||||
highlightElement.style.transform = `translate(${rect.left - rootElementRect.left}px, ${
|
||||
rect.top - rootElementRect.top + rootElement.scrollTop
|
||||
}px)`
|
||||
highlightElement.style.width = `${rect.width}px`
|
||||
highlightElement.style.height = `${rect.height}px`
|
||||
highlightElement.style.backgroundColor = 'var(--sn-stylekit-info-color)'
|
||||
highlightElement.style.opacity = '0.5'
|
||||
highlightElement.className = 'search-highlight'
|
||||
highlightElement.id = id
|
||||
|
||||
containerElement.appendChild(highlightElement)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const getAllTextNodesInElement = (element: HTMLElement) => {
|
||||
const textNodes: Text[] = []
|
||||
const walk = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
|
||||
let node = walk.nextNode()
|
||||
while (node) {
|
||||
textNodes.push(node as Text)
|
||||
node = walk.nextNode()
|
||||
}
|
||||
return textNodes
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
KEY_TAB_COMMAND,
|
||||
OUTDENT_CONTENT_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* This plugin adds the ability to indent content using the tab key. Generally, we don't
|
||||
* recommend using this plugin as it could negatively affect acessibility for keyboard
|
||||
* users, causing focus to become trapped within the editor.
|
||||
*/
|
||||
export function TabIndentationPlugin(): null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand<KeyboardEvent>(
|
||||
KEY_TAB_COMMAND,
|
||||
(event) => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
return editor.dispatchCommand(event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND, undefined)
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { INSERT_TABLE_COMMAND } from '@lexical/table'
|
||||
import {
|
||||
$createNodeSelection,
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
EditorThemeClasses,
|
||||
Klass,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import * as React from 'react'
|
||||
import invariant from '../Lexical/Shared/invariant'
|
||||
import { $createTableNodeWithDimensions, TableNode } from '../Lexical/Nodes/TableNode'
|
||||
import Button from '../Lexical/UI/Button'
|
||||
import { DialogActions } from '../Lexical/UI/Dialog'
|
||||
import TextInput from '../Lexical/UI/TextInput'
|
||||
|
||||
export type InsertTableCommandPayload = Readonly<{
|
||||
columns: string
|
||||
rows: string
|
||||
includeHeaders?: boolean
|
||||
}>
|
||||
|
||||
export type CellContextShape = {
|
||||
cellEditorConfig: null | CellEditorConfig
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
|
||||
set: (cellEditorConfig: null | CellEditorConfig, cellEditorPlugins: null | JSX.Element | Array<JSX.Element>) => void
|
||||
}
|
||||
|
||||
export type CellEditorConfig = Readonly<{
|
||||
namespace: string
|
||||
nodes?: ReadonlyArray<Klass<LexicalNode>>
|
||||
onError: (error: Error, editor: LexicalEditor) => void
|
||||
readOnly?: boolean
|
||||
theme?: EditorThemeClasses
|
||||
}>
|
||||
|
||||
export const INSERT_NEW_TABLE_COMMAND: LexicalCommand<InsertTableCommandPayload> =
|
||||
createCommand('INSERT_NEW_TABLE_COMMAND')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: not sure why TS doesn't like using null as the value?
|
||||
export const CellContext: React.Context<CellContextShape> = createContext({
|
||||
cellEditorConfig: null,
|
||||
cellEditorPlugins: null,
|
||||
set: () => {
|
||||
// Empty
|
||||
},
|
||||
})
|
||||
|
||||
export function TableContext({ children }: { children: JSX.Element }) {
|
||||
const [contextValue, setContextValue] = useState<{
|
||||
cellEditorConfig: null | CellEditorConfig
|
||||
cellEditorPlugins: null | JSX.Element | Array<JSX.Element>
|
||||
}>({
|
||||
cellEditorConfig: null,
|
||||
cellEditorPlugins: null,
|
||||
})
|
||||
return (
|
||||
<CellContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
cellEditorConfig: contextValue.cellEditorConfig,
|
||||
cellEditorPlugins: contextValue.cellEditorPlugins,
|
||||
set: (cellEditorConfig, cellEditorPlugins) => {
|
||||
setContextValue({ cellEditorConfig, cellEditorPlugins })
|
||||
},
|
||||
}),
|
||||
[contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</CellContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function InsertTableDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [rows, setRows] = useState('5')
|
||||
const [columns, setColumns] = useState('5')
|
||||
|
||||
const onClick = () => {
|
||||
activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, { columns, rows })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput label="Number of rows" onChange={setRows} value={rows} />
|
||||
<TextInput label="Number of columns" onChange={setColumns} value={columns} />
|
||||
<DialogActions data-test-id="table-model-confirm-insert">
|
||||
<Button onClick={onClick}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function InsertNewTableDialog({
|
||||
activeEditor,
|
||||
onClose,
|
||||
}: {
|
||||
activeEditor: LexicalEditor
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [rows, setRows] = useState('5')
|
||||
const [columns, setColumns] = useState('5')
|
||||
|
||||
const onClick = () => {
|
||||
activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, { columns, rows })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput label="No of rows" onChange={setRows} value={rows} />
|
||||
<TextInput label="No of columns" onChange={setColumns} value={columns} />
|
||||
<DialogActions data-test-id="table-model-confirm-insert">
|
||||
<Button onClick={onClick}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TablePlugin({
|
||||
cellEditorConfig,
|
||||
children,
|
||||
}: {
|
||||
cellEditorConfig: CellEditorConfig
|
||||
children: JSX.Element | Array<JSX.Element>
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const cellContext = useContext(CellContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TableNode])) {
|
||||
invariant(false, 'TablePlugin: TableNode is not registered on editor')
|
||||
}
|
||||
|
||||
cellContext.set(cellEditorConfig, children)
|
||||
|
||||
return editor.registerCommand<InsertTableCommandPayload>(
|
||||
INSERT_TABLE_COMMAND,
|
||||
({ columns, rows, includeHeaders }) => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const focus = selection.focus
|
||||
const focusNode = focus.getNode()
|
||||
|
||||
if (focusNode !== null) {
|
||||
const tableNode = $createTableNodeWithDimensions(Number(rows), Number(columns), includeHeaders)
|
||||
|
||||
if ($isRootOrShadowRoot(focusNode)) {
|
||||
const target = focusNode.getChildAtIndex(focus.offset)
|
||||
|
||||
if (target !== null) {
|
||||
target.insertBefore(tableNode)
|
||||
} else {
|
||||
focusNode.append(tableNode)
|
||||
}
|
||||
|
||||
tableNode.insertBefore($createParagraphNode())
|
||||
} else {
|
||||
const topLevelNode = focusNode.getTopLevelElementOrThrow()
|
||||
topLevelNode.insertAfter(tableNode)
|
||||
}
|
||||
|
||||
tableNode.insertAfter($createParagraphNode())
|
||||
const nodeSelection = $createNodeSelection()
|
||||
nodeSelection.add(tableNode.getKey())
|
||||
$setSelection(nodeSelection)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [cellContext, cellEditorConfig, children, editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { $createTweetNode, TweetNode } from '../../Lexical/Nodes/TweetNode'
|
||||
|
||||
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand('INSERT_TWEET_COMMAND')
|
||||
|
||||
export default function TwitterPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TweetNode])) {
|
||||
throw new Error('TwitterPlugin: TweetNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_TWEET_COMMAND,
|
||||
(payload) => {
|
||||
const tweetNode = $createTweetNode(payload)
|
||||
$insertNodeToNearestRoot(tweetNode)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { $createYouTubeNode, YouTubeNode } from '../../Lexical/Nodes/YouTubeNode'
|
||||
|
||||
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand('INSERT_YOUTUBE_COMMAND')
|
||||
|
||||
export default function YouTubePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([YouTubeNode])) {
|
||||
throw new Error('YouTubePlugin: YouTubeNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_YOUTUBE_COMMAND,
|
||||
(payload) => {
|
||||
const youTubeNode = $createYouTubeNode(payload)
|
||||
$insertNodeToNearestRoot(youTubeNode)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user