refactor: inner blocks note links (#1973)

This commit is contained in:
Mo
2022-11-09 06:06:46 -06:00
committed by GitHub
parent 2438443e53
commit 6bcba01dab
121 changed files with 1641 additions and 130 deletions

View File

@@ -25,6 +25,9 @@ import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin';
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
import CodeHighlightPlugin from '../Lexical/Plugins/CodeHighlightPlugin';
import FloatingTextFormatToolbarPlugin from '../Lexical/Plugins/FloatingTextFormatToolbarPlugin';
import FloatingLinkEditorPlugin from '../Lexical/Plugins/FloatingLinkEditorPlugin';
const BlockDragEnabled = false;
@@ -88,12 +91,20 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<AutoFocusPlugin />
<ClearEditorPlugin />
<CheckListPlugin />
<CodeHighlightPlugin />
<LinkPlugin />
<HashtagPlugin />
<AutoEmbedPlugin />
<TwitterPlugin />
<YouTubePlugin />
<CollapsiblePlugin />
{floatingAnchorElem && (
<>
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin />
</>
)}
{floatingAnchorElem && BlockDragEnabled && (
<>{<DraggableBlockPlugin anchorElem={floatingAnchorElem} />}</>
)}

View File

@@ -1,19 +0,0 @@
const classNames = (...values: (string | boolean | undefined)[]): string => {
return values
.map((value) => (typeof value === 'string' ? value : null))
.join(' ');
};
export const PopoverClassNames = classNames(
'typeahead-popover file-picker-menu absolute z-dropdown-menu flex w-full min-w-80',
'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto md:max-w-xs h-auto overflow-y-scroll',
);
export const PopoverItemClassNames = classNames(
'flex w-full items-center text-base gap-4 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

@@ -1,5 +0,0 @@
import {createCommand, LexicalCommand} from 'lexical';
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_FILE_COMMAND',
);

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

@@ -8,8 +8,8 @@
*/
.Collapsible__container {
background: #fcfcfc;
border: 1px solid #eee;
background: var(--sn-stylekit-contrast-background-color);
border: 1px solid var(--sn-stylekit-contrast-border-color);
border-radius: 10px;
margin-bottom: 8px;
}
@@ -44,7 +44,7 @@
.Collapsible__container[open] .Collapsible__title:before {
border-color: transparent;
border-width: 6px 4px 0 4px;
border-top-color: #000;
border-top-color: var(--sn-stylekit-contrast-color);
}
.Collapsible__content {

View File

@@ -0,0 +1,40 @@
.link-editor {
position: absolute;
top: 0;
left: 0;
z-index: 10;
max-width: 400px;
width: 100%;
opacity: 0;
background-color: #fff;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
transition: opacity 0.5s;
will-change: transform;
}
.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,258 @@
/**
* 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 {$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 '../../UI/LinkPreview';
import {getSelectedNode} from '../../Utils/getSelectedNode';
import {sanitizeUrl} from '../../Utils/sanitizeUrl';
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
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 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();
}
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);
}}
/>
</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);
// We don't want this menu to open for auto links.
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,129 @@
.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);
color: var(--sn-stylekit-contrast-color);
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
transition: opacity 0.5s;
height: 35px;
will-change: transform;
}
.floating-text-format-popup button.popup-item {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 8px;
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: #eee;
}
.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,332 @@
/**
* 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} from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {getDOMRangeRect} from '../../Utils/getDOMRangeRect';
import {getSelectedNode} from '../../Utils/getSelectedNode';
import {setFloatingElemPosition} from '../../Utils/setFloatingElemPosition';
import {
TypeItalic,
TypeStrikethrough,
TypeSubscript,
TypeSuperscript,
TypeUnderline,
TypeBold,
LexicalCode,
LexicalLink,
} from '@standardnotes/icons';
function TextFormatFloatingToolbar({
editor,
anchorElem,
isLink,
isBold,
isItalic,
isUnderline,
isCode,
isStrikethrough,
isSubscript,
isSuperscript,
}: {
editor: LexicalEditor;
anchorElem: HTMLElement;
isBold: boolean;
isCode: boolean;
isItalic: boolean;
isLink: boolean;
isStrikethrough: boolean;
isSubscript: boolean;
isSuperscript: boolean;
isUnderline: boolean;
}): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const insertLink = useCallback(() => {
if (!isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
}, [editor, isLink]);
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">
<TypeBold />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format text as italics">
<TypeItalic />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format text to underlined">
<TypeUnderline />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format text with a strikethrough">
<TypeStrikethrough />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
}}
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
title="Subscript"
aria-label="Format Subscript">
<TypeSubscript />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
}}
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
title="Superscript"
aria-label="Format Superscript">
<TypeSuperscript />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
}}
className={'popup-item spaced ' + (isCode ? 'active' : '')}
aria-label="Insert code block">
<LexicalCode />
</button>
<button
onClick={insertLink}
className={'popup-item spaced ' + (isLink ? 'active' : '')}
aria-label="Insert link">
<LexicalLink />
</button>
</>
)}
</div>
);
}
function useFloatingTextFormatToolbar(
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null {
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 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();
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))
) {
setIsText(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
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]);
useEffect(() => {
document.addEventListener('selectionchange', updatePopup);
return () => {
document.removeEventListener('selectionchange', updatePopup);
};
}, [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}
/>,
anchorElem,
);
}
export default function FloatingTextFormatToolbarPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem);
}

View File

@@ -1219,7 +1219,7 @@ body {
content: '';
display: block;
height: 2px;
background-color: #ccc;
background-color: var(--sn-stylekit-contrast-border-color);
line-height: 2px;
}

View File

@@ -61,10 +61,12 @@
vertical-align: super;
}
.Lexical__textCode {
background-color: rgb(240, 242, 245);
padding: 1px 0.25rem;
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: 94%;
font-size: 85%;
}
.Lexical__hashtag {
background-color: rgba(88, 144, 255, 0.15);
@@ -78,7 +80,7 @@
text-decoration: underline;
}
.Lexical__code {
background-color: rgb(240, 242, 245);
background-color: var(--sn-stylekit-contrast-background-color);
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 8px 8px 52px;
@@ -95,12 +97,12 @@
.Lexical__code:before {
content: attr(data-gutter);
position: absolute;
background-color: #eee;
background-color: var(--sn-stylekit-secondary-background-color);
left: 0;
top: 0;
border-right: 1px solid #ccc;
border-right: 1px solid var(--sn-stylekit-contrast-border-color);
padding: 8px;
color: #777;
color: var(--sn-stylekit-info-color);
white-space: pre-wrap;
text-align: right;
min-width: 25px;
@@ -113,12 +115,13 @@
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 #bbb;
border: 1px solid var(--sn-stylekit-border-color);
min-width: 75px;
vertical-align: top;
text-align: start;
@@ -147,7 +150,8 @@
top: 0;
}
.Lexical__tableCellHeader {
background-color: #f2f3f5;
background-color: var(--sn-stylekit-contrast-background-color);
border-color: var(--sn-stylekit-contrast-border-color);
text-align: start;
}
.Lexical__tableCellSelected {
@@ -306,7 +310,7 @@
list-style-position: inside;
}
.Lexical__listItem {
margin: 0 32px;
margin: 0 0px;
}
.Lexical__listItemChecked,
.Lexical__listItemUnchecked {
@@ -317,6 +321,7 @@
padding-right: 24px;
list-style-type: none;
outline: none;
vertical-align: middle;
}
.Lexical__listItemChecked {
text-decoration: line-through;
@@ -326,10 +331,9 @@
content: '';
width: 16px;
height: 16px;
top: 5px;
left: 0;
top: 5px;
cursor: pointer;
display: block;
background-size: cover;
position: absolute;
}

View File

@@ -81,42 +81,10 @@ i.bucket {
background-image: url(#{$blocks-editor-icons-path}/paint-bucket.svg);
}
i.bold {
background-image: url(#{$blocks-editor-icons-path}/type-bold.svg);
}
i.italic {
background-image: url(#{$blocks-editor-icons-path}/type-italic.svg);
}
i.clear {
background-image: url(#{$blocks-editor-icons-path}/trash.svg);
}
i.code {
background-image: url(#{$blocks-editor-icons-path}/code.svg);
}
i.underline {
background-image: url(#{$blocks-editor-icons-path}/type-underline.svg);
}
i.strikethrough {
background-image: url(#{$blocks-editor-icons-path}/type-strikethrough.svg);
}
i.subscript {
background-image: url(#{$blocks-editor-icons-path}/type-subscript.svg);
}
i.superscript {
background-image: url(#{$blocks-editor-icons-path}/type-superscript.svg);
}
i.link {
background-image: url(#{$blocks-editor-icons-path}/link.svg);
}
i.horizontal-rule {
background-image: url(#{$blocks-editor-icons-path}/horizontal-rule.svg);
}

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,117 @@
/**
* 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,27 @@
/**
* 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,27 @@
/**
* 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,23 @@
/**
* 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

@@ -1,4 +1,2 @@
export * from './Editor/BlocksEditor';
export * from './Editor/BlocksEditorComposer';
export * from './Editor/Commands';
export * from './Editor/ClassNames';