mirror of https://github.com/fantasticit/think.git
feat: imporve editor mobile ux
This commit is contained in:
parent
ef61f1bdf3
commit
12379571cb
|
@ -1,9 +1,12 @@
|
||||||
export function debounce(func, timeout = 300) {
|
export function debounce(func, timeout = 200) {
|
||||||
let timer;
|
let timer;
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
|
if (!timer) {
|
||||||
|
func.apply(this, args);
|
||||||
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
func.apply(this, args);
|
timer = undefined;
|
||||||
}, timeout);
|
}, timeout);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function isIOS() {
|
||||||
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||||
|
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAndroid() {
|
||||||
|
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||||
|
return /android/i.test(userAgent);
|
||||||
|
}
|
|
@ -114,24 +114,16 @@ export class LRUCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const USED_STORAGE_KEYS = [];
|
const CacheMap = new Map();
|
||||||
|
|
||||||
export const createKeysLocalStorageLRUCache = (storageKey, capacity) => {
|
export const createKeysLocalStorageLRUCache = (storageKey, capacity) => {
|
||||||
const lruCache = new LRUCache(capacity);
|
const lruCache = new LRUCache(capacity);
|
||||||
|
|
||||||
if (USED_STORAGE_KEYS.includes(storageKey)) {
|
if (CacheMap.has(storageKey)) {
|
||||||
// @ts-ignore
|
return CacheMap.get(setStorage);
|
||||||
if (module.hot) {
|
|
||||||
console.error(`Storage Key ${storageKey} has been used!`);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Storage Key ${storageKey} has been used!`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
USED_STORAGE_KEYS.push(storageKey);
|
const manager = {
|
||||||
|
|
||||||
return {
|
|
||||||
syncFromStorage() {
|
syncFromStorage() {
|
||||||
const data = getStorage(storageKey) || [];
|
const data = getStorage(storageKey) || [];
|
||||||
data
|
data
|
||||||
|
@ -152,4 +144,8 @@ export const createKeysLocalStorageLRUCache = (storageKey, capacity) => {
|
||||||
return key ? lruCache.get(key) : lruCache.keys();
|
return key ? lruCache.get(key) : lruCache.keys();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
CacheMap.set(storageKey, manager);
|
||||||
|
|
||||||
|
return manager;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Toast } from '@douyinfe/semi-ui';
|
||||||
import Router from 'next/router';
|
import Router from 'next/router';
|
||||||
|
|
||||||
export const HttpClient = axios.create({
|
export const HttpClient = axios.create({
|
||||||
baseURL: process.env.SERVER_API_URL,
|
baseURL: 'http://192.168.31.124:5001/api' || process.env.SERVER_API_URL,
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey, Transaction } from 'prosemirror-state';
|
||||||
|
import { debounce } from 'helpers/debounce';
|
||||||
|
|
||||||
|
export const scrollIntoViewPluginKey = new PluginKey('scrollIntoViewPlugin');
|
||||||
|
|
||||||
|
type TransactionWithScroll = Transaction & { scrolledIntoView: boolean };
|
||||||
|
|
||||||
|
export const ScrollIntoView = Extension.create({
|
||||||
|
name: 'scrollIntoView',
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const { editor } = this;
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: scrollIntoViewPluginKey,
|
||||||
|
appendTransaction: debounce((transactions, oldState, newState) => {
|
||||||
|
if (!transactions.length || !editor.isEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tr = transactions[0] as TransactionWithScroll;
|
||||||
|
if (
|
||||||
|
(tr.docChanged || tr.storedMarksSet) &&
|
||||||
|
!tr.scrolledIntoView &&
|
||||||
|
tr.getMeta('scrollIntoView') !== false &&
|
||||||
|
tr.getMeta('addToHistory') !== false
|
||||||
|
) {
|
||||||
|
return newState.tr.scrollIntoView();
|
||||||
|
}
|
||||||
|
}, 100),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
|
@ -66,13 +66,13 @@ export const EmojiList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
<div ref={$container}>
|
<div ref={$container}>
|
||||||
{props.items.length ? (
|
{props.items.length ? (
|
||||||
props.items.map((item, index) => (
|
props.items.map((item, index) => (
|
||||||
<button
|
<span
|
||||||
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
>
|
>
|
||||||
{item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji}:{item.name}:
|
{item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji}:{item.name}:
|
||||||
</button>
|
</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.item}>没有找到结果</div>
|
<div className={styles.item}>没有找到结果</div>
|
||||||
|
|
|
@ -66,13 +66,13 @@ export const MentionList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
<div ref={$container}>
|
<div ref={$container}>
|
||||||
{props.items.length ? (
|
{props.items.length ? (
|
||||||
props.items.map((item, index) => (
|
props.items.map((item, index) => (
|
||||||
<button
|
<span
|
||||||
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
</button>
|
</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.item}>没有找到结果</div>
|
<div className={styles.item}>没有找到结果</div>
|
||||||
|
|
|
@ -5,10 +5,6 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
box-shadow: rgb(9 30 66 / 31%) 0 0 1px, rgb(9 30 66 / 25%) 0 4px 8px -2px;
|
box-shadow: rgb(9 30 66 / 31%) 0 0 1px, rgb(9 30 66 / 25%) 0 4px 8px -2px;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
background-color: #fff !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
|
@ -21,7 +17,6 @@
|
||||||
background-color: rgb(255 255 255);
|
background-color: rgb(255 255 255);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
outline: none;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
|
|
@ -72,13 +72,13 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
|
||||||
<div ref={$container}>
|
<div ref={$container}>
|
||||||
{props.items.length ? (
|
{props.items.length ? (
|
||||||
props.items.map((item, index) => (
|
props.items.map((item, index) => (
|
||||||
<button
|
<span
|
||||||
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
>
|
>
|
||||||
{typeof item.label === 'function' ? item.label(props.editor) : item.label}
|
{typeof item.label === 'function' ? item.label(props.editor) : item.label}
|
||||||
</button>
|
</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.item}>没有找到结果</div>
|
<div className={styles.item}>没有找到结果</div>
|
||||||
|
|
|
@ -2,7 +2,6 @@ import React, { useEffect, forwardRef, useImperativeHandle, useRef, useMemo } fr
|
||||||
import { Toast, BackTop } from '@douyinfe/semi-ui';
|
import { Toast, BackTop } from '@douyinfe/semi-ui';
|
||||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||||
import cls from 'classnames';
|
import cls from 'classnames';
|
||||||
import { debounce } from 'helpers/debounce';
|
|
||||||
import { useNetwork } from 'hooks/use-network';
|
import { useNetwork } from 'hooks/use-network';
|
||||||
import { useToggle } from 'hooks/use-toggle';
|
import { useToggle } from 'hooks/use-toggle';
|
||||||
import { useWindowSize } from 'hooks/use-window-size';
|
import { useWindowSize } from 'hooks/use-window-size';
|
||||||
|
@ -11,6 +10,7 @@ import { Banner } from 'components/banner';
|
||||||
import { Collaboration } from 'tiptap/core/extensions/collaboration';
|
import { Collaboration } from 'tiptap/core/extensions/collaboration';
|
||||||
import { CollaborationCursor } from 'tiptap/core/extensions/collaboration-cursor';
|
import { CollaborationCursor } from 'tiptap/core/extensions/collaboration-cursor';
|
||||||
import { getRandomColor } from 'helpers/color';
|
import { getRandomColor } from 'helpers/color';
|
||||||
|
import { isAndroid, isIOS } from 'helpers/env';
|
||||||
import { useEditor, EditorContent } from '../../react';
|
import { useEditor, EditorContent } from '../../react';
|
||||||
import { CollaborationKit } from '../kit';
|
import { CollaborationKit } from '../kit';
|
||||||
import { MenuBar } from './menubar';
|
import { MenuBar } from './menubar';
|
||||||
|
@ -25,8 +25,30 @@ type IProps = Pick<
|
||||||
status: ProviderStatus;
|
status: ProviderStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function scrollEditor(editor) {
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* 修复移动端编辑问题
|
||||||
|
*/
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const element = editor.options.element;
|
||||||
|
// 脏代码:这里使用 parentElement 是和布局有关的,需要根据实际情况修改
|
||||||
|
const parentElement = element.parentNode as HTMLElement;
|
||||||
|
const nextScrollTop = element.scrollHeight;
|
||||||
|
parentElement.scrollTop = nextScrollTop;
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const EditorInstance = forwardRef((props: IProps, ref) => {
|
export const EditorInstance = forwardRef((props: IProps, ref) => {
|
||||||
const { hocuspocusProvider, editable, user, onTitleUpdate, status, menubar, renderInEditorPortal } = props;
|
const { hocuspocusProvider, editable, user, onTitleUpdate, status, menubar, renderInEditorPortal } = props;
|
||||||
|
const $headerContainer = useRef<HTMLDivElement>();
|
||||||
const $mainContainer = useRef<HTMLDivElement>();
|
const $mainContainer = useRef<HTMLDivElement>();
|
||||||
const { isMobile } = useWindowSize();
|
const { isMobile } = useWindowSize();
|
||||||
const { online } = useNetwork();
|
const { online } = useNetwork();
|
||||||
|
@ -49,16 +71,24 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
onTransaction: debounce(({ transaction }) => {
|
onTransaction({ transaction }) {
|
||||||
try {
|
try {
|
||||||
const title = transaction.doc.content.firstChild.content.firstChild.textContent;
|
const title = transaction.doc.content.firstChild.content.firstChild.textContent;
|
||||||
onTitleUpdate(title);
|
onTitleUpdate(title);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
}, 50),
|
|
||||||
|
if (editable) {
|
||||||
|
scrollEditor(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
onCreate() {
|
onCreate() {
|
||||||
toggleCreated(true);
|
toggleCreated(true);
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
scrollEditor(this);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDestroy() {},
|
onDestroy() {},
|
||||||
},
|
},
|
||||||
|
@ -89,6 +119,46 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 监听键盘收起、打开
|
||||||
|
useEffect(() => {
|
||||||
|
let cleanUp = () => {};
|
||||||
|
const focusIn = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!$headerContainer.current) return;
|
||||||
|
$headerContainer.current.classList.add(styles.keyUp);
|
||||||
|
$headerContainer.current.scrollIntoView();
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
const focusOut = () => {
|
||||||
|
if (!$headerContainer.current) return;
|
||||||
|
$headerContainer.current.classList.remove(styles.iOSKeyUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isIOS()) {
|
||||||
|
document.body.addEventListener('focusin', focusIn);
|
||||||
|
document.body.addEventListener('focusout', focusOut);
|
||||||
|
cleanUp = () => {
|
||||||
|
document.body.removeEventListener('focusin', focusIn);
|
||||||
|
document.body.removeEventListener('focusout', focusOut);
|
||||||
|
};
|
||||||
|
} else if (isAndroid) {
|
||||||
|
const originalHeight = document.documentElement.clientHeight || document.body.clientHeight;
|
||||||
|
window.onresize = function () {
|
||||||
|
//键盘弹起与隐藏都会引起窗口的高度发生变化
|
||||||
|
const resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
|
||||||
|
if (resizeHeight < originalHeight) {
|
||||||
|
focusIn();
|
||||||
|
} else {
|
||||||
|
focusOut();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanUp();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(!online || status === 'disconnected') && (
|
{(!online || status === 'disconnected') && (
|
||||||
|
@ -102,7 +172,7 @@ export const EditorInstance = forwardRef((props: IProps, ref) => {
|
||||||
<Banner type="warning" description="您没有编辑权限,暂不能编辑该文档。" closeable={false} />
|
<Banner type="warning" description="您没有编辑权限,暂不能编辑该文档。" closeable={false} />
|
||||||
)}
|
)}
|
||||||
{menubar && (
|
{menubar && (
|
||||||
<header className={cls(isMobile && styles.mobileToolbar)}>
|
<header className={cls(isMobile && styles.mobileToolbar)} ref={$headerContainer}>
|
||||||
<MenuBar editor={editor} />
|
<MenuBar editor={editor} />
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -34,6 +34,10 @@
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-top: 1px solid var(--semi-color-border);
|
border-top: 1px solid var(--semi-color-border);
|
||||||
|
|
||||||
|
&.keyUp {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ export const CollaborationEditor = forwardRef((props: ICollaborationEditorProps,
|
||||||
|
|
||||||
const hocuspocusProvider = useMemo(() => {
|
const hocuspocusProvider = useMemo(() => {
|
||||||
return new HocuspocusProvider({
|
return new HocuspocusProvider({
|
||||||
url: process.env.COLLABORATION_API_URL,
|
url: 'ws://192.168.31.124:5003' || process.env.COLLABORATION_API_URL,
|
||||||
name: documentId,
|
name: documentId,
|
||||||
token: (user && user.token) || 'read-public',
|
token: (user && user.token) || 'read-public',
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { OrderedList } from 'tiptap/core/extensions/ordered-list';
|
||||||
import { Paragraph } from 'tiptap/core/extensions/paragraph';
|
import { Paragraph } from 'tiptap/core/extensions/paragraph';
|
||||||
import { Placeholder } from 'tiptap/core/extensions/placeholder';
|
import { Placeholder } from 'tiptap/core/extensions/placeholder';
|
||||||
import { SelectionExtension } from 'tiptap/core/extensions/selection';
|
import { SelectionExtension } from 'tiptap/core/extensions/selection';
|
||||||
|
import { ScrollIntoView } from 'tiptap/core/extensions/scroll-into-view';
|
||||||
import { Strike } from 'tiptap/core/extensions/strike';
|
import { Strike } from 'tiptap/core/extensions/strike';
|
||||||
import { Subscript } from 'tiptap/core/extensions/subscript';
|
import { Subscript } from 'tiptap/core/extensions/subscript';
|
||||||
import { Superscript } from 'tiptap/core/extensions/superscript';
|
import { Superscript } from 'tiptap/core/extensions/superscript';
|
||||||
|
@ -106,6 +107,7 @@ export const CollaborationKit = [
|
||||||
Loading,
|
Loading,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
SelectionExtension,
|
SelectionExtension,
|
||||||
|
ScrollIntoView,
|
||||||
Strike,
|
Strike,
|
||||||
Subscript,
|
Subscript,
|
||||||
Superscript,
|
Superscript,
|
||||||
|
@ -139,6 +141,5 @@ export const CollaborationKit = [
|
||||||
SearchNReplace,
|
SearchNReplace,
|
||||||
Status,
|
Status,
|
||||||
Title,
|
Title,
|
||||||
// Document,
|
|
||||||
DocumentWithTitle,
|
DocumentWithTitle,
|
||||||
];
|
];
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { OrderedList } from 'tiptap/core/extensions/ordered-list';
|
||||||
import { Paragraph } from 'tiptap/core/extensions/paragraph';
|
import { Paragraph } from 'tiptap/core/extensions/paragraph';
|
||||||
import { Placeholder } from 'tiptap/core/extensions/placeholder';
|
import { Placeholder } from 'tiptap/core/extensions/placeholder';
|
||||||
import { Strike } from 'tiptap/core/extensions/strike';
|
import { Strike } from 'tiptap/core/extensions/strike';
|
||||||
|
import { ScrollIntoView } from 'tiptap/core/extensions/scroll-into-view';
|
||||||
import { Subscript } from 'tiptap/core/extensions/subscript';
|
import { Subscript } from 'tiptap/core/extensions/subscript';
|
||||||
import { Superscript } from 'tiptap/core/extensions/superscript';
|
import { Superscript } from 'tiptap/core/extensions/superscript';
|
||||||
import { Table } from 'tiptap/core/extensions/table';
|
import { Table } from 'tiptap/core/extensions/table';
|
||||||
|
@ -87,6 +88,7 @@ export const CommentKit = [
|
||||||
showOnlyWhenEditable: true,
|
showOnlyWhenEditable: true,
|
||||||
}),
|
}),
|
||||||
Strike,
|
Strike,
|
||||||
|
ScrollIntoView,
|
||||||
Subscript,
|
Subscript,
|
||||||
Superscript,
|
Superscript,
|
||||||
Table,
|
Table,
|
||||||
|
|
|
@ -6,5 +6,5 @@ import { triggerOpenCountSettingModal } from '../_event';
|
||||||
* @param editor
|
* @param editor
|
||||||
*/
|
*/
|
||||||
export const createCountdown = (editor: Editor) => {
|
export const createCountdown = (editor: Editor) => {
|
||||||
triggerOpenCountSettingModal(null);
|
triggerOpenCountSettingModal(editor, null);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue