refactor: merge blocks-editor package with web (#2217)

This commit is contained in:
Aman Harwara
2023-02-17 18:35:17 +05:30
committed by GitHub
parent 135956ce73
commit 7bf76b51c5
158 changed files with 95 additions and 668 deletions

View File

@@ -0,0 +1,116 @@
import { FunctionComponent, useCallback, useState } from 'react'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin'
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
import { TablePlugin } from '@lexical/react/LexicalTablePlugin'
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
import { EditorState, LexicalEditor } from 'lexical'
import HorizontalRulePlugin from './Plugins/HorizontalRulePlugin'
import TwitterPlugin from './Plugins/TwitterPlugin'
import YouTubePlugin from './Plugins/YouTubePlugin'
import AutoEmbedPlugin from './Plugins/AutoEmbedPlugin'
import CollapsiblePlugin from './Plugins/CollapsiblePlugin'
import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin'
import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin'
import FloatingTextFormatToolbarPlugin from './Plugins/FloatingTextFormatToolbarPlugin'
import FloatingLinkEditorPlugin from './Plugins/FloatingLinkEditorPlugin'
import { TabIndentationPlugin } from './Plugins/TabIndentationPlugin'
import { handleEditorChange } from './Utils'
import { SuperEditorContentId } from './Constants'
import { classNames } from '@standardnotes/utils'
import { MarkdownTransformers } from './MarkdownTransformers'
type BlocksEditorProps = {
onChange?: (value: string, preview: string) => void
className?: string
children?: React.ReactNode
previewLength?: number
spellcheck?: boolean
ignoreFirstChange?: boolean
readonly?: boolean
}
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
onChange,
className,
children,
previewLength,
spellcheck,
ignoreFirstChange = false,
readonly,
}) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => {
if (ignoreFirstChange && !didIgnoreFirstChange) {
setDidIgnoreFirstChange(true)
return
}
editorState.read(() => {
handleEditorChange(editorState, previewLength, onChange)
})
},
[ignoreFirstChange, didIgnoreFirstChange, previewLength, onChange],
)
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem)
}
}
return (
<>
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller h-full min-h-0">
<div className="editor z-0 overflow-hidden" ref={onRef}>
<ContentEditable
id={SuperEditorContentId}
className={classNames('ContentEditable__root overflow-y-auto', className)}
spellCheck={spellcheck}
/>
<div className="search-highlight-container pointer-events-none absolute top-0 left-0 h-full w-full" />
</div>
</div>
}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<ListPlugin />
<MarkdownShortcutPlugin transformers={MarkdownTransformers} />
<TablePlugin />
<OnChangePlugin onChange={handleChange} ignoreSelectionChange={true} />
<HistoryPlugin />
<HorizontalRulePlugin />
<ClearEditorPlugin />
<CheckListPlugin />
<CodeHighlightPlugin />
<LinkPlugin />
<HashtagPlugin />
<AutoEmbedPlugin />
<TwitterPlugin />
<YouTubePlugin />
<CollapsiblePlugin />
<TabIndentationPlugin />
{!readonly && floatingAnchorElem && (
<>
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
</>
)}
{children}
</>
)
}

View File

@@ -0,0 +1,34 @@
import { FunctionComponent } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import BlocksEditorTheme from './Lexical/Theme/Theme'
import { BlockEditorNodes } from './Lexical/Nodes/AllNodes'
import { Klass, LexicalNode } from 'lexical'
type BlocksEditorComposerProps = {
initialValue: string | undefined
children: React.ReactNode
nodes?: Array<Klass<LexicalNode>>
readonly?: boolean
}
export const BlocksEditorComposer: FunctionComponent<BlocksEditorComposerProps> = ({
initialValue,
children,
readonly,
nodes = [],
}) => {
return (
<LexicalComposer
initialConfig={{
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: !readonly,
onError: (error: Error) => console.error(error),
editorState: initialValue && initialValue.length > 0 ? initialValue : undefined,
nodes: [...nodes, ...BlockEditorNodes],
}}
>
<>{children}</>
</LexicalComposer>
)
}

View File

@@ -0,0 +1 @@
export const SuperEditorContentId = 'super-editor-content'

View File

