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:
Kilu.He 2024-03-18 18:42:19 +08:00 committed by GitHub
parent 7375349626
commit cb617cd9d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 745 additions and 508 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
export * from './shortcuts.hooks';
export * from './withShortcuts';
export * from './withMarkdown';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()));
},
_ => {},

View File

@ -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": []