mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support markdown for heading 4-6 and inline math (#4917)
* feat: support-OAuth-login * fix: optimize editor experience and fix bugs (0315)
This commit is contained in:
parent
7375349626
commit
cb617cd9d3
@ -22,7 +22,7 @@ function ViewBanner({
|
||||
<div className={'view-banner flex w-full flex-col items-center overflow-hidden'}>
|
||||
{showCover && cover && <ViewCover cover={cover} onUpdateCover={onUpdateCover} />}
|
||||
|
||||
<div className={`relative min-h-[65px] ${showCover ? 'w-[964px] min-w-0 max-w-full px-16' : ''} pt-4`}>
|
||||
<div className={`relative min-h-[65px] ${showCover ? 'w-[964px] min-w-0 max-w-full px-16' : ''} pt-12`}>
|
||||
<div
|
||||
style={{
|
||||
display: icon ? 'flex' : 'none',
|
||||
|
@ -39,14 +39,14 @@ function ViewIconGroup({ icon, onUpdateIcon, showCover, cover, onUpdateCover }:
|
||||
}, [onUpdateCover]);
|
||||
|
||||
return (
|
||||
<div className={'flex items-center py-2'}>
|
||||
<div className={'flex items-center py-1'}>
|
||||
{showAddIcon && (
|
||||
<Button onClick={onAddIcon} color={'inherit'} startIcon={<EmojiEmotionsOutlined />}>
|
||||
<Button size={'small'} onClick={onAddIcon} color={'inherit'} startIcon={<EmojiEmotionsOutlined />}>
|
||||
{t('document.plugins.cover.addIcon')}
|
||||
</Button>
|
||||
)}
|
||||
{showAddCover && (
|
||||
<Button onClick={onAddCover} color={'inherit'} startIcon={<ImageIcon />}>
|
||||
<Button size={'small'} onClick={onAddCover} color={'inherit'} startIcon={<ImageIcon />}>
|
||||
{t('document.plugins.cover.addCover')}
|
||||
</Button>
|
||||
)}
|
||||
|
@ -75,16 +75,45 @@ export const CustomEditor = {
|
||||
if (!afterPoint) return false;
|
||||
return CustomEditor.isInlineNode(editor, afterPoint);
|
||||
},
|
||||
blockEqual: (editor: ReactEditor, point: Point, anotherPoint: Point) => {
|
||||
const match = CustomEditor.getBlock(editor, point);
|
||||
const anotherMatch = CustomEditor.getBlock(editor, anotherPoint);
|
||||
|
||||
if (!match || !anotherMatch) return false;
|
||||
isMultipleBlockSelected: (editor: ReactEditor, filterEmpty = false) => {
|
||||
const { selection } = editor;
|
||||
|
||||
const [node] = match;
|
||||
const [anotherNode] = anotherMatch;
|
||||
if (!selection) return false;
|
||||
|
||||
return node === anotherNode;
|
||||
const start = selection.anchor;
|
||||
const end = selection.focus;
|
||||
const startBlock = CustomEditor.getBlock(editor, start);
|
||||
const endBlock = CustomEditor.getBlock(editor, end);
|
||||
|
||||
if (!startBlock || !endBlock) return false;
|
||||
|
||||
const [, startPath] = startBlock;
|
||||
const [, endPath] = endBlock;
|
||||
const pathIsEqual = Path.equals(startPath, endPath);
|
||||
|
||||
if (pathIsEqual) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!filterEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const notEmptyBlocks = Array.from(
|
||||
editor.nodes({
|
||||
match: (n) => {
|
||||
return (
|
||||
!Editor.isEditor(n) &&
|
||||
Element.isElement(n) &&
|
||||
n.blockId !== undefined &&
|
||||
!CustomEditor.isEmptyText(editor, n)
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return notEmptyBlocks.length > 1;
|
||||
},
|
||||
|
||||
/**
|
||||
@ -109,6 +138,10 @@ export const CustomEditor = {
|
||||
const cloneNode = CustomEditor.cloneBlock(editor, node);
|
||||
|
||||
Object.assign(cloneNode, newProperties);
|
||||
cloneNode.data = {
|
||||
...(node.data || {}),
|
||||
...(newProperties.data || {}),
|
||||
};
|
||||
|
||||
const isEmbed = editor.isEmbed(cloneNode);
|
||||
|
||||
@ -273,18 +306,35 @@ export const CustomEditor = {
|
||||
});
|
||||
},
|
||||
|
||||
toggleTodo(editor: ReactEditor, node: TodoListNode) {
|
||||
const checked = node.data.checked;
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const data = node.data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
checked: !checked,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
toggleTodo(editor: ReactEditor, at?: Location) {
|
||||
const selection = at || editor.selection;
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
if (!selection) return;
|
||||
|
||||
const nodes = Array.from(
|
||||
editor.nodes({
|
||||
at: selection,
|
||||
match: (n) => Element.isElement(n) && n.type === EditorNodeType.TodoListBlock,
|
||||
})
|
||||
);
|
||||
|
||||
const matchUnChecked = nodes.some(([node]) => {
|
||||
return !(node as TodoListNode).data.checked;
|
||||
});
|
||||
|
||||
const checked = Boolean(matchUnChecked);
|
||||
|
||||
nodes.forEach(([node, path]) => {
|
||||
const data = (node as TodoListNode).data || {};
|
||||
const newProperties = {
|
||||
data: {
|
||||
...data,
|
||||
checked: checked,
|
||||
},
|
||||
} as Partial<Element>;
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
});
|
||||
},
|
||||
|
||||
toggleToggleList(editor: ReactEditor, node: ToggleListNode) {
|
||||
|
@ -38,15 +38,15 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className?
|
||||
}
|
||||
|
||||
case EditorNodeType.ToggleListBlock:
|
||||
return t('document.plugins.toggleList');
|
||||
return t('blockPlaceholders.bulletList');
|
||||
case EditorNodeType.QuoteBlock:
|
||||
return t('editor.quote');
|
||||
return t('blockPlaceholders.quote');
|
||||
case EditorNodeType.TodoListBlock:
|
||||
return t('document.plugins.todoList');
|
||||
return t('blockPlaceholders.todoList');
|
||||
case EditorNodeType.NumberedListBlock:
|
||||
return t('document.plugins.numberedList');
|
||||
return t('blockPlaceholders.numberList');
|
||||
case EditorNodeType.BulletedListBlock:
|
||||
return t('document.plugins.bulletedList');
|
||||
return t('blockPlaceholders.bulletList');
|
||||
case EditorNodeType.HeadingBlock: {
|
||||
const level = (block as HeadingNode).data.level;
|
||||
|
||||
|
@ -100,6 +100,11 @@ function SelectLanguage({
|
||||
ref={ref}
|
||||
size={'small'}
|
||||
variant={'standard'}
|
||||
sx={{
|
||||
'& .MuiInputBase-root, & .MuiInputBase-input': {
|
||||
userSelect: 'none',
|
||||
},
|
||||
}}
|
||||
className={'w-[150px]'}
|
||||
value={language}
|
||||
onClick={() => {
|
||||
@ -115,6 +120,7 @@ function SelectLanguage({
|
||||
{open && (
|
||||
<Popover
|
||||
disableAutoFocus={true}
|
||||
disableRestoreFocus={true}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
anchorEl={ref.current}
|
||||
|
@ -35,7 +35,7 @@ function DatabaseList({
|
||||
return (
|
||||
<div className={'flex items-center text-text-title'}>
|
||||
<GridSvg className={'mr-2 h-4 w-4'} />
|
||||
<div className={'truncate'}>{item.name || t('document.title.placeholder')}</div>
|
||||
<div className={'truncate'}>{item.name.trim() || t('menuAppHeader.defaultNewPageName')}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@ -15,7 +15,7 @@ export const DividerNode = memo(
|
||||
|
||||
return (
|
||||
<div {...attributes} className={className}>
|
||||
<div contentEditable={false} className={'w-full py-2 text-line-divider'}>
|
||||
<div contentEditable={false} className={'w-full px-1 py-2 text-line-divider'}>
|
||||
<hr />
|
||||
</div>
|
||||
<div ref={ref} className={`absolute h-full w-full caret-transparent`}>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, memo, useCallback, useRef } from 'react';
|
||||
import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { EditorElementProps, ImageNode } from '$app/application/document/document.types';
|
||||
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||
import ImageRender from '$app/components/editor/components/blocks/image/ImageRender';
|
||||
@ -7,7 +7,7 @@ import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpt
|
||||
export const ImageBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<ImageNode>>(({ node, children, className, ...attributes }, ref) => {
|
||||
const selected = useSelected();
|
||||
const { url, align } = node.data;
|
||||
const { url, align } = useMemo(() => node.data || {}, [node.data]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useSlateStatic();
|
||||
const onFocusNode = useCallback(() => {
|
||||
|
@ -20,7 +20,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const editor = useSlateStatic();
|
||||
const { url = '', width: imageWidth, image_type: source } = node.data;
|
||||
const { url = '', width: imageWidth, image_type: source } = useMemo(() => node.data || {}, [node.data]);
|
||||
const { t } = useTranslation();
|
||||
const blockId = node.blockId;
|
||||
|
||||
|
@ -36,7 +36,7 @@ export function useStartIcon(node: TextNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component className={`text-block-icon relative select-all`} block={block} />;
|
||||
return <Component className={`text-block-icon relative`} block={block} />;
|
||||
}, [Component, block]);
|
||||
|
||||
return {
|
||||
|
@ -14,9 +14,9 @@ export const Text = memo(
|
||||
<span
|
||||
ref={ref}
|
||||
{...attributes}
|
||||
className={`text-element relative my-1 flex w-full px-1 ${isEmpty ? 'select-none' : ''} ${className ?? ''} ${
|
||||
hasStartIcon ? 'has-start-icon' : ''
|
||||
}`}
|
||||
className={`text-element relative my-1 flex w-full whitespace-pre-wrap px-1 ${isEmpty ? 'select-none' : ''} ${
|
||||
className ?? ''
|
||||
} ${hasStartIcon ? 'has-start-icon' : ''}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
<Placeholder isEmpty={isEmpty} node={node} />
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { TodoListNode } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { Location } from 'slate';
|
||||
import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg';
|
||||
import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg';
|
||||
|
||||
@ -9,9 +10,25 @@ function CheckboxIcon({ block, className }: { block: TodoListNode; className: st
|
||||
const editor = useSlateStatic();
|
||||
const { checked } = block.data;
|
||||
|
||||
const toggleTodo = useCallback(() => {
|
||||
CustomEditor.toggleTodo(editor, block);
|
||||
}, [editor, block]);
|
||||
const toggleTodo = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const path = ReactEditor.findPath(editor, block);
|
||||
const start = editor.start(path);
|
||||
let at: Location = start;
|
||||
|
||||
if (e.shiftKey) {
|
||||
const end = editor.end(path);
|
||||
|
||||
at = {
|
||||
anchor: start,
|
||||
focus: end,
|
||||
};
|
||||
}
|
||||
|
||||
CustomEditor.toggleTodo(editor, at);
|
||||
},
|
||||
[editor, block]
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
|
@ -3,7 +3,7 @@ import { EditorElementProps, TodoListNode } from '$app/application/document/docu
|
||||
|
||||
export const TodoList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<TodoListNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { checked } = node.data;
|
||||
const { checked = false } = useMemo(() => node.data || {}, [node.data]);
|
||||
const className = useMemo(() => {
|
||||
return `flex w-full flex-col ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
|
||||
}, [attributes.className, checked]);
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types';
|
||||
|
||||
export const ToggleList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<ToggleListNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { collapsed } = node.data;
|
||||
const { collapsed } = useMemo(() => node.data || {}, [node.data]);
|
||||
const className = `${attributes.className ?? ''} flex w-full flex-col ${collapsed ? 'collapsed' : ''}`;
|
||||
|
||||
return (
|
||||
|
@ -3,7 +3,6 @@ import { KeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||
import { BaseRange, createEditor, Editor, NodeEntry, Range, Transforms, Element } from 'slate';
|
||||
import { ReactEditor, withReact } from 'slate-react';
|
||||
import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins';
|
||||
import { withShortcuts } from 'src/appflowy_app/components/editor/plugins/shortcuts';
|
||||
import { withInlines } from '$app/components/editor/components/inline_nodes';
|
||||
import { withYHistory, withYjs, YjsEditor } from '@slate-yjs/core';
|
||||
import * as Y from 'yjs';
|
||||
@ -11,11 +10,12 @@ import { CustomEditor } from '$app/components/editor/command';
|
||||
import { CodeNode, EditorNodeType } from '$app/application/document/document.types';
|
||||
import { decorateCode } from '$app/components/editor/components/blocks/code/utils';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { withMarkdown } from '$app/components/editor/plugins/shortcuts';
|
||||
|
||||
export function useEditor(sharedType: Y.XmlText) {
|
||||
const editor = useMemo(() => {
|
||||
if (!sharedType) return null;
|
||||
const e = withShortcuts(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType))))));
|
||||
const e = withMarkdown(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType))))));
|
||||
|
||||
// Ensure editor always has at least 1 valid child
|
||||
const { normalizeNode } = e;
|
||||
|
@ -39,7 +39,7 @@ export const Link = memo(({ children }: { leaf: Text; children: React.ReactNode
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseDown={handleClick}
|
||||
className={`cursor-pointer rounded px-1 py-0.5 text-fill-default underline`}
|
||||
className={`cursor-pointer select-auto px-1 py-0.5 text-fill-default underline`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
@ -14,7 +14,7 @@ import KeyboardNavigation, {
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
|
||||
import { openUrl, pattern } from '$app/utils/open_url';
|
||||
import { openUrl, isUrl } from '$app/utils/open_url';
|
||||
|
||||
function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) {
|
||||
const editor = useSlateStatic();
|
||||
@ -59,7 +59,7 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (pattern.test(link)) {
|
||||
if (isUrl(link)) {
|
||||
onClose();
|
||||
setNodeMark();
|
||||
}
|
||||
@ -125,7 +125,7 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
|
||||
return [
|
||||
{
|
||||
key: 'open',
|
||||
disabled: !pattern.test(link),
|
||||
disabled: !isUrl(link),
|
||||
content: renderOption(<LinkSvg className={'h-4 w-4'} />, t('editor.openLink')),
|
||||
},
|
||||
{
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { pattern } from '$app/utils/open_url';
|
||||
import { isUrl } from '$app/utils/open_url';
|
||||
|
||||
function LinkEditInput({
|
||||
link,
|
||||
@ -16,7 +16,7 @@ function LinkEditInput({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pattern.test(link)) {
|
||||
if (isUrl(link)) {
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export function LinkEditPopover({
|
||||
style={{
|
||||
maxHeight: paperHeight,
|
||||
}}
|
||||
className='flex flex-col p-4'
|
||||
className='flex select-none flex-col p-4'
|
||||
>
|
||||
<LinkEditContent defaultHref={defaultHref} onClose={onClose} />
|
||||
</div>
|
||||
|
@ -132,7 +132,7 @@ export function MentionLeaf({ mention }: { mention: Mention }) {
|
||||
page && (
|
||||
<>
|
||||
{page.icon?.value || <DocumentSvg />}
|
||||
<span className={'mr-1 underline'}>{page.name || t('document.title.placeholder')}</span>
|
||||
<span className={'mr-1 underline'}>{page.name.trim() || t('menuAppHeader.defaultNewPageName')}</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
@ -49,10 +49,19 @@ export function useBlockActionsToolbar(ref: RefObject<HTMLDivElement>, contextMe
|
||||
try {
|
||||
range = ReactEditor.findEventRange(editor, e);
|
||||
} catch {
|
||||
range = findEventRange(editor, e);
|
||||
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||
|
||||
range = findEventRange(editor, {
|
||||
...e,
|
||||
clientX: e.clientX + editorDom.offsetWidth / 2,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!range) return;
|
||||
const match = editor.above({
|
||||
match: (n) => {
|
||||
return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined;
|
||||
|
@ -2,6 +2,7 @@ import { ReactEditor } from 'slate-react';
|
||||
import { getEditorDomNode, getHeadingCssProperty } from '$app/components/editor/plugins/utils';
|
||||
import { Element } from 'slate';
|
||||
import { EditorNodeType, HeadingNode } from '$app/application/document/document.types';
|
||||
import { Log } from '$app/utils/log';
|
||||
|
||||
export function getBlockActionsPosition(editor: ReactEditor, blockElement: HTMLElement) {
|
||||
const editorDom = getEditorDomNode(editor);
|
||||
@ -58,30 +59,8 @@ export function findEventRange(editor: ReactEditor, e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
if (domRange && domRange.startContainer) {
|
||||
const startContainer = domRange.startContainer;
|
||||
|
||||
let element: HTMLElement | null = startContainer as HTMLElement;
|
||||
const nodeType = element.nodeType;
|
||||
|
||||
if (nodeType === 3 || typeof element === 'string') {
|
||||
const parent = element.parentElement?.closest('.text-block-icon') as HTMLElement;
|
||||
|
||||
element = parent;
|
||||
}
|
||||
|
||||
if (element && element.nodeType < 3) {
|
||||
if (element.classList?.contains('text-block-icon')) {
|
||||
const sibling = domRange.startContainer.parentElement;
|
||||
|
||||
if (sibling) {
|
||||
domRange.selectNode(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!domRange) {
|
||||
Log.warn('Could not find a range from the caret position.');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -67,7 +67,7 @@ export function useMentionPanel({
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'flex h-5 w-5 items-center justify-center'}>{page.icon?.value || <DocumentSvg />}</div>
|
||||
|
||||
<div className={'flex-1'}>{page.name || t('document.title.placeholder')}</div>
|
||||
<div className={'flex-1'}>{page.name.trim() || t('menuAppHeader.defaultNewPageName')}</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
@ -109,7 +109,10 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
useEffect(() => {
|
||||
const decorateState = getStaticState();
|
||||
|
||||
if (decorateState) return;
|
||||
if (decorateState) {
|
||||
setIsAcrossBlocks(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = editor;
|
||||
|
||||
@ -131,10 +134,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
return;
|
||||
}
|
||||
|
||||
const start = selection.anchor;
|
||||
const end = selection.focus;
|
||||
|
||||
setIsAcrossBlocks(!CustomEditor.blockEqual(editor, start, end));
|
||||
setIsAcrossBlocks(CustomEditor.isMultipleBlockSelected(editor, true));
|
||||
debounceRecalculatePosition();
|
||||
});
|
||||
|
||||
|
@ -12,10 +12,16 @@ export function NumberedList() {
|
||||
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.NumberedListBlock);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
let type = EditorNodeType.NumberedListBlock;
|
||||
|
||||
if (isActivated) {
|
||||
type = EditorNodeType.Paragraph;
|
||||
}
|
||||
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.NumberedListBlock,
|
||||
type,
|
||||
});
|
||||
}, [editor]);
|
||||
}, [editor, isActivated]);
|
||||
|
||||
return (
|
||||
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.numberedList')}>
|
||||
|
@ -12,10 +12,16 @@ export function Quote() {
|
||||
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.QuoteBlock);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
let type = EditorNodeType.QuoteBlock;
|
||||
|
||||
if (isActivated) {
|
||||
type = EditorNodeType.Paragraph;
|
||||
}
|
||||
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.QuoteBlock,
|
||||
type,
|
||||
});
|
||||
}, [editor]);
|
||||
}, [editor, isActivated]);
|
||||
|
||||
return (
|
||||
<ActionButton active={isActivated} onClick={onClick} tooltip={t('editor.quote')}>
|
||||
|
@ -13,10 +13,19 @@ export function TodoList() {
|
||||
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.TodoListBlock);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
let type = EditorNodeType.TodoListBlock;
|
||||
|
||||
if (isActivated) {
|
||||
type = EditorNodeType.Paragraph;
|
||||
}
|
||||
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.TodoListBlock,
|
||||
type,
|
||||
data: {
|
||||
checked: false,
|
||||
},
|
||||
});
|
||||
}, [editor]);
|
||||
}, [editor, isActivated]);
|
||||
|
||||
return (
|
||||
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.todoList')}>
|
||||
|
@ -12,10 +12,19 @@ export function ToggleList() {
|
||||
const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.ToggleListBlock);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
let type = EditorNodeType.ToggleListBlock;
|
||||
|
||||
if (isActivated) {
|
||||
type = EditorNodeType.Paragraph;
|
||||
}
|
||||
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
type: EditorNodeType.ToggleListBlock,
|
||||
type,
|
||||
data: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
}, [editor]);
|
||||
}, [editor, isActivated]);
|
||||
|
||||
return (
|
||||
<ActionButton active={isActivated} onClick={onClick} tooltip={t('document.plugins.toggleList')}>
|
||||
|
@ -13,17 +13,20 @@
|
||||
|
||||
.block-element.block-align-left {
|
||||
> div > .text-element {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
.block-element.block-align-right {
|
||||
> div > .text-element {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
|
||||
}
|
||||
}
|
||||
.block-element.block-align-center {
|
||||
> div > .text-element {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@ -84,8 +87,8 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
|
||||
|
||||
.text-content, [data-dark-mode="true"] .text-content {
|
||||
@apply min-w-[1px];
|
||||
&.empty-content {
|
||||
@apply min-w-[1px];
|
||||
span {
|
||||
&::selection {
|
||||
@apply bg-transparent;
|
||||
@ -103,7 +106,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
.text-placeholder {
|
||||
|
||||
&:after {
|
||||
@apply text-text-placeholder absolute left-1.5 top-1/2 transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap;
|
||||
@apply text-text-placeholder absolute left-[5px] top-1/2 transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap;
|
||||
content: (attr(placeholder));
|
||||
}
|
||||
}
|
||||
|
@ -1,2 +1,2 @@
|
||||
export * from './shortcuts.hooks';
|
||||
export * from './withShortcuts';
|
||||
export * from './withMarkdown';
|
||||
|
@ -0,0 +1,172 @@
|
||||
export type MarkdownRegex = {
|
||||
[key in MarkdownShortcuts]: {
|
||||
pattern: RegExp;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?: Record<string, any>;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TriggerHotKey = {
|
||||
[key in MarkdownShortcuts]: string[];
|
||||
};
|
||||
|
||||
export enum MarkdownShortcuts {
|
||||
Bold,
|
||||
Italic,
|
||||
StrikeThrough,
|
||||
Code,
|
||||
Equation,
|
||||
/** block */
|
||||
Heading,
|
||||
BlockQuote,
|
||||
CodeBlock,
|
||||
Divider,
|
||||
/** list */
|
||||
BulletedList,
|
||||
NumberedList,
|
||||
TodoList,
|
||||
ToggleList,
|
||||
}
|
||||
|
||||
const defaultMarkdownRegex: MarkdownRegex = {
|
||||
[MarkdownShortcuts.Heading]: [
|
||||
{
|
||||
pattern: /^#{1,6}$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.Bold]: [
|
||||
{
|
||||
pattern: /(\*\*|__)(.*?)(\*\*|__)$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.Italic]: [
|
||||
{
|
||||
pattern: /([*_])(.*?)([*_])$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.StrikeThrough]: [
|
||||
{
|
||||
pattern: /(~~)(.*?)(~~)$/,
|
||||
},
|
||||
{
|
||||
pattern: /(~)(.*?)(~)$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.Code]: [
|
||||
{
|
||||
pattern: /(`)(.*?)(`)$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.Equation]: [
|
||||
{
|
||||
pattern: /(\$)(.*?)(\$)$/,
|
||||
data: {
|
||||
formula: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.BlockQuote]: [
|
||||
{
|
||||
pattern: /^([”“"])$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.CodeBlock]: [
|
||||
{
|
||||
pattern: /^(`{3,})$/,
|
||||
data: {
|
||||
language: 'json',
|
||||
},
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.Divider]: [
|
||||
{
|
||||
pattern: /^(([-*]){3,})$/,
|
||||
},
|
||||
],
|
||||
|
||||
[MarkdownShortcuts.BulletedList]: [
|
||||
{
|
||||
pattern: /^([*\-+])$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.NumberedList]: [
|
||||
{
|
||||
pattern: /^(\d+)\.$/,
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.TodoList]: [
|
||||
{
|
||||
pattern: /^(-)?\[ ]$/,
|
||||
data: {
|
||||
checked: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^(-)?\[x]$/,
|
||||
data: {
|
||||
checked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^(-)?\[]$/,
|
||||
data: {
|
||||
checked: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[MarkdownShortcuts.ToggleList]: [
|
||||
{
|
||||
pattern: /^>$/,
|
||||
data: {
|
||||
collapsed: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const defaultTriggerChar: TriggerHotKey = {
|
||||
[MarkdownShortcuts.Heading]: [' '],
|
||||
[MarkdownShortcuts.Bold]: ['*', '_'],
|
||||
[MarkdownShortcuts.Italic]: ['*', '_'],
|
||||
[MarkdownShortcuts.StrikeThrough]: ['~'],
|
||||
[MarkdownShortcuts.Code]: ['`'],
|
||||
[MarkdownShortcuts.BlockQuote]: [' '],
|
||||
[MarkdownShortcuts.CodeBlock]: ['`'],
|
||||
[MarkdownShortcuts.Divider]: ['-', '*'],
|
||||
[MarkdownShortcuts.Equation]: ['$'],
|
||||
[MarkdownShortcuts.BulletedList]: [' '],
|
||||
[MarkdownShortcuts.NumberedList]: [' '],
|
||||
[MarkdownShortcuts.TodoList]: [' '],
|
||||
[MarkdownShortcuts.ToggleList]: [' '],
|
||||
};
|
||||
|
||||
export function isTriggerChar(char: string) {
|
||||
return Object.values(defaultTriggerChar).some((trigger) => trigger.includes(char));
|
||||
}
|
||||
|
||||
export function whatShortcutTrigger(char: string): MarkdownShortcuts[] | null {
|
||||
const isTrigger = isTriggerChar(char);
|
||||
|
||||
if (!isTrigger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shortcuts = Object.keys(defaultTriggerChar).map((key) => Number(key) as MarkdownShortcuts);
|
||||
|
||||
return shortcuts.filter((shortcut) => defaultTriggerChar[shortcut].includes(char));
|
||||
}
|
||||
|
||||
export function getRegex(shortcut: MarkdownShortcuts) {
|
||||
return defaultMarkdownRegex[shortcut];
|
||||
}
|
||||
|
||||
export function whatShortcutsMatch(text: string) {
|
||||
const shortcuts = Object.keys(defaultMarkdownRegex).map((key) => Number(key) as MarkdownShortcuts);
|
||||
|
||||
return shortcuts.filter((shortcut) => {
|
||||
const regexes = defaultMarkdownRegex[shortcut];
|
||||
|
||||
return regexes.some((regex) => regex.pattern.test(text));
|
||||
});
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { useCallback, KeyboardEvent } from 'react';
|
||||
import { EditorNodeType, TodoListNode, ToggleListNode } from '$app/application/document/document.types';
|
||||
import { EditorNodeType, ToggleListNode } from '$app/application/document/document.types';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { getBlock } from '$app/components/editor/plugins/utils';
|
||||
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||
|
||||
/**
|
||||
* Hotkeys shortcuts
|
||||
@ -65,18 +66,18 @@ export function useShortcuts(editor: ReactEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHotkey('mod+Enter', e) && node) {
|
||||
if (node.type === EditorNodeType.TodoListBlock) {
|
||||
e.preventDefault();
|
||||
CustomEditor.toggleTodo(editor, node as TodoListNode);
|
||||
return;
|
||||
}
|
||||
if (createHotkey(HOT_KEY_NAME.TOGGLE_TODO)(e.nativeEvent)) {
|
||||
e.preventDefault();
|
||||
CustomEditor.toggleTodo(editor);
|
||||
}
|
||||
|
||||
if (node.type === EditorNodeType.ToggleListBlock) {
|
||||
e.preventDefault();
|
||||
CustomEditor.toggleToggleList(editor, node as ToggleListNode);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
createHotkey(HOT_KEY_NAME.TOGGLE_COLLAPSE)(e.nativeEvent) &&
|
||||
node &&
|
||||
node.type === EditorNodeType.ToggleListBlock
|
||||
) {
|
||||
e.preventDefault();
|
||||
CustomEditor.toggleToggleList(editor, node as ToggleListNode);
|
||||
}
|
||||
|
||||
if (isHotkey('shift+backspace', e)) {
|
||||
|
@ -0,0 +1,219 @@
|
||||
import { Range, Element, Editor, NodeEntry } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import {
|
||||
getRegex,
|
||||
MarkdownShortcuts,
|
||||
whatShortcutsMatch,
|
||||
whatShortcutTrigger,
|
||||
} from '$app/components/editor/plugins/shortcuts/markdown';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
|
||||
export const withMarkdown = (editor: ReactEditor) => {
|
||||
const { insertText } = editor;
|
||||
|
||||
editor.insertText = (char) => {
|
||||
const { selection } = editor;
|
||||
|
||||
insertText(char);
|
||||
if (!selection || !Range.isCollapsed(selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerShortcuts = whatShortcutTrigger(char);
|
||||
|
||||
if (!triggerShortcuts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = CustomEditor.getBlock(editor);
|
||||
const [node, path] = match as NodeEntry<Element>;
|
||||
const start = Editor.start(editor, path);
|
||||
const beforeRange = { anchor: start, focus: selection.anchor };
|
||||
const beforeText = Editor.string(editor, beforeRange);
|
||||
|
||||
const removeBeforeText = (beforeRange: Range) => {
|
||||
editor.deleteBackward('character');
|
||||
editor.delete({
|
||||
at: beforeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const matchBlockShortcuts = whatShortcutsMatch(beforeText);
|
||||
|
||||
for (const shortcut of matchBlockShortcuts) {
|
||||
const block = whichBlock(shortcut, beforeText);
|
||||
|
||||
// if the block shortcut is matched, remove the before text and turn to the block
|
||||
// then return
|
||||
if (block) {
|
||||
// Don't turn to the block condition
|
||||
// 1. Heading should be able to co-exist with number list
|
||||
if (block.type === EditorNodeType.NumberedListBlock && node.type === EditorNodeType.HeadingBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If the block is the same type, and data is the same
|
||||
if (block.type === node.type && isEqual(block.data || {}, node.data || {})) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeBeforeText(beforeRange);
|
||||
CustomEditor.turnToBlock(editor, block);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// get the range that matches the mark shortcuts
|
||||
const markRange = {
|
||||
anchor: Editor.start(editor, selection.anchor.path),
|
||||
focus: selection.focus,
|
||||
};
|
||||
const rangeText = Editor.string(editor, markRange) + char;
|
||||
|
||||
if (!rangeText) return;
|
||||
|
||||
// inputting a character that is start of a mark
|
||||
const isStartTyping = rangeText.indexOf(char) === rangeText.lastIndexOf(char);
|
||||
|
||||
if (isStartTyping) return;
|
||||
|
||||
// if the range text includes a double character mark, and the last one is not finished
|
||||
const doubleCharNotFinish =
|
||||
['*', '_', '~'].includes(char) &&
|
||||
rangeText.indexOf(`${char}${char}`) > -1 &&
|
||||
rangeText.indexOf(`${char}${char}`) === rangeText.lastIndexOf(`${char}${char}`);
|
||||
|
||||
if (doubleCharNotFinish) return;
|
||||
|
||||
const matchMarkShortcuts = whatShortcutsMatch(rangeText);
|
||||
|
||||
for (const shortcut of matchMarkShortcuts) {
|
||||
const item = getRegex(shortcut).find((p) => p.pattern.test(rangeText));
|
||||
const execArr = item?.pattern?.exec(rangeText);
|
||||
|
||||
const removeText = execArr ? execArr[0] : '';
|
||||
|
||||
const text = execArr ? execArr[2].replaceAll(char, '') : '';
|
||||
|
||||
if (text) {
|
||||
const index = rangeText.indexOf(removeText);
|
||||
const removeRange = {
|
||||
anchor: {
|
||||
path: markRange.anchor.path,
|
||||
offset: markRange.anchor.offset + index,
|
||||
},
|
||||
focus: {
|
||||
path: markRange.anchor.path,
|
||||
offset: markRange.anchor.offset + index + removeText.length,
|
||||
},
|
||||
};
|
||||
|
||||
removeBeforeText(removeRange);
|
||||
insertMark(editor, shortcut, text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
function whichBlock(shortcut: MarkdownShortcuts, beforeText: string) {
|
||||
switch (shortcut) {
|
||||
case MarkdownShortcuts.Heading:
|
||||
return {
|
||||
type: EditorNodeType.HeadingBlock,
|
||||
data: {
|
||||
level: beforeText.length,
|
||||
},
|
||||
};
|
||||
case MarkdownShortcuts.CodeBlock:
|
||||
return {
|
||||
type: EditorNodeType.CodeBlock,
|
||||
data: {
|
||||
language: 'json',
|
||||
},
|
||||
};
|
||||
case MarkdownShortcuts.BulletedList:
|
||||
return {
|
||||
type: EditorNodeType.BulletedListBlock,
|
||||
data: {},
|
||||
};
|
||||
case MarkdownShortcuts.NumberedList:
|
||||
return {
|
||||
type: EditorNodeType.NumberedListBlock,
|
||||
data: {},
|
||||
};
|
||||
case MarkdownShortcuts.TodoList:
|
||||
return {
|
||||
type: EditorNodeType.TodoListBlock,
|
||||
data: {
|
||||
checked: beforeText.includes('[x]'),
|
||||
},
|
||||
};
|
||||
case MarkdownShortcuts.BlockQuote:
|
||||
return {
|
||||
type: EditorNodeType.QuoteBlock,
|
||||
data: {},
|
||||
};
|
||||
case MarkdownShortcuts.Divider:
|
||||
return {
|
||||
type: EditorNodeType.DividerBlock,
|
||||
data: {},
|
||||
};
|
||||
|
||||
case MarkdownShortcuts.ToggleList:
|
||||
return {
|
||||
type: EditorNodeType.ToggleListBlock,
|
||||
data: {
|
||||
collapsed: false,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function insertMark(editor: ReactEditor, shortcut: MarkdownShortcuts, text: string) {
|
||||
switch (shortcut) {
|
||||
case MarkdownShortcuts.Bold:
|
||||
case MarkdownShortcuts.Italic:
|
||||
case MarkdownShortcuts.StrikeThrough:
|
||||
case MarkdownShortcuts.Code: {
|
||||
const textNode = {
|
||||
text,
|
||||
};
|
||||
const attributes = {
|
||||
[MarkdownShortcuts.Bold]: {
|
||||
[EditorMarkFormat.Bold]: true,
|
||||
},
|
||||
[MarkdownShortcuts.Italic]: {
|
||||
[EditorMarkFormat.Italic]: true,
|
||||
},
|
||||
[MarkdownShortcuts.StrikeThrough]: {
|
||||
[EditorMarkFormat.StrikeThrough]: true,
|
||||
},
|
||||
[MarkdownShortcuts.Code]: {
|
||||
[EditorMarkFormat.Code]: true,
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(textNode, attributes[shortcut]);
|
||||
|
||||
editor.insertNodes(textNode);
|
||||
return;
|
||||
}
|
||||
|
||||
case MarkdownShortcuts.Equation: {
|
||||
CustomEditor.insertFormula(editor, text);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
@ -1,352 +0,0 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Range, Element as SlateElement, Transforms } from 'slate';
|
||||
import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
|
||||
/**
|
||||
* Markdown shortcuts
|
||||
* @description
|
||||
* - bold: **bold** or __bold__
|
||||
* - italic: *italic* or _italic_
|
||||
* - strikethrough: ~~strikethrough~~ or ~strikethrough~
|
||||
* - code: `code`
|
||||
* - heading: # or ## or ###
|
||||
* - bulleted list: * or - or +
|
||||
* - number list: 1. or 2. or 3.
|
||||
* - toggle list: >
|
||||
* - quote: ” or “ or "
|
||||
* - todo list: -[ ] or -[x] or -[] or [] or [x] or [ ]
|
||||
* - code block: ```
|
||||
* - callout: [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
|
||||
* - divider: ---or***
|
||||
* - equation: $$formula$$
|
||||
*/
|
||||
|
||||
const regexMap: Record<
|
||||
string,
|
||||
{
|
||||
pattern: RegExp;
|
||||
data?: Record<string, unknown>;
|
||||
}[]
|
||||
> = {
|
||||
[EditorNodeType.BulletedListBlock]: [
|
||||
{
|
||||
pattern: /^([*\-+])$/,
|
||||
},
|
||||
],
|
||||
[EditorNodeType.ToggleListBlock]: [
|
||||
{
|
||||
pattern: /^>$/,
|
||||
data: {
|
||||
collapsed: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[EditorNodeType.QuoteBlock]: [
|
||||
{
|
||||
pattern: /^”$/,
|
||||
},
|
||||
{
|
||||
pattern: /^“$/,
|
||||
},
|
||||
{
|
||||
pattern: /^"$/,
|
||||
},
|
||||
],
|
||||
[EditorNodeType.TodoListBlock]: [
|
||||
{
|
||||
pattern: /^(-)?\[ ]$/,
|
||||
data: {
|
||||
checked: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^(-)?\[x]$/,
|
||||
data: {
|
||||
checked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^(-)?\[]$/,
|
||||
data: {
|
||||
checked: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[EditorNodeType.NumberedListBlock]: [
|
||||
{
|
||||
pattern: /^(\d+)\.$/,
|
||||
},
|
||||
],
|
||||
[EditorNodeType.HeadingBlock]: [
|
||||
{
|
||||
pattern: /^#$/,
|
||||
data: {
|
||||
level: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^#{2}$/,
|
||||
data: {
|
||||
level: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^#{3}$/,
|
||||
data: {
|
||||
level: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
[EditorNodeType.CodeBlock]: [
|
||||
{
|
||||
pattern: /^(`{3,})$/,
|
||||
data: {
|
||||
language: 'json',
|
||||
},
|
||||
},
|
||||
],
|
||||
[EditorNodeType.CalloutBlock]: [
|
||||
{
|
||||
pattern: /^\[!TIP]$/,
|
||||
data: {
|
||||
icon: '💡',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\[!INFO]$/,
|
||||
data: {
|
||||
icon: 'ℹ️',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\[!WARNING]$/,
|
||||
data: {
|
||||
icon: '⚠️',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\[!DANGER]$/,
|
||||
data: {
|
||||
icon: '🚨',
|
||||
},
|
||||
},
|
||||
],
|
||||
[EditorNodeType.DividerBlock]: [
|
||||
{
|
||||
pattern: /^(([-*]){3,})$/,
|
||||
},
|
||||
],
|
||||
[EditorNodeType.EquationBlock]: [
|
||||
{
|
||||
pattern: /^\$\$(.*)\$\$$/,
|
||||
data: {
|
||||
formula: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockCommands = [' ', '-', '`', '$', '*'];
|
||||
|
||||
const CharToMarkTypeMap: Record<string, EditorMarkFormat> = {
|
||||
'**': EditorMarkFormat.Bold,
|
||||
__: EditorMarkFormat.Bold,
|
||||
'*': EditorMarkFormat.Italic,
|
||||
_: EditorMarkFormat.Italic,
|
||||
'~': EditorMarkFormat.StrikeThrough,
|
||||
'~~': EditorMarkFormat.StrikeThrough,
|
||||
'`': EditorMarkFormat.Code,
|
||||
};
|
||||
|
||||
const inlineBlockCommands = ['*', '_', '~', '`'];
|
||||
const doubleCharCommands = ['*', '_', '~'];
|
||||
|
||||
const matchBlockShortcutType = (beforeText: string, endChar: string) => {
|
||||
// end with divider char: -
|
||||
if (endChar === '-' || endChar === '*') {
|
||||
const dividerRegex = regexMap[EditorNodeType.DividerBlock][0];
|
||||
|
||||
return dividerRegex.pattern.test(beforeText + endChar)
|
||||
? {
|
||||
type: EditorNodeType.DividerBlock,
|
||||
data: {},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// end with code block char: `
|
||||
if (endChar === '`') {
|
||||
const codeBlockRegex = regexMap[EditorNodeType.CodeBlock][0];
|
||||
|
||||
return codeBlockRegex.pattern.test(beforeText + endChar)
|
||||
? {
|
||||
type: EditorNodeType.CodeBlock,
|
||||
data: codeBlockRegex.data,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (endChar === '$') {
|
||||
const equationBlockRegex = regexMap[EditorNodeType.EquationBlock][0];
|
||||
|
||||
const match = equationBlockRegex.pattern.exec(beforeText + endChar);
|
||||
|
||||
const formula = match?.[1];
|
||||
|
||||
return equationBlockRegex.pattern.test(beforeText + endChar)
|
||||
? {
|
||||
type: EditorNodeType.EquationBlock,
|
||||
data: {
|
||||
formula,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
for (const [type, regexes] of Object.entries(regexMap)) {
|
||||
for (const regex of regexes) {
|
||||
if (regex.pattern.test(beforeText)) {
|
||||
return {
|
||||
type,
|
||||
data: regex.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const withMarkdownShortcuts = (editor: ReactEditor) => {
|
||||
const { insertText } = editor;
|
||||
|
||||
editor.insertText = (text) => {
|
||||
if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const { selection } = editor;
|
||||
|
||||
if (!selection || !Range.isCollapsed(selection)) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// block shortcuts
|
||||
if (blockCommands.some((char) => text.endsWith(char))) {
|
||||
const endChar = text.slice(-1);
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorNodeType.Text,
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path] = match;
|
||||
|
||||
const { anchor } = selection;
|
||||
const start = Editor.start(editor, path);
|
||||
const range = { anchor, focus: start };
|
||||
const beforeText = Editor.string(editor, range) + text.slice(0, -1);
|
||||
|
||||
if (beforeText === undefined) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchItem = matchBlockShortcutType(beforeText, endChar);
|
||||
|
||||
if (matchItem) {
|
||||
const { type, data } = matchItem;
|
||||
|
||||
Transforms.select(editor, range);
|
||||
|
||||
if (!Range.isCollapsed(range)) {
|
||||
Transforms.delete(editor);
|
||||
}
|
||||
|
||||
const newProperties: Partial<SlateElement> = {
|
||||
type,
|
||||
data,
|
||||
};
|
||||
|
||||
CustomEditor.turnToBlock(editor, newProperties);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// inline shortcuts
|
||||
// end with inline mark char: * or _ or ~ or `
|
||||
// eg: **bold** or *italic* or ~strikethrough~ or `code` or _italic_ or __bold__ or ~~strikethrough~~
|
||||
const keyword = inlineBlockCommands.find((char) => text.endsWith(char));
|
||||
|
||||
if (keyword !== undefined) {
|
||||
const { focus } = selection;
|
||||
const start = {
|
||||
path: focus.path,
|
||||
offset: 0,
|
||||
};
|
||||
const range = { anchor: start, focus };
|
||||
|
||||
const rangeText = Editor.string(editor, range);
|
||||
|
||||
if (!rangeText.includes(keyword)) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullText = rangeText + keyword;
|
||||
|
||||
let matchChar = keyword;
|
||||
|
||||
if (doubleCharCommands.includes(keyword)) {
|
||||
const doubleKeyword = `${keyword}${keyword}`;
|
||||
|
||||
if (rangeText.includes(doubleKeyword)) {
|
||||
const match = fullText.match(new RegExp(`\\${keyword}{2}(.*)\\${keyword}{2}`));
|
||||
|
||||
if (!match) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
matchChar = doubleKeyword;
|
||||
}
|
||||
}
|
||||
|
||||
const markType = CharToMarkTypeMap[matchChar];
|
||||
|
||||
const startIndex = rangeText.lastIndexOf(matchChar);
|
||||
const beforeText = rangeText.slice(startIndex + matchChar.length, matchChar.length > 1 ? -1 : undefined);
|
||||
|
||||
if (!beforeText) {
|
||||
insertText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = { path: start.path, offset: start.offset + startIndex };
|
||||
|
||||
const at = {
|
||||
anchor,
|
||||
focus,
|
||||
};
|
||||
|
||||
editor.select(at);
|
||||
editor.addMark(markType, true);
|
||||
editor.insertText(beforeText);
|
||||
editor.collapse({
|
||||
edge: 'end',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
insertText(text);
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
@ -1,6 +0,0 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { withMarkdownShortcuts } from '$app/components/editor/plugins/shortcuts/withMarkdownShortcuts';
|
||||
|
||||
export function withShortcuts(editor: ReactEditor) {
|
||||
return withMarkdownShortcuts(editor);
|
||||
}
|
@ -10,6 +10,12 @@ export function getHeadingCssProperty(level: number) {
|
||||
return 'text-2xl pt-[8px] pb-[6px] font-bold';
|
||||
case 3:
|
||||
return 'text-xl pt-[4px] font-bold';
|
||||
case 4:
|
||||
return 'text-lg pt-[4px] font-bold';
|
||||
case 5:
|
||||
return 'text-base pt-[4px] font-bold';
|
||||
case 6:
|
||||
return 'text-sm pt-[4px] font-bold';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { EditorNodeType } from '$app/application/document/document.types';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { Path } from 'slate';
|
||||
import { Path, Transforms } from 'slate';
|
||||
import { YjsEditor } from '@slate-yjs/core';
|
||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
|
||||
export function withBlockInsertBreak(editor: ReactEditor) {
|
||||
const { insertBreak } = editor;
|
||||
@ -16,9 +17,9 @@ export function withBlockInsertBreak(editor: ReactEditor) {
|
||||
|
||||
const isEmbed = editor.isEmbed(node);
|
||||
|
||||
if (isEmbed) {
|
||||
const nextPath = Path.next(path);
|
||||
const nextPath = Path.next(path);
|
||||
|
||||
if (isEmbed) {
|
||||
CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath);
|
||||
editor.select(nextPath);
|
||||
return;
|
||||
@ -26,11 +27,63 @@ export function withBlockInsertBreak(editor: ReactEditor) {
|
||||
|
||||
const type = node.type as EditorNodeType;
|
||||
|
||||
const isBeginning = CustomEditor.focusAtStartOfBlock(editor);
|
||||
|
||||
const isEmpty = CustomEditor.isEmptyText(editor, node);
|
||||
|
||||
// if the node is empty, convert it to a paragraph
|
||||
if (isEmpty && type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) {
|
||||
CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph });
|
||||
if (isEmpty) {
|
||||
const depth = path.length;
|
||||
let hasNextNode = false;
|
||||
|
||||
try {
|
||||
hasNextNode = Boolean(editor.node(nextPath));
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// if the node is empty and the depth is greater than 1, tab backward
|
||||
if (depth > 1 && !hasNextNode) {
|
||||
CustomEditor.tabBackward(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the node is empty, convert it to a paragraph
|
||||
if (type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) {
|
||||
CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph });
|
||||
return;
|
||||
}
|
||||
} else if (isBeginning) {
|
||||
// insert line below the current block
|
||||
const newNodeType = [
|
||||
EditorNodeType.TodoListBlock,
|
||||
EditorNodeType.BulletedListBlock,
|
||||
EditorNodeType.NumberedListBlock,
|
||||
].includes(type)
|
||||
? type
|
||||
: EditorNodeType.Paragraph;
|
||||
|
||||
Transforms.insertNodes(
|
||||
editor,
|
||||
{
|
||||
type: newNodeType,
|
||||
data: node.data ?? {},
|
||||
blockId: generateId(),
|
||||
children: [
|
||||
{
|
||||
type: EditorNodeType.Text,
|
||||
textId: generateId(),
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
at: path,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -35,9 +35,9 @@ function Breadcrumb() {
|
||||
{pagePath?.map((page: Page, index) => {
|
||||
if (index === pagePath.length - 1) {
|
||||
return (
|
||||
<div key={page.id} className={'flex select-none gap-1 text-text-title'}>
|
||||
<div className={'select-none'}>{getPageIcon(page)}</div>
|
||||
{page.name || t('menuAppHeader.defaultNewPageName')}
|
||||
<div key={page.id} className={'flex cursor-default select-none gap-1 text-text-title'}>
|
||||
<div>{getPageIcon(page)}</div>
|
||||
{page.name.trim() || t('menuAppHeader.defaultNewPageName')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -54,7 +54,7 @@ function Breadcrumb() {
|
||||
>
|
||||
<div>{getPageIcon(page)}</div>
|
||||
|
||||
{page.name || t('document.title.placeholder')}
|
||||
{page.name.trim() || t('menuAppHeader.defaultNewPageName')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
@ -76,7 +76,7 @@ function NestedPageTitle({
|
||||
{pageIcon}
|
||||
|
||||
<div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>
|
||||
{page?.name || t('menuAppHeader.defaultNewPageName')}
|
||||
{page?.name.trim() || t('menuAppHeader.defaultNewPageName')}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end px-2'}>
|
||||
|
@ -39,6 +39,9 @@ export function useLoadTrash() {
|
||||
export function useTrashActions() {
|
||||
const [restoreAllDialogOpen, setRestoreAllDialogOpen] = useState(false);
|
||||
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const [deleteId, setDeleteId] = useState('');
|
||||
|
||||
const onClickRestoreAll = () => {
|
||||
setRestoreAllDialogOpen(true);
|
||||
@ -51,9 +54,18 @@ export function useTrashActions() {
|
||||
const closeDialog = () => {
|
||||
setRestoreAllDialogOpen(false);
|
||||
setDeleteAllDialogOpen(false);
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const onClickDelete = (id: string) => {
|
||||
setDeleteId(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
return {
|
||||
onClickDelete,
|
||||
deleteDialogOpen,
|
||||
deleteId,
|
||||
onPutback: putback,
|
||||
onDelete: deleteTrashItem,
|
||||
onDeleteAll: deleteAll,
|
||||
|
@ -20,6 +20,9 @@ function Trash() {
|
||||
onRestoreAll,
|
||||
onDeleteAll,
|
||||
closeDialog,
|
||||
deleteDialogOpen,
|
||||
deleteId,
|
||||
onClickDelete,
|
||||
} = useTrashActions();
|
||||
const [hoverId, setHoverId] = useState('');
|
||||
|
||||
@ -50,7 +53,7 @@ function Trash() {
|
||||
item={item}
|
||||
key={item.id}
|
||||
onPutback={onPutback}
|
||||
onDelete={onDelete}
|
||||
onDelete={onClickDelete}
|
||||
hoverId={hoverId}
|
||||
setHoverId={setHoverId}
|
||||
/>
|
||||
@ -62,6 +65,7 @@ function Trash() {
|
||||
subtitle={t('trash.confirmRestoreAll.caption')}
|
||||
onOk={onRestoreAll}
|
||||
onClose={closeDialog}
|
||||
okText={t('trash.restoreAll')}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
open={deleteAllDialogOpen}
|
||||
@ -70,6 +74,12 @@ function Trash() {
|
||||
onOk={onDeleteAll}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
title={t('trash.confirmDeleteTitle')}
|
||||
onOk={() => onDelete([deleteId])}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ function TrashItem({
|
||||
item: Trash;
|
||||
hoverId: string;
|
||||
onPutback: (id: string) => void;
|
||||
onDelete: (ids: string[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -35,7 +35,9 @@ function TrashItem({
|
||||
}}
|
||||
>
|
||||
<div className={'flex w-[100%] items-center justify-around gap-2 rounded-lg p-2 text-xs hover:bg-fill-list-hover'}>
|
||||
<div className={'w-[40%] whitespace-break-spaces text-left'}>{item.name || t('document.title.placeholder')}</div>
|
||||
<div className={'w-[40%] whitespace-break-spaces text-left'}>
|
||||
{item.name.trim() || t('menuAppHeader.defaultNewPageName')}
|
||||
</div>
|
||||
<div className={'flex-1'}>{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}</div>
|
||||
<div className={'flex-1'}>{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}</div>
|
||||
<div
|
||||
@ -50,7 +52,7 @@ function TrashItem({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip placement={'top-start'} title={t('button.delete')}>
|
||||
<IconButton size={'small'} color={'error'} onClick={(_) => onDelete([item.id])}>
|
||||
<IconButton size={'small'} color={'error'} onClick={(_) => onDelete(item.id)}>
|
||||
<DeleteOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
@ -22,40 +22,62 @@ export enum HOT_KEY_NAME {
|
||||
UNDERLINE = 'underline',
|
||||
STRIKETHROUGH = 'strikethrough',
|
||||
CODE = 'code',
|
||||
TOGGLE_TODO = 'toggle-todo',
|
||||
TOGGLE_COLLAPSE = 'toggle-collapse',
|
||||
}
|
||||
|
||||
const defaultHotKeys = {
|
||||
[HOT_KEY_NAME.ALIGN_LEFT]: 'control+shift+l',
|
||||
[HOT_KEY_NAME.ALIGN_CENTER]: 'control+shift+e',
|
||||
[HOT_KEY_NAME.ALIGN_RIGHT]: 'control+shift+r',
|
||||
[HOT_KEY_NAME.BOLD]: 'mod+b',
|
||||
[HOT_KEY_NAME.ITALIC]: 'mod+i',
|
||||
[HOT_KEY_NAME.UNDERLINE]: 'mod+u',
|
||||
[HOT_KEY_NAME.STRIKETHROUGH]: 'mod+shift+s',
|
||||
[HOT_KEY_NAME.CODE]: 'mod+shift+c',
|
||||
[HOT_KEY_NAME.ALIGN_LEFT]: ['control+shift+l'],
|
||||
[HOT_KEY_NAME.ALIGN_CENTER]: ['control+shift+e'],
|
||||
[HOT_KEY_NAME.ALIGN_RIGHT]: ['control+shift+r'],
|
||||
[HOT_KEY_NAME.BOLD]: ['mod+b'],
|
||||
[HOT_KEY_NAME.ITALIC]: ['mod+i'],
|
||||
[HOT_KEY_NAME.UNDERLINE]: ['mod+u'],
|
||||
[HOT_KEY_NAME.STRIKETHROUGH]: ['mod+shift+s', 'mod+shift+x'],
|
||||
[HOT_KEY_NAME.CODE]: ['mod+e'],
|
||||
[HOT_KEY_NAME.TOGGLE_TODO]: ['mod+enter'],
|
||||
[HOT_KEY_NAME.TOGGLE_COLLAPSE]: ['mod+enter'],
|
||||
};
|
||||
|
||||
const replaceModifier = (hotkey: string) => {
|
||||
return hotkey.replace('mod', getModifier()).replace('control', 'ctrl');
|
||||
};
|
||||
|
||||
export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string>) => {
|
||||
/**
|
||||
* Create a hotkey checker.
|
||||
* @example trigger strike through when user press "Cmd + Shift + S" or "Cmd + Shift + X"
|
||||
* @param hotkeyName
|
||||
* @param customHotKeys
|
||||
*/
|
||||
export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string[]>) => {
|
||||
const keys = customHotKeys || defaultHotKeys;
|
||||
const hotkey = keys[hotkeyName];
|
||||
const hotkeys = keys[hotkeyName];
|
||||
|
||||
return (event: KeyboardEvent) => {
|
||||
return isHotkey(hotkey, event);
|
||||
return hotkeys.some((hotkey) => {
|
||||
return isHotkey(hotkey, event);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string>) => {
|
||||
/**
|
||||
* Create a hotkey label.
|
||||
* eg. "Ctrl + B / ⌘ + B"
|
||||
* @param hotkeyName
|
||||
* @param customHotKeys
|
||||
*/
|
||||
export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string[]>) => {
|
||||
const keys = customHotKeys || defaultHotKeys;
|
||||
const hotkey = replaceModifier(keys[hotkeyName]);
|
||||
const hotkeys = keys[hotkeyName].map((key) => replaceModifier(key));
|
||||
|
||||
return hotkey
|
||||
.split('+')
|
||||
.map((key) => {
|
||||
return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1);
|
||||
})
|
||||
.join(' + ');
|
||||
return hotkeys
|
||||
.map((hotkey) =>
|
||||
hotkey
|
||||
.split('+')
|
||||
.map((key) => {
|
||||
return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1);
|
||||
})
|
||||
.join(' + ')
|
||||
)
|
||||
.join(' / ');
|
||||
};
|
||||
|
@ -1,9 +1,14 @@
|
||||
import { open as openWindow } from '@tauri-apps/api/shell';
|
||||
|
||||
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/;
|
||||
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/;
|
||||
const ipPattern = /^(https?:\/\/)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d{1,5})?$/;
|
||||
|
||||
export function isUrl(str: string) {
|
||||
return urlPattern.test(str) || ipPattern.test(str);
|
||||
}
|
||||
|
||||
export function openUrl(str: string) {
|
||||
if (pattern.test(str)) {
|
||||
if (isUrl(str)) {
|
||||
const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:'];
|
||||
|
||||
if (linkPrefix.some((prefix) => str.startsWith(prefix))) {
|
||||
|
@ -144,7 +144,8 @@
|
||||
"emptyDescription": "You don't have any deleted file",
|
||||
"isDeleted": "is deleted",
|
||||
"isRestored": "is restored"
|
||||
}
|
||||
},
|
||||
"confirmDeleteTitle": "Are you sure you want to delete this page permanently?"
|
||||
},
|
||||
"deletePagePrompt": {
|
||||
"text": "This page is in Trash",
|
||||
|
@ -107,7 +107,6 @@ pub const TEXT_DECORATION: &str = "text-decoration";
|
||||
pub const BACKGROUND_COLOR: &str = "background-color";
|
||||
|
||||
pub const TRANSPARENT: &str = "transparent";
|
||||
pub const DEFAULT_FONT_COLOR: &str = "rgb(0, 0, 0)";
|
||||
pub const COLOR: &str = "color";
|
||||
pub const LINE_THROUGH: &str = "line-through";
|
||||
|
||||
|
@ -428,10 +428,6 @@ fn get_attributes_with_style(style: &str) -> HashMap<String, Value> {
|
||||
attributes.insert(BG_COLOR.to_string(), Value::String(value.to_string()));
|
||||
},
|
||||
COLOR => {
|
||||
if value.eq(DEFAULT_FONT_COLOR) {
|
||||
continue;
|
||||
}
|
||||
|
||||
attributes.insert(FONT_COLOR.to_string(), Value::String(value.to_string()));
|
||||
},
|
||||
_ => {},
|
||||
|
@ -2,7 +2,10 @@
|
||||
"type": "page",
|
||||
"data": {
|
||||
"delta": [{
|
||||
"insert": "This is a paragraph"
|
||||
"insert": "This is a paragraph",
|
||||
"attributes": {
|
||||
"font_color": "rgb(0, 0, 0)"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"children": []
|
||||
|
Loading…
Reference in New Issue
Block a user