@@ -0,0 +1,56 @@
/**
* 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 { useCallback, useMemo, useState } from 'react'
import Modal from '../UI/Modal'
export default function useModal(): [
JSX.Element | null,
(title: string, showModal: (onClose: () => void) => JSX.Element) => void,
] {
const [modalContent, setModalContent] = useState<null | {
closeOnClickOutside: boolean
content: JSX.Element
title: string
}>(null)
const onClose = useCallback(() => {
setModalContent(null)
}, [])
const modal = useMemo(() => {
if (modalContent === null) {
return null
}
const { title, content, closeOnClickOutside } = modalContent
return (
<Modal onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
{content}
</Modal>
)
}, [modalContent, onClose])
const showModal = useCallback(
(
title: string,
// eslint-disable-next-line no-shadow
getContent: (onClose: () => void) => JSX.Element,
closeOnClickOutside = false,
) => {
setModalContent({
closeOnClickOutside,
content: getContent(onClose),
title,
})
},
[onClose],
)
return [modal, showModal]
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down"><path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 24" fill="currentColor"><path stroke="currentColor" d="M8.5 10a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0 7a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm7-10a2 2 0 1 0-2-2 2 2 0 0 0 2 2Zm-7-4a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm7 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2Zm0-7a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" class="bi bi-pencil-fill"><path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/></svg>

After

Width:  |  Height:  |  Size: 554 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1,41 @@
import { CodeHighlightNode, CodeNode } from '@lexical/code'
import { HashtagNode } from '@lexical/hashtag'
import { AutoLinkNode, LinkNode } from '@lexical/link'
import { ListItemNode, ListNode } from '@lexical/list'
import { MarkNode } from '@lexical/mark'
import { OverflowNode } from '@lexical/overflow'
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { TweetNode } from './TweetNode'
import { YouTubeNode } from './YouTubeNode'
import { CollapsibleContainerNode } from '../../Plugins/CollapsiblePlugin/CollapsibleContainerNode'
import { CollapsibleContentNode } from '../../Plugins/CollapsiblePlugin/CollapsibleContentNode'
import { CollapsibleTitleNode } from '../../Plugins/CollapsiblePlugin/CollapsibleTitleNode'
import { FileNode } from '../../Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { BubbleNode } from '../../Plugins/ItemBubblePlugin/Nodes/BubbleNode'
export const BlockEditorNodes = [
AutoLinkNode,
CodeHighlightNode,
CodeNode,
CollapsibleContainerNode,
CollapsibleContentNode,
CollapsibleTitleNode,
HashtagNode,
HeadingNode,
HorizontalRuleNode,
LinkNode,
ListItemNode,
ListNode,
MarkNode,
OverflowNode,
QuoteNode,
TableCellNode,
TableNode,
TableRowNode,
TweetNode,
YouTubeNode,
FileNode,
BubbleNode,
]

View File

@@ -0,0 +1,390 @@
import {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
DecoratorNode,
} from 'lexical'
import * as React from 'react'
import { Suspense } from 'react'
export type Cell = {
colSpan: number
json: string
type: 'normal' | 'header'
id: string
width: number | null
}
export type Row = {
cells: Array<Cell>
height: null | number
id: string
}
export type Rows = Array<Row>
export const cellHTMLCache: Map<string, string> = new Map()
export const cellTextContentCache: Map<string, string> = new Map()
const emptyEditorJSON =
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
const plainTextEditorJSON = (text: string) => {
return text === ''
? emptyEditorJSON
: `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`
}
const TableComponent = React.lazy(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
() => import('./TableComponent'),
)
export function createUID(): string {
return Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(0, 5)
}
function createCell(type: 'normal' | 'header'): Cell {
return {
colSpan: 1,
id: createUID(),
json: emptyEditorJSON,
type,
width: null,
}
}
export function createRow(): Row {
return {
cells: [],
height: null,
id: createUID(),
}
}
export type SerializedTableNode = Spread<
{
rows: Rows
type: 'tablesheet'
version: 1
},
SerializedLexicalNode
>
export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows {
const rowElems = tableElem.querySelectorAll('tr')
const rows: Rows = []
for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y]
const cellElems = rowElem.querySelectorAll('td,th')
if (!cellElems || cellElems.length === 0) {
continue
}
const cells: Array<Cell> = []
for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement
const isHeader = cellElem.nodeName === 'TH'
const cell = createCell(isHeader ? 'header' : 'normal')
cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
cells.push(cell)
}
const row = createRow()
row.cells = cells
rows.push(row)
}
return rows
}
function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
const rowElems = domNode.querySelectorAll('tr')
if (!rowElems || rowElems.length === 0) {
return null
}
const rows: Rows = []
for (let y = 0; y < rowElems.length; y++) {
const rowElem = rowElems[y]
const cellElems = rowElem.querySelectorAll('td,th')
if (!cellElems || cellElems.length === 0) {
continue
}
const cells: Array<Cell> = []
for (let x = 0; x < cellElems.length; x++) {
const cellElem = cellElems[x] as HTMLElement
const isHeader = cellElem.nodeName === 'TH'
const cell = createCell(isHeader ? 'header' : 'normal')
cell.json = plainTextEditorJSON(JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')))
cells.push(cell)
}
const row = createRow()
row.cells = cells
rows.push(row)
}
return { node: $createTableNode(rows) }
}
export function exportTableCellsToHTML(
rows: Rows,
rect?: { startX: number; endX: number; startY: number; endY: number },
): HTMLElement {
const table = document.createElement('table')
const colGroup = document.createElement('colgroup')
const tBody = document.createElement('tbody')
const firstRow = rows[0]
for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : firstRow.cells.length); x++) {
const col = document.createElement('col')
colGroup.append(col)
}
for (let y = rect != null ? rect.startY : 0; y < (rect != null ? rect.endY + 1 : rows.length); y++) {
const row = rows[y]
const cells = row.cells
const rowElem = document.createElement('tr')
for (let x = rect != null ? rect.startX : 0; x < (rect != null ? rect.endX + 1 : cells.length); x++) {
const cell = cells[x]
const cellElem = document.createElement(cell.type === 'header' ? 'th' : 'td')
cellElem.innerHTML = cellHTMLCache.get(cell.json) || ''
rowElem.appendChild(cellElem)
}
tBody.appendChild(rowElem)
}
table.appendChild(colGroup)
table.appendChild(tBody)
return table
}
export class TableNode extends DecoratorNode<JSX.Element> {
__rows: Rows
static override getType(): string {
return 'tablesheet'
}
static override clone(node: TableNode): TableNode {
return new TableNode(Array.from(node.__rows), node.__key)
}
static override importJSON(serializedNode: SerializedTableNode): TableNode {
return $createTableNode(serializedNode.rows)
}
override exportJSON(): SerializedTableNode {
return {
rows: this.__rows,
type: 'tablesheet',
version: 1,
}
}
static importDOM(): DOMConversionMap | null {
return {
table: (_node: Node) => ({
conversion: convertTableElement,
priority: 0,
}),
}
}
override exportDOM(): DOMExportOutput {
return { element: exportTableCellsToHTML(this.__rows) }
}
constructor(rows?: Rows, key?: NodeKey) {
super(key)
this.__rows = rows || []
}
override createDOM(): HTMLElement {
const div = document.createElement('div')
div.style.display = 'contents'
return div
}
override updateDOM(): false {
return false
}
mergeRows(startX: number, startY: number, mergeRows: Rows): void {
const self = this.getWritable()
const rows = self.__rows
const endY = Math.min(rows.length, startY + mergeRows.length)
for (let y = startY; y < endY; y++) {
const row = rows[y]
const mergeRow = mergeRows[y - startY]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
const mergeCells = mergeRow.cells
const endX = Math.min(cells.length, startX + mergeCells.length)
for (let x = startX; x < endX; x++) {
const cell = cells[x]
const mergeCell = mergeCells[x - startX]
const cellClone = { ...cell, json: mergeCell.json, type: mergeCell.type }
cellsClone[x] = cellClone
}
rows[y] = rowClone
}
}
updateCellJSON(x: number, y: number, json: string): void {
const self = this.getWritable()
const rows = self.__rows
const row = rows[y]
const cells = row.cells
const cell = cells[x]
const cellsClone = Array.from(cells)
const cellClone = { ...cell, json }
const rowClone = { ...row, cells: cellsClone }
cellsClone[x] = cellClone
rows[y] = rowClone
}
updateCellType(x: number, y: number, type: 'header' | 'normal'): void {
const self = this.getWritable()
const rows = self.__rows
const row = rows[y]
const cells = row.cells
const cell = cells[x]
const cellsClone = Array.from(cells)
const cellClone = { ...cell, type }
const rowClone = { ...row, cells: cellsClone }
cellsClone[x] = cellClone
rows[y] = rowClone
}
insertColumnAt(x: number): void {
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
const type = (cells[x] || cells[x - 1]).type
cellsClone.splice(x, 0, createCell(type))
rows[y] = rowClone
}
}
deleteColumnAt(x: number): void {
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
cellsClone.splice(x, 1)
rows[y] = rowClone
}
}
addColumns(count: number): void {
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
const type = cells[cells.length - 1].type
for (let x = 0; x < count; x++) {
cellsClone.push(createCell(type))
}
rows[y] = rowClone
}
}
insertRowAt(y: number): void {
const self = this.getWritable()
const rows = self.__rows
const prevRow = rows[y] || rows[y - 1]
const cellCount = prevRow.cells.length
const row = createRow()
for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type)
row.cells.push(cell)
}
rows.splice(y, 0, row)
}
deleteRowAt(y: number): void {
const self = this.getWritable()
const rows = self.__rows
rows.splice(y, 1)
}
addRows(count: number): void {
const self = this.getWritable()
const rows = self.__rows
const prevRow = rows[rows.length - 1]
const cellCount = prevRow.cells.length
for (let y = 0; y < count; y++) {
const row = createRow()
for (let x = 0; x < cellCount; x++) {
const cell = createCell(prevRow.cells[x].type)
row.cells.push(cell)
}
rows.push(row)
}
}
updateColumnWidth(x: number, width: number): void {
const self = this.getWritable()
const rows = self.__rows
for (let y = 0; y < rows.length; y++) {
const row = rows[y]
const cells = row.cells
const cellsClone = Array.from(cells)
const rowClone = { ...row, cells: cellsClone }
cellsClone[x].width = width
rows[y] = rowClone
}
}
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
return (
<Suspense>
<TableComponent nodeKey={this.__key} theme={config.theme} rows={this.__rows} />
</Suspense>
)
}
}
export function $isTableNode(node: LexicalNode | null | undefined): node is TableNode {
return node instanceof TableNode
}
export function $createTableNode(rows: Rows): TableNode {
return new TableNode(rows)
}
export function $createTableNodeWithDimensions(
rowCount: number,
columnCount: number,
includeHeaders = true,
): TableNode {
const rows: Rows = []
for (let y = 0; y < columnCount; y++) {
const row: Row = createRow()
rows.push(row)
for (let x = 0; x < rowCount; x++) {
row.cells.push(createCell(includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal'))
}
}
return new TableNode(rows)
}

View File

@@ -0,0 +1,212 @@
/**
* 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 {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical'
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { useCallback, useEffect, useRef, useState } from 'react'
const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js'
type TweetComponentProps = Readonly<{
className: Readonly<{
base: string
focus: string
}>
format: ElementFormatType | null
loadingComponent?: JSX.Element | string
nodeKey: NodeKey
onError?: (error: string) => void
onLoad?: () => void
tweetID: string
}>
function convertTweetElement(domNode: HTMLDivElement): DOMConversionOutput | null {
const id = domNode.getAttribute('data-lexical-tweet-id')
if (id) {
const node = $createTweetNode(id)
return { node }
}
return null
}
let isTwitterScriptLoading = true
function TweetComponent({
className,
format,
loadingComponent,
nodeKey,
onError,
onLoad,
tweetID,
}: TweetComponentProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const previousTweetIDRef = useRef<string>('')
const [isTweetLoading, setIsTweetLoading] = useState(false)
const createTweet = useCallback(async () => {
try {
// @ts-expect-error Twitter is attached to the window.
await window.twttr.widgets.createTweet(tweetID, containerRef.current)
setIsTweetLoading(false)
isTwitterScriptLoading = false
if (onLoad) {
onLoad()
}
} catch (error) {
if (onError) {
onError(String(error))
}
}
}, [onError, onLoad, tweetID])
useEffect(() => {
if (tweetID !== previousTweetIDRef.current) {
setIsTweetLoading(true)
if (isTwitterScriptLoading) {
const script = document.createElement('script')
script.src = WIDGET_SCRIPT_URL
script.async = true
document.body?.appendChild(script)
script.onload = createTweet
if (onError) {
script.onerror = onError as OnErrorEventHandler
}
} else {
createTweet().catch(console.error)
}
if (previousTweetIDRef) {
previousTweetIDRef.current = tweetID
}
}
}, [createTweet, onError, tweetID])
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
{isTweetLoading ? loadingComponent : null}
<div style={{ display: 'inline-block', width: '550px' }} ref={containerRef} />
</BlockWithAlignableContents>
)
}
export type SerializedTweetNode = Spread<
{
id: string
type: 'tweet'
version: 1
},
SerializedDecoratorBlockNode
>
export class TweetNode extends DecoratorBlockNode {
__id: string
static override getType(): string {
return 'tweet'
}
static override clone(node: TweetNode): TweetNode {
return new TweetNode(node.__id, node.__format, node.__key)
}
static override importJSON(serializedNode: SerializedTweetNode): TweetNode {
const node = $createTweetNode(serializedNode.id)
node.setFormat(serializedNode.format)
return node
}
override exportJSON(): SerializedTweetNode {
return {
...super.exportJSON(),
id: this.getId(),
type: 'tweet',
version: 1,
}
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {
div: (domNode: HTMLDivElement) => {
if (!domNode.hasAttribute('data-lexical-tweet-id')) {
return null
}
return {
conversion: convertTweetElement,
priority: 2,
}
},
}
}
override exportDOM(): DOMExportOutput {
const element = document.createElement('div')
element.setAttribute('data-lexical-tweet-id', 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
}
override getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `https://twitter.com/i/web/status/${this.__id}`
}
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}
const className = {
base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '',
}
return (
<TweetComponent
className={className}
format={this.__format}
loadingComponent="Loading..."
nodeKey={this.getKey()}
tweetID={this.__id}
/>
)
}
override isInline(): false {
return false
}
}
export function $createTweetNode(tweetID: string): TweetNode {
return new TweetNode(tweetID)
}
export function $isTweetNode(node: TweetNode | LexicalNode | null | undefined): node is TweetNode {
return node instanceof TweetNode
}

View File

@@ -0,0 +1,112 @@
/**
* 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 { EditorConfig, ElementFormatType, LexicalEditor, LexicalNode, NodeKey, Spread } from 'lexical'
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
type YouTubeComponentProps = Readonly<{
className: Readonly<{
base: string
focus: string
}>
format: ElementFormatType | null
nodeKey: NodeKey
videoID: string
}>
function YouTubeComponent({ className, format, nodeKey, videoID }: YouTubeComponentProps) {
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<iframe
width="560"
height="315"
src={`https://www.youtube.com/embed/${videoID}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
title="YouTube video"
/>
</BlockWithAlignableContents>
)
}
export type SerializedYouTubeNode = Spread<
{
videoID: string
type: 'youtube'
version: 1
},
SerializedDecoratorBlockNode
>
export class YouTubeNode extends DecoratorBlockNode {
__id: string
static getType(): string {
return 'youtube'
}
static clone(node: YouTubeNode): YouTubeNode {
return new YouTubeNode(node.__id, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
const node = $createYouTubeNode(serializedNode.videoID)
node.setFormat(serializedNode.format)
return node
}
exportJSON(): SerializedYouTubeNode {
return {
...super.exportJSON(),
type: 'youtube',
version: 1,
videoID: this.__id,
}
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
updateDOM(): false {
return false
}
getId(): string {
return this.__id
}
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `https://www.youtube.com/watch?v=${this.__id}`
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}
const className = {
base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '',
}
return <YouTubeComponent className={className} format={this.__format} nodeKey={this.getKey()} videoID={this.__id} />
}
isInline(): false {
return false
}
}
export function $createYouTubeNode(videoID: string): YouTubeNode {
return new YouTubeNode(videoID)
}
export function $isYouTubeNode(node: YouTubeNode | LexicalNode | null | undefined): node is YouTubeNode {
return node instanceof YouTubeNode
}

View File

@@ -0,0 +1,12 @@
/**
* 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.
*
*/
export const CAN_USE_DOM: boolean =
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'

View File

@@ -0,0 +1,37 @@
/**
* 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 { CAN_USE_DOM } from './canUseDOM'
declare global {
interface Document {
documentMode?: string
}
interface Window {
MSStream?: unknown
}
}
const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null
export const IS_APPLE: boolean = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
export const IS_FIREFOX: boolean = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
export const CAN_USE_BEFORE_INPUT: boolean =
CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false
export const IS_SAFARI: boolean = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent)
export const IS_IOS: boolean = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
// Keep these in case we need to use them in the future.
// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
// export const IS_CHROME: boolean = CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;

View File

@@ -0,0 +1,12 @@
// invariant(condition, message) will refine types based on "condition", and
// if "condition" is false will throw an error. This function is special-cased
// in flow itself, so we can't name it anything else.
export default function invariant(cond?: boolean, _message?: string, ..._args: string[]): asserts cond {
if (cond) {
return
}
throw new Error(
'Internal Lexical error: invariant() is meant to be replaced at compile ' + 'time. There is no runtime version.',
)
}

View File

@@ -0,0 +1,15 @@
export const IconComponent = ({
children,
size = 20,
paddingTop = 0,
}: {
children: React.ReactNode
size?: number
paddingTop?: number
}) => {
return (
<span className="svg-icon" style={{ width: size, height: size, paddingTop }}>
{children}
</span>
)
}

View File

@@ -0,0 +1,95 @@
import type { EditorThemeClasses } from 'lexical'
const BlocksEditorTheme: EditorThemeClasses = {
characterLimit: 'Lexical__characterLimit',
code: 'Lexical__code',
codeHighlight: {
atrule: 'Lexical__tokenAttr',
attr: 'Lexical__tokenAttr',
boolean: 'Lexical__tokenProperty',
builtin: 'Lexical__tokenSelector',
cdata: 'Lexical__tokenComment',
char: 'Lexical__tokenSelector',
class: 'Lexical__tokenFunction',
'class-name': 'Lexical__tokenFunction',
comment: 'Lexical__tokenComment',
constant: 'Lexical__tokenProperty',
deleted: 'Lexical__tokenProperty',
doctype: 'Lexical__tokenComment',
entity: 'Lexical__tokenOperator',
function: 'Lexical__tokenFunction',
important: 'Lexical__tokenVariable',
inserted: 'Lexical__tokenSelector',
keyword: 'Lexical__tokenAttr',
namespace: 'Lexical__tokenVariable',
number: 'Lexical__tokenProperty',
operator: 'Lexical__tokenOperator',
prolog: 'Lexical__tokenComment',
property: 'Lexical__tokenProperty',
punctuation: 'Lexical__tokenPunctuation',
regex: 'Lexical__tokenVariable',
selector: 'Lexical__tokenSelector',
string: 'Lexical__tokenSelector',
symbol: 'Lexical__tokenProperty',
tag: 'Lexical__tokenProperty',
url: 'Lexical__tokenOperator',
variable: 'Lexical__tokenVariable',
},
embedBlock: {
base: 'Lexical__embedBlock',
focus: 'Lexical__embedBlockFocus',
},
hashtag: 'Lexical__hashtag',
heading: {
h1: 'Lexical__h1',
h2: 'Lexical__h2',
h3: 'Lexical__h3',
h4: 'Lexical__h4',
h5: 'Lexical__h5',
h6: 'Lexical__h6',
},
image: 'editor-image',
link: 'Lexical__link',
list: {
listitem: 'Lexical__listItem',
listitemChecked: 'Lexical__listItemChecked',
listitemUnchecked: 'Lexical__listItemUnchecked',
nested: {
listitem: 'Lexical__nestedListItem',
},
olDepth: ['Lexical__ol1', 'Lexical__ol2', 'Lexical__ol3', 'Lexical__ol4', 'Lexical__ol5'],
ul: 'Lexical__ul',
},
ltr: 'Lexical__ltr',
mark: 'Lexical__mark',
markOverlap: 'Lexical__markOverlap',
paragraph: 'Lexical__paragraph',
quote: 'Lexical__quote',
rtl: 'Lexical__rtl',
table: 'Lexical__table',
tableAddColumns: 'Lexical__tableAddColumns',
tableAddRows: 'Lexical__tableAddRows',
tableCell: 'Lexical__tableCell',
tableCellActionButton: 'Lexical__tableCellActionButton',
tableCellActionButtonContainer: 'Lexical__tableCellActionButtonContainer',
tableCellEditing: 'Lexical__tableCellEditing',
tableCellHeader: 'Lexical__tableCellHeader',
tableCellPrimarySelected: 'Lexical__tableCellPrimarySelected',
tableCellResizer: 'Lexical__tableCellResizer',
tableCellSelected: 'Lexical__tableCellSelected',
tableCellSortedIndicator: 'Lexical__tableCellSortedIndicator',
tableResizeRuler: 'Lexical__tableCellResizeRuler',
tableSelected: 'Lexical__tableSelected',
text: {
bold: 'Lexical__textBold',
code: 'Lexical__textCode',
italic: 'Lexical__textItalic',
strikethrough: 'Lexical__textStrikethrough',
subscript: 'Lexical__textSubscript',
superscript: 'Lexical__textSuperscript',
underline: 'Lexical__textUnderline',
underlineStrikethrough: 'Lexical__textUnderlineStrikethrough',
},
}
export default BlocksEditorTheme

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
@import '../../Plugins/DraggableBlockPlugin/index.scss';
#typeahead-menu {
z-index: 10000;
}
#blocks-editor {
p {
color: var(--sn-stylekit-editor-foreground-color);
}
}

View File

@@ -0,0 +1,427 @@
.Lexical__ltr {
text-align: left;
}
.Lexical__rtl {
text-align: right;
}
.Lexical__paragraph {
margin: 0;
position: relative;
}
.Lexical__quote {
margin: 0;
margin-left: 20px;
margin-bottom: 10px;
font-size: 15px;
color: rgb(101, 103, 107);
border-left-color: rgb(206, 208, 212);
border-left-width: 4px;
border-left-style: solid;
padding-left: 16px;
}
.Lexical__h1 {
font-size: 26px;
color: var(--sn-stylekit-editor-foreground-color);
font-weight: 700;
margin: 0;
}
.Lexical__h2 {
font-size: 22px;
color: var(--sn-stylekit-editor-foreground-color);
font-weight: 700;
margin: 0;
}
.Lexical__h3 {
font-size: 19px;
font-weight: 700;
margin: 0;
}
.Lexical__textBold {
font-weight: bold;
}
.Lexical__textItalic {
font-style: italic;
}
.Lexical__textUnderline {
text-decoration: underline;
}
.Lexical__textStrikethrough {
text-decoration: line-through;
}
.Lexical__textUnderlineStrikethrough {
text-decoration: underline line-through;
}
.Lexical__textSubscript {
font-size: 0.8em;
vertical-align: sub !important;
}
.Lexical__textSuperscript {
font-size: 0.8em;
vertical-align: super;
}
.Lexical__textCode {
background-color: var(--sn-stylekit-secondary-background-color);
color: var(--sn-stylekit-info-color);
padding: 5px;
border-radius: 5px;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 85%;
}
.Lexical__hashtag {
background-color: rgba(88, 144, 255, 0.15);
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
}
.Lexical__link {
color: var(--sn-stylekit-info-color);
text-decoration: none;
}
.Lexical__link:hover {
text-decoration: underline;
}
.Lexical__code {
background-color: var(--sn-stylekit-contrast-background-color);
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 8px 8px 52px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
tab-size: 2;
/* white-space: pre; */
overflow-x: auto;
position: relative;
}
.Lexical__code:before {
content: attr(data-gutter);
position: absolute;
background-color: var(--sn-stylekit-secondary-background-color);
left: 0;
top: 0;
border-right: 1px solid var(--sn-stylekit-contrast-border-color);
padding: 8px;
color: var(--sn-stylekit-info-color);
white-space: pre-wrap;
text-align: right;
min-width: 25px;
}
.Lexical__table {
border-collapse: collapse;
border-spacing: 0;
max-width: 100%;
overflow-y: scroll;
table-layout: fixed;
width: calc(100% - 25px);
margin: 30px 0;
color: var(--sn-stylekit-contrast-foreground-color);
}
.Lexical__tableSelected {
outline: 2px solid rgb(60, 132, 244);
}
.Lexical__tableCell {
border: 1px solid var(--sn-stylekit-border-color);
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
cursor: default;
outline: none;
}
.Lexical__tableCellSortedIndicator {
display: block;
opacity: 0.5;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #999;
}
.Lexical__tableCellResizer {
position: absolute;
right: -4px;
height: 100%;
width: 8px;
cursor: ew-resize;
z-index: 10;
top: 0;
}
.Lexical__tableCellHeader {
background-color: var(--sn-stylekit-contrast-background-color);
border-color: var(--sn-stylekit-contrast-border-color);
text-align: start;
}
.Lexical__tableCellSelected {
background-color: #c9dbf0;
}
.Lexical__tableCellPrimarySelected {
border: 2px solid rgb(60, 132, 244);
display: block;
height: calc(100% - 2px);
position: absolute;
width: calc(100% - 2px);
left: -1px;
top: -1px;
z-index: 2;
}
.Lexical__tableCellEditing {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
border-radius: 3px;
}
.Lexical__tableAddColumns {
position: absolute;
top: 0;
width: 20px;
background-color: #eee;
height: 100%;
right: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
.Lexical__tableAddColumns:after {
background-image: url(#{$blocks-editor-icons-path}/plus.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
display: block;
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.4;
}
.Lexical__tableAddColumns:hover {
background-color: #c9dbf0;
}
.Lexical__tableAddRows {
position: absolute;
bottom: -25px;
width: calc(100% - 25px);
background-color: #eee;
height: 20px;
left: 0;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
}
.Lexical__tableAddRows:after {
background-image: url(#{$blocks-editor-icons-path}/plus.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
display: block;
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.4;
}
.Lexical__tableAddRows:hover {
background-color: #c9dbf0;
}
@keyframes table-controls {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.Lexical__tableCellResizeRuler {
display: block;
position: absolute;
width: 1px;
background-color: rgb(60, 132, 244);
height: 100%;
top: 0;
}
.Lexical__tableCellActionButtonContainer {
display: block;
right: 5px;
top: 6px;
position: absolute;
z-index: 4;
width: 20px;
height: 20px;
}
.Lexical__tableCellActionButton {
background-color: #eee;
display: block;
border: 0;
border-radius: 20px;
width: 20px;
height: 20px;
color: #222;
cursor: pointer;
}
.Lexical__tableCellActionButton:hover {
background-color: #ddd;
}
.Lexical__characterLimit {
display: inline;
background-color: #ffbbbb !important;
}
.Lexical__ol1 {
padding: 0;
margin: 0;
margin-left: 16px;
list-style-position: outside;
}
.Lexical__ol2 {
padding: 0;
margin: 0;
margin-left: 16px;
list-style-type: upper-alpha;
list-style-position: outside;
}
.Lexical__ol3 {
padding: 0;
margin: 0;
margin-left: 16px;
list-style-type: lower-alpha;
list-style-position: outside;
}
.Lexical__ol4 {
padding: 0;
margin: 0;
margin-left: 16px;
list-style-type: upper-roman;
list-style-position: outside;
}
.Lexical__ol5 {
padding: 0;
margin: 0;
margin-left: 16px;
list-style-type: lower-roman;
list-style-position: outside;
}
.Lexical__ul {
padding: 0;
margin: 0;
margin-left: 16px;
list-style-position: outside;
}
.Lexical__listItem {
margin: 0 0px;
}
.Lexical__listItemChecked,
.Lexical__listItemUnchecked {
position: relative;
margin-left: 8px;
margin-right: 8px;
padding-left: 24px;
padding-right: 24px;
list-style-type: none;
outline: none;
vertical-align: middle;
}
.Lexical__listItemChecked {
text-decoration: line-through;
opacity: 0.4;
}
.Lexical__listItemUnchecked:before,
.Lexical__listItemChecked:before {
content: '';
width: 16px;
height: 16px;
left: 0;
top: 5px;
cursor: pointer;
background-size: cover;
position: absolute;
}
.Lexical__listItemUnchecked[dir='rtl']:before,
.Lexical__listItemChecked[dir='rtl']:before {
left: auto;
right: 0;
}
.Lexical__listItemUnchecked:focus:before,
.Lexical__listItemChecked:focus:before {
box-shadow: 0 0 0 2px #a6cdfe;
border-radius: 2px;
}
.Lexical__listItemUnchecked:before {
border: 1px solid #999;
border-radius: 2px;
}
.Lexical__listItemChecked:before {
border: 1px solid var(--sn-stylekit-info-color);
border-radius: 2px;
background-color: var(--sn-stylekit-info-color);
background-repeat: no-repeat;
}
.Lexical__listItemChecked:after {
content: '';
cursor: pointer;
border-color: var(--sn-stylekit-info-contrast-color);
border-style: solid;
position: absolute;
display: block;
top: 7px;
width: 5px;
left: 6px;
height: 10px;
transform: rotate(45deg);
border-width: 0 2px 2px 0;
}
.Lexical__nestedListItem {
list-style-type: none;
}
.Lexical__nestedListItem:before,
.Lexical__nestedListItem:after {
display: none;
}
.Lexical__tokenComment {
color: slategray;
}
.Lexical__tokenPunctuation {
color: #999;
}
.Lexical__tokenProperty {
color: #905;
}
.Lexical__tokenSelector {
color: #690;
}
.Lexical__tokenOperator {
color: #9a6e3a;
}
.Lexical__tokenAttr {
color: #07a;
}
.Lexical__tokenVariable {
color: #e90;
}
.Lexical__tokenFunction {
color: #dd4a68;
}
.Lexical__mark {
background: rgba(255, 212, 0, 0.14);
border-bottom: 2px solid rgba(255, 212, 0, 0.3);
padding-bottom: 2px;
}
.Lexical__markOverlap {
background: rgba(255, 212, 0, 0.3);
border-bottom: 2px solid rgba(255, 212, 0, 0.7);
}
.Lexical__mark.selected {
background: rgba(255, 212, 0, 0.5);
border-bottom: 2px solid rgba(255, 212, 0, 1);
}
.Lexical__markOverlap.selected {
background: rgba(255, 212, 0, 0.7);
border-bottom: 2px solid rgba(255, 212, 0, 0.7);
}
.Lexical__embedBlock {
user-select: none;
}
.Lexical__embedBlockFocus {
outline: 2px solid rgb(60, 132, 244);
}

View File

@@ -0,0 +1,117 @@
.icon.plus {
background-image: url(#{$blocks-editor-icons-path}/plus.svg);
}
/**
.icon.dropdown-more {
background-image: url(#{$blocks-editor-icons-path}/dropdown-more.svg);
}
.icon.font-color {
background-image: url(#{$blocks-editor-icons-path}/font-color.svg);
}
.icon.font-family {
background-image: url(#{$blocks-editor-icons-path}/font-family.svg);
}
.icon.bg-color {
background-image: url(#{$blocks-editor-icons-path}/bg-color.svg);
}
i.palette {
background-image: url(#{$blocks-editor-icons-path}/palette.svg);
}
i.bucket {
background-image: url(#{$blocks-editor-icons-path}/paint-bucket.svg);
}
i.clear {
background-image: url(#{$blocks-editor-icons-path}/trash.svg);
}
i.image {
background-image: url(#{$blocks-editor-icons-path}/file-image.svg);
}
i.close {
background-image: url(#{$blocks-editor-icons-path}/close.svg);
}
i.figma {
background-image: url(#{$blocks-editor-icons-path}/figma.svg);
}
i.poll {
background-image: url(#{$blocks-editor-icons-path}/card-checklist.svg);
}
i.indent {
background-image: url(#{$blocks-editor-icons-path}/indent.svg);
}
i.markdown {
background-image: url(#{$blocks-editor-icons-path}/markdown.svg);
}
i.outdent {
background-image: url(#{$blocks-editor-icons-path}/outdent.svg);
}
i.undo {
background-image: url(#{$blocks-editor-icons-path}/arrow-counterclockwise.svg);
}
i.redo {
background-image: url(#{$blocks-editor-icons-path}/arrow-clockwise.svg);
}
i.sticky {
background-image: url(#{$blocks-editor-icons-path}/sticky.svg);
}
i.mic {
background-image: url(#{$blocks-editor-icons-path}/mic.svg);
}
i.import {
background-image: url(#{$blocks-editor-icons-path}/upload.svg);
}
i.export {
background-image: url(#{$blocks-editor-icons-path}/download.svg);
}
i.diagram-2 {
background-image: url(#{$blocks-editor-icons-path}/diagram-2.svg);
}
i.user {
background-image: url(#{$blocks-editor-icons-path}/user.svg);
}
i.equation {
background-image: url(#{$blocks-editor-icons-path}/plus-slash-minus.svg);
}
i.gif {
background-image: url(#{$blocks-editor-icons-path}/filetype-gif.svg);
}
i.copy {
background-image: url(#{$blocks-editor-icons-path}/copy.svg);
}
i.success {
background-image: url(#{$blocks-editor-icons-path}/success.svg);
}
i.prettier {
background-image: url(#{$blocks-editor-icons-path}/prettier.svg);
}
i.prettier-error {
background-image: url(#{$blocks-editor-icons-path}/prettier-error.svg);
}
*/

View File

@@ -0,0 +1,5 @@
@import 'base';
@import 'custom';
@import 'editor';
@import 'icons';
@import 'links';

View File

@@ -0,0 +1,110 @@
.button.active,
.toolbar .button.active {
background-color: var(--sn-stylekit-contrast-background-color);
}
.link-editor {
background-color: var(--sn-stylekit-secondary-background-color);
border-radius: 8px;
border: 0;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
box-sizing: border-box;
display: absolute;
font-family: inherit;
font-size: 15px;
left: 0;
margin: 8px 12px;
max-width: 400px;
opacity: 0;
outline: 0;
padding: 8px 12px;
position: absolute;
top: 0;
transition: opacity 0.5s;
width: 100%;
will-change: transform;
z-index: 10;
}
.link-input {
background-color: var(--sn-stylekit-background-color);
border-radius: 15px;
border: 0;
box-sizing: border-box;
color: var(--sn-stylekit-contrast-color);
display: absolute;
font-family: inherit;
font-size: 15px;
margin: 8px 12px;
outline: 0;
padding: 8px 12px;
position: relative;
width: calc(100% - 24px);
}
.link-editor div.link-edit {
display: flex;
align-items: center;
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
vertical-align: -0.25em;
position: absolute;
right: 12px;
top: 0;
bottom: 0;
cursor: pointer;
}
.link-editor .link-input a {
color: rgb(33, 111, 219);
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
margin-right: 30px;
text-overflow: ellipsis;
}
.link-editor .link-input a:hover {
text-decoration: underline;
}
.link-editor .font-size-wrapper,
.link-editor .font-family-wrapper {
display: flex;
margin: 0 4px;
}
.link-editor select {
padding: 6px;
border: none;
background-color: rgba(0, 0, 0, 0.075);
border-radius: 4px;
}
.link-editor .button {
width: 20px;
height: 20px;
display: inline-block;
padding: 6px;
border-radius: 8px;
cursor: pointer;
margin: 0 2px;
}
.link-editor .button.hovered {
width: 20px;
height: 20px;
display: inline-block;
background-color: #eee;
}
.link-editor .button i,
.actions i {
background-size: contain;
display: inline-block;
height: 20px;
width: 20px;
vertical-align: -0.25em;
}

View File

@@ -0,0 +1,36 @@
/**
* 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.
*
*
*/
.Button__root {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 15px;
padding-right: 15px;
border: 0px;
background-color: var(--sn-stylekit-contrast-background-color);
cursor: pointer;
font-size: 14px;
}
.Button__root:hover {
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.Button__small {
padding-top: 5px;
padding-bottom: 5px;
padding-left: 10px;
padding-right: 10px;
font-size: 13px;
}
.Button__disabled {
cursor: not-allowed;
}
.Button__disabled:hover {
background-color: var(--sn-stylekit-secondary-background-color);
}

View File

@@ -0,0 +1,44 @@
/**
* 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 './Button.css'
import { ReactNode } from 'react'
import joinClasses from '../Utils/join-classes'
export default function Button({
'data-test-id': dataTestId,
children,
className,
onClick,
disabled,
small,
title,
}: {
'data-test-id'?: string
children: ReactNode
className?: string
disabled?: boolean
onClick: () => void
small?: boolean
title?: string
}): JSX.Element {
return (
<button
disabled={disabled}
className={joinClasses('Button__root', disabled && 'Button__disabled', small && 'Button__small', className)}
onClick={onClick}
title={title}
aria-label={title}
{...(dataTestId && { 'data-test-id': dataTestId })}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,17 @@
.DialogActions {
display: flex;
flex-direction: row;
justify-content: right;
margin-top: 20px;
}
.DialogButtonsList {
display: flex;
flex-direction: column;
justify-content: right;
margin-top: 20px;
}
.DialogButtonsList button {
margin-bottom: 20px;
}

View File

@@ -0,0 +1,28 @@
/**
* 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 './Dialog.css'
import { ReactNode } from 'react'
type Props = Readonly<{
'data-test-id'?: string
children: ReactNode
}>
export function DialogButtonsList({ children }: Props): JSX.Element {
return <div className="DialogButtonsList">{children}</div>
}
export function DialogActions({ 'data-test-id': dataTestId, children }: Props): JSX.Element {
return (
<div className="DialogActions" data-test-id={dataTestId}>
{children}
</div>
)
}

View File

@@ -0,0 +1,33 @@
/**
* 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.
*
*
*/
.Input__wrapper {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10px;
}
.Input__label {
display: flex;
flex: 1;
color: #666;
margin-right: 20px;
}
.Input__input {
display: flex;
flex: 2;
border: 1px solid var(--sn-stylekit-contrast-border-color);
background-color: var(--sn-stylekit-contrast-background-color);
padding-top: 7px;
padding-bottom: 7px;
padding-left: 10px;
padding-right: 10px;
font-size: 16px;
min-width: 0;
}

View File

@@ -0,0 +1,69 @@
/**
* 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.
*
*
*/
@keyframes glimmer-animation {
0% {
background: #f9f9f9;
}
.50% {
background: #eeeeee;
}
.100% {
background: #f9f9f9;
}
}
.LinkPreview__container {
padding-bottom: 12px;
}
.LinkPreview__imageWrapper {
text-align: center;
}
.LinkPreview__image {
max-width: 100%;
max-height: 250px;
margin: auto;
}
.LinkPreview__title {
margin-left: 12px;
margin-right: 12px;
margin-top: 4px;
}
.LinkPreview__description {
color: #999;
font-size: 90%;
margin-left: 12px;
margin-right: 12px;
margin-top: 4px;
}
.LinkPreview__domain {
color: #999;
font-size: 90%;
margin-left: 12px;
margin-right: 12px;
margin-top: 4px;
}
.LinkPreview__glimmer {
background: #f9f9f9;
border-radius: 8px;
height: 18px;
margin-bottom: 8px;
margin-left: 12px;
margin-right: 12px;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-name: glimmer-animation;
}

View File

@@ -0,0 +1,106 @@
/**
* 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 './LinkPreview.css'
import { CSSProperties, Suspense } from 'react'
type Preview = {
title: string
description: string
img: string
domain: string
} | null
// Cached responses or running request promises
const PREVIEW_CACHE: Record<string, Promise<Preview> | { preview: Preview }> = {}
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
function useSuspenseRequest(url: string) {
let cached = PREVIEW_CACHE[url]
if (!url.match(URL_MATCHER)) {
return { preview: null }
}
if (!cached) {
cached = PREVIEW_CACHE[url] = fetch(`/api/link-preview?url=${encodeURI(url)}`)
.then((response) => response.json())
.then((preview) => {
PREVIEW_CACHE[url] = preview
return preview
})
.catch(() => {
PREVIEW_CACHE[url] = { preview: null }
})
}
if (cached instanceof Promise) {
throw cached
}
return cached
}
function LinkPreviewContent({
url,
}: Readonly<{
url: string
}>): JSX.Element | null {
const { preview } = useSuspenseRequest(url)
if (preview === null) {
return null
}
return (
<div className="LinkPreview__container">
{preview.img && (
<div className="LinkPreview__imageWrapper">
<img src={preview.img} alt={preview.title} className="LinkPreview__image" />
</div>
)}
{preview.domain && <div className="LinkPreview__domain">{preview.domain}</div>}
{preview.title && <div className="LinkPreview__title">{preview.title}</div>}
{preview.description && <div className="LinkPreview__description">{preview.description}</div>}
</div>
)
}
function Glimmer(props: { style: CSSProperties; index: number }): JSX.Element {
return (
<div
className="LinkPreview__glimmer"
{...props}
style={{
animationDelay: String((props.index || 0) * 300),
...(props.style || {}),
}}
/>
)
}
export default function LinkPreview({
url,
}: Readonly<{
url: string
}>): JSX.Element {
return (
<Suspense
fallback={
<>
<Glimmer style={{ height: '80px' }} index={0} />
<Glimmer style={{ width: '60%' }} index={1} />
<Glimmer style={{ width: '80%' }} index={2} />
</>
}
>
<LinkPreviewContent url={url} />
</Suspense>
)
}

View File

@@ -0,0 +1,64 @@
/**
* 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.
*
*
*/
.Modal__overlay {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
flex-direction: column;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.7);
flex-grow: 0px;
flex-shrink: 1px;
z-index: 100;
}
.Modal__modal {
padding: 20px;
min-height: 100px;
min-width: 300px;
display: flex;
flex-grow: 0px;
background-color: var(--sn-stylekit-background-color);
flex-direction: column;
position: relative;
box-shadow: 0 0px 0 var(--sn-stylekit-shadow-color);
border-radius: 0px;
}
.Modal__title {
color: var(--sn-stylekit-foreground-color);
margin: 0px;
padding-bottom: 15px;
border-bottom: 1px solid var(--sn-stylekit-border-color);
}
.Modal__closeButton {
border: 0px;
position: absolute;
right: 20px;
top: 15px;
border-radius: 20px;
justify-content: center;
align-items: center;
display: flex;
width: 30px;
height: 30px;
text-align: center;
cursor: pointer;
background-color: var(--sn-stylekit-contrast-background-color);
}
.Modal__closeButton:hover {
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.Modal__content {
padding-top: 20px;
}

View File

@@ -0,0 +1,93 @@
/**
* 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 './Modal.css'
import { ReactNode, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
function PortalImpl({
onClose,
children,
title,
closeOnClickOutside,
}: {
children: ReactNode
closeOnClickOutside: boolean
onClose: () => void
title: string
}) {
const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (modalRef.current !== null) {
modalRef.current.focus()
}
}, [])
useEffect(() => {
let modalOverlayElement: HTMLElement | null = null
const handler = (event: KeyboardEvent) => {
if (event.keyCode === 27) {
onClose()
}
}
const clickOutsideHandler = (event: MouseEvent) => {
const target = event.target
if (modalRef.current !== null && !modalRef.current.contains(target as Node) && closeOnClickOutside) {
onClose()
}
}
if (modalRef.current !== null) {
modalOverlayElement = modalRef.current?.parentElement
if (modalOverlayElement !== null) {
modalOverlayElement?.addEventListener('click', clickOutsideHandler)
}
}
window.addEventListener('keydown', handler)
return () => {
window.removeEventListener('keydown', handler)
if (modalOverlayElement !== null) {
modalOverlayElement?.removeEventListener('click', clickOutsideHandler)
}
}
}, [closeOnClickOutside, onClose])
return (
<div className="Modal__overlay" role="dialog">
<div className="Modal__modal" tabIndex={-1} ref={modalRef}>
<h2 className="Modal__title">{title}</h2>
<button className="Modal__closeButton" aria-label="Close modal" type="button" onClick={onClose}>
</button>
<div className="Modal__content">{children}</div>
</div>
</div>
)
}
export default function Modal({
onClose,
children,
title,
closeOnClickOutside = false,
}: {
children: ReactNode
closeOnClickOutside?: boolean
onClose: () => void
title: string
}): JSX.Element {
return createPortal(
<PortalImpl onClose={onClose} title={title} closeOnClickOutside={closeOnClickOutside}>
{children}
</PortalImpl>,
document.body,
)
}

View File

@@ -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 './Input.css'
type Props = Readonly<{
'data-test-id'?: string
label: string
onChange: (val: string) => void
placeholder?: string
value: string
}>
export default function TextInput({
label,
value,
onChange,
placeholder = '',
'data-test-id': dataTestId,
}: Props): JSX.Element {
return (
<div className="Input__wrapper">
<label className="Input__label">{label}</label>
<input
type="text"
className="Input__input"
placeholder={placeholder}
value={value}
onChange={(e) => {
onChange(e.target.value)
}}
data-test-id={dataTestId}
/>
</div>
)
}

View File

@@ -0,0 +1,24 @@
/**
* 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.
*
*/
export function getDOMRangeRect(nativeSelection: Selection, rootElement: HTMLElement): DOMRect {
const domRange = nativeSelection.getRangeAt(0)
let rect
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement
while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement
}
rect = inner.getBoundingClientRect()
} else {
rect = domRange.getBoundingClientRect()
}
return rect
}

View File

@@ -0,0 +1,25 @@
/**
* 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 { $isAtNodeEnd } from '@lexical/selection'
import { ElementNode, RangeSelection, TextNode } from 'lexical'
export function getSelectedNode(selection: RangeSelection): TextNode | ElementNode {
const anchor = selection.anchor
const focus = selection.focus
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
if (anchorNode === focusNode) {
return anchorNode
}
const isBackward = selection.isBackward()
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode
}
}

View File

@@ -0,0 +1,10 @@
/**
* 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.
*
*/
export function isHTMLElement(x: unknown): x is HTMLElement {
return x instanceof HTMLElement
}

View File

@@ -0,0 +1,11 @@
/**
* 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.
*
*/
export default function joinClasses(...args: Array<string | boolean | null | undefined>) {
return args.filter(Boolean).join(' ')
}

View File

@@ -0,0 +1,52 @@
/**
* 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.
*
*/
export class Point {
private readonly _x: number
private readonly _y: number
constructor(x: number, y: number) {
this._x = x
this._y = y
}
get x(): number {
return this._x
}
get y(): number {
return this._y
}
public equals({ x, y }: Point): boolean {
return this.x === x && this.y === y
}
public calcDeltaXTo({ x }: Point): number {
return this.x - x
}
public calcDeltaYTo({ y }: Point): number {
return this.y - y
}
public calcHorizontalDistanceTo(point: Point): number {
return Math.abs(this.calcDeltaXTo(point))
}
public calcVerticalDistance(point: Point): number {
return Math.abs(this.calcDeltaYTo(point))
}
public calcDistanceTo(point: Point): number {
return Math.sqrt(Math.pow(this.calcDeltaXTo(point), 2) + Math.pow(this.calcDeltaYTo(point), 2))
}
}
export function isPoint(x: unknown): x is Point {
return x instanceof Point
}

View File

@@ -0,0 +1,135 @@
/**
* 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 { isPoint, Point } from './point'
export type ContainsPointReturn = {
result: boolean
reason: {
isOnTopSide: boolean
isOnBottomSide: boolean
isOnLeftSide: boolean
isOnRightSide: boolean
}
}
export class Rect {
private readonly _left: number
private readonly _top: number
private readonly _right: number
private readonly _bottom: number
constructor(left: number, top: number, right: number, bottom: number) {
const [physicTop, physicBottom] = top <= bottom ? [top, bottom] : [bottom, top]
const [physicLeft, physicRight] = left <= right ? [left, right] : [right, left]
this._top = physicTop
this._right = physicRight
this._left = physicLeft
this._bottom = physicBottom
}
get top(): number {
return this._top
}
get right(): number {
return this._right
}
get bottom(): number {
return this._bottom
}
get left(): number {
return this._left
}
get width(): number {
return Math.abs(this._left - this._right)
}
get height(): number {
return Math.abs(this._bottom - this._top)
}
public equals({ top, left, bottom, right }: Rect): boolean {
return top === this._top && bottom === this._bottom && left === this._left && right === this._right
}
public contains({ x, y }: Point): ContainsPointReturn
public contains({ top, left, bottom, right }: Rect): boolean
public contains(target: Point | Rect): boolean | ContainsPointReturn {
if (isPoint(target)) {
const { x, y } = target
const isOnTopSide = y < this._top
const isOnBottomSide = y > this._bottom
const isOnLeftSide = x < this._left
const isOnRightSide = x > this._right
const result = !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide
return {
reason: {
isOnBottomSide,
isOnLeftSide,
isOnRightSide,
isOnTopSide,
},
result,
}
} else {
const { top, left, bottom, right } = target
return (
top >= this._top &&
top <= this._bottom &&
bottom >= this._top &&
bottom <= this._bottom &&
left >= this._left &&
left <= this._right &&
right >= this._left &&
right <= this._right
)
}
}
public intersectsWith(rect: Rect): boolean {
const { left: x1, top: y1, width: w1, height: h1 } = rect
const { left: x2, top: y2, width: w2, height: h2 } = this
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2
const minX = x1 <= x2 ? x1 : x2
const minY = y1 <= y2 ? y1 : y2
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2
}
public generateNewRect({ left = this.left, top = this.top, right = this.right, bottom = this.bottom }): Rect {
return new Rect(left, top, right, bottom)
}
static fromLTRB(left: number, top: number, right: number, bottom: number): Rect {
return new Rect(left, top, right, bottom)
}
static fromLWTH(left: number, width: number, top: number, height: number): Rect {
return new Rect(left, top, left + width, top + height)
}
static fromPoints(startPoint: Point, endPoint: Point): Rect {
const { y: top, x: left } = startPoint
const { y: bottom, x: right } = endPoint
return Rect.fromLTRB(left, top, right, bottom)
}
static fromDOM(dom: HTMLElement): Rect {
const { top, width, left, height } = dom.getBoundingClientRect()
return Rect.fromLWTH(left, width, top, height)
}
}

View File

@@ -0,0 +1,24 @@
/**
* 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.
*
*/
export const sanitizeUrl = (url: string): string => {
/** A pattern that matches safe URLs. */
const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi
/** A pattern that matches safe data URLs. */
const DATA_URL_PATTERN =
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
url = String(url).trim()
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) {
return url
}
return 'https://'
}

View File

@@ -0,0 +1,46 @@
/**
* 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.
*
*/
const VERTICAL_GAP = 10
const HORIZONTAL_OFFSET = 5
export function setFloatingElemPosition(
targetRect: ClientRect | null,
floatingElem: HTMLElement,
anchorElem: HTMLElement,
verticalGap: number = VERTICAL_GAP,
horizontalOffset: number = HORIZONTAL_OFFSET,
): void {
const scrollerElem = anchorElem.parentElement
if (targetRect === null || !scrollerElem) {
floatingElem.style.opacity = '0'
floatingElem.style.transform = 'translate(-10000px, -10000px)'
return
}
const floatingElemRect = floatingElem.getBoundingClientRect()
const anchorElementRect = anchorElem.getBoundingClientRect()
const editorScrollerRect = scrollerElem.getBoundingClientRect()
let top = targetRect.top - floatingElemRect.height - verticalGap
let left = targetRect.left - horizontalOffset
if (top < editorScrollerRect.top) {
top += floatingElemRect.height + targetRect.height + verticalGap * 2
}
if (left + floatingElemRect.width > editorScrollerRect.right) {
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset
}
top -= anchorElementRect.top
left -= anchorElementRect.left
floatingElem.style.opacity = '1'
floatingElem.style.transform = `translate(${left}px, ${top}px)`
}

View File

@@ -0,0 +1,42 @@
import {
CHECK_LIST,
ELEMENT_TRANSFORMERS,
ElementTransformer,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
} from '@lexical/markdown'
import {
HorizontalRuleNode,
$createHorizontalRuleNode,
$isHorizontalRuleNode,
} from '@lexical/react/LexicalHorizontalRuleNode'
import { LexicalNode } from 'lexical'
const HorizontalRule: ElementTransformer = {
dependencies: [HorizontalRuleNode],
export: (node: LexicalNode) => {
return $isHorizontalRuleNode(node) ? '***' : null
},
regExp: /^(---|\*\*\*|___)\s?$/,
replace: (parentNode, _1, _2, isImport) => {
const line = $createHorizontalRuleNode()
if (isImport || parentNode.getNextSibling() != null) {
parentNode.replace(line)
} else {
parentNode.insertBefore(line)
}
line.selectNext()
},
type: 'element',
}
export const MarkdownTransformers = [
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
HorizontalRule,
]

View File

@@ -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
}}
/>
</>
)
}

View File

@@ -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} />
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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)
}
}

View File

@@ -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>
)
}}
/>
</>
)
}

View File

@@ -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,
}),
)
}

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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,
}),
)
}

View File

@@ -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,
})
}

View File

@@ -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,
}),
)
}

View File

@@ -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,
}),
)
}

View File

@@ -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,
}),
)
}

View File

@@ -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,
})
}

View File

@@ -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,
})
}

View File

@@ -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()),
}),
]
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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),
}))
}

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -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)
}
}
}),
}
}

View File

@@ -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),
}
}

View File

@@ -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'),
},
]
}

View File

@@ -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),
}
}

View File

@@ -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),
}))
}

View File

@@ -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))
}
}),
}))
}

View File

@@ -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),
},
]
}

View File

@@ -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),
}
}

View File

@@ -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())
}
}),
}
}

View File

@@ -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)),
}
}

View File

@@ -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())
}
}),
}
}

View File

@@ -0,0 +1,3 @@
export function GetTableBlock(onSelect: () => void) {
return { name: 'Table', iconName: 'table', keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], onSelect }
}

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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)}
/>
)
}
}

View File

@@ -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
}

View File

@@ -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
>

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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;
}

View File

@@ -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)
}

Some files were not shown because too many files have changed in this diff Show More