feat & fix & docs

# feat
- 移除 mark-range 插件 `onChange` 和 `onSelect` 可选项,使用 `mark-range:select` 和 `mark-range:change` 替代
- 增加卡片懒渲染,给卡片静态属性 `lazyRender` 标识为 ture 即可启用卡片懒渲染,即卡片节点在视窗内可见时才渲染。engine 可选项增加 lazyRender 设置,设置为 false,即禁用全部卡片懒渲染,默认为 true。codeblock 和 table 已支持懒渲染

# fix
- 不能连续换行
- 光标重合时进行评论标记会自动取消
- vue link 插件解析 markdown 错误
- 两个inline类型的卡片在一起时,删除会造成结构性错误
# docs
- 更新卡片 & mark-range 文档
This commit is contained in:
yanmao 2021-11-23 23:38:28 +08:00
parent eb9c87a0fb
commit 3bc30a1899
46 changed files with 873 additions and 439 deletions

View File

@ -230,5 +230,11 @@ After rendering, `View` loses all editing capabilities and collaboration capabil
### scrollNode
- Type: `Node | (() => Node | null)`
- Default value: Find the node whose parent style `overflow` or `overflow-y` is `auto` or `scroll`, if not, take `document.body`
- Default value: Find the node whose parent style `overflow` or `overflow-y` is `auto` or `scroll`, if not, take `document.documentElement`
- Detailed: The editor scroll bar node is mainly used to monitor the `scroll` event to set the floating position of the bomb layer and actively set the scroll to the editor target position
### lazyRender
- Type: `boolena`
- Default value: `true`
- Detailed: Lazy rendering of cards (only cards with lazyRender enabled), the default is true

View File

@ -230,5 +230,11 @@ console.log(view.language.get<string>('test'));
### scrollNode
- 类型: `Node | (() => Node | null)`
- 默认值:查找父级样式 `overflow` 或者 `overflow-y``auto` 或者 `scroll` 的节点,如果没有就取 `document.body`
- 默认值:查找父级样式 `overflow` 或者 `overflow-y``auto` 或者 `scroll` 的节点,如果没有就取 `document.documentElement`
- 详细:编辑器滚动条节点,主要用于监听 `scroll` 事件设置弹层浮动位置和主动设置滚动到编辑器目标位置
### lazyRender
- 类型: `boolena`
- 默认值:`true`
- 详细:懒惰渲染卡片(仅限已启用 lazyRender 的卡片),默认为 true

View File

@ -73,3 +73,15 @@ console.log(view.language.get<string>('test'));
- Type: `{ [key: string]: PluginOptions }`
- Default value: `{}`
- Detailed: the configuration item of each plug-in, the key is the name of the plug-in, please refer to the description of each plug-in for detailed configuration
### scrollNode
- Type: `Node | (() => Node | null)`
- Default value: Find the node whose parent style `overflow` or `overflow-y` is `auto` or `scroll`, if not, take `document.documentElement`
- Detailed: The editor scroll bar node is mainly used to monitor the `scroll` event to set the floating position of the bomb layer and actively set the scroll to the editor target position
### lazyRender
- Type: `boolena`
- Default value: `true`
- Detailed: Lazy rendering of cards (only cards with lazyRender enabled), the default is true

View File

@ -73,3 +73,15 @@ console.log(view.language.get<string>('test'));
- 类型: `{ [key: string]: PluginOptions }`
- 默认值:`{}`
- 详细每个插件的配置项key 为插件名称,详细配置请参考每个插件的说明
### scrollNode
- 类型: `Node | (() => Node | null)`
- 默认值:查找父级样式 `overflow` 或者 `overflow-y``auto` 或者 `scroll` 的节点,如果没有就取 `document.documentElement`
- 详细:编辑器滚动条节点,主要用于监听 `scroll` 事件设置弹层浮动位置和主动设置滚动到编辑器目标位置
### lazyRender
- 类型: `boolena`
- 默认值:`true`
- 详细:懒惰渲染卡片(仅限已启用 lazyRender 的卡片),默认为 true

View File

@ -18,20 +18,7 @@ These three plugins all have vue3 dependencies and use the antv UI library. Othe
## window is not defined, document is not defined, navigator is not defined
SSR will execute the render method on the server side, and the server side does not have DOM/BOM variables and methods
In the editing mode, there is basically no need for server-side rendering. Mainly lies in the view rendering. If pure html is used, the dynamic interaction of the content of `Card` will be lacking.
1. Use the built-in window object of jsdom. You can use the getWindow object to get this \_\_amWindow object inside the engine or plug-in. But it cannot solve the problem of third-party packages relying on the window object
```ts
const { JSDOM } = require('jsdom');
const { window } = new JSDOM(`<html><body></body></html>`);
global.__amWindow = window;
```
2. Introduce third-party packages dynamically or use `isServer` to determine whether there is a window object. This can solve the problem of no errors when running, but the content cannot be completely rendered on the server side. You can output html on the server to meet the needs of seo. Re-render the view reader after loading into the browser
SSR will execute the render method on the server side, and the server side does not have DOM/BOM variables and methods. Does not support server-side rendering
## Improve paste efficiency/filter paste style
@ -57,3 +44,13 @@ engine.on('paste:schema', (schema) => {
});
});
```
## Import and Export
Use the two methods `getHtml` and `setHtml` provided by the engine instance, and use `html` as the intermediary for conversion
You can use third-party libraries or back-end APIs to read other documents and convert them to the standard format of `html` and then transfer them back to the front-end, call `setHtml` to set them in the editor
Convert to other document formats in the same way, use `getHtml` to obtain `html` and then convert
Some cards may require additional attributes to restore the `html` correctly. You can check the conversion conditions in the `pasteHtml` method in the specific card plug-in

View File

@ -18,20 +18,7 @@
## window is not defined, document is not defined, navigator is not defined
SSR 因为会在服务端执行 render 渲染方法,而服务端没有 DOM/BOM 变量和方法
在编辑模式下,基本上没有服务端渲染的需求。主要在于视图渲染,如果使用纯 html 呈现将缺少`Card`内容的动态交互。
1. 使用 jsdom 内置 window 对象。在引擎或插件内部可以使用 getWindow 对象获取这个 \_\_amWindow 对象。但是无法解决第三方包依赖 window 对象的问题
```ts
const { JSDOM } = require('jsdom');
const { window } = new JSDOM(`<html><body></body></html>`);
global.__amWindow = window;
```
2. 将第三方包动态引入 或者 使用 `isServer` 判定是否有 window 对象。这样能解决运行不会出错的问题,但是在服务端还是无法完整的渲染出内容。可以在服务端输出 html满足 seo 需求。加载到浏览器后重新渲染 view 阅读器
SSR 因为会在服务端执行 render 渲染方法,而服务端没有 DOM/BOM 变量和方法。不支持服务端渲染
## 提高粘贴效率/过滤粘贴样式
@ -57,3 +44,13 @@ engine.on('paste:schema', (schema) => {
});
});
```
## 导入/导出
使用 engine 实例提供的 `getHtml``setHtml` 两个方法,以 `html` 为中介进行转换
可以使用第三方库或者后端 api 读取其它文档并转换为`html`标准格式后传回前端,调用 `setHtml` 设置到编辑器中
转化为其它文档格式同理,使用 `getHtml` 获取到 `html` 后进行转换
有些卡片可能需要额外的属性才能使 `html` 正确的还原,可以查看具体卡片插件中的 `pasteHtml` 方法中有哪些转换条件

View File

@ -47,31 +47,7 @@ keys: Array<string>
//For example, comments keys = ["comment"]
```
### Mark node change callback
In collaborative editing, this callback will be triggered after other authors add tags, or edit or delete some nodes that contain tagged nodes
This callback will also be triggered when using undo and redo related operations
addIds: Newly added mark node number collection
removeIds: a collection of deleted marker node numbers
ids: a collection of all valid marked node numbers
```ts
onChange?: (addIds: {[key: string]: Array<string>},removeIds: {[key: string]: Array<string>},ids: {[key:string]: Array<string> }) = > void
```
### Callback when the marked section is selected
Triggered when the cursor changes. If selectInfo has a value, it will carry the nearest cursor position. If it is a nested relationship, then it will return the innermost mark number
```ts
onSelect?: (range: RangeInterface, selectInfo?: {key: string, id: string}) => void
```
### hot key
### Hotkey
No shortcut keys by default
@ -166,6 +142,36 @@ value Gets the html in the root node of the current editor as the value by defau
engine.command.execute('mark-range', key: string,'wrap', paths: Array<{ id: Array<string>, path: Array<Path>}>, value?: string): string
```
## Event
### Mark node change callback
In collaborative editing, this callback will be triggered after other authors add tags, or edit or delete some nodes that contain tagged nodes
This callback will also be triggered when using undo and redo related operations
addIds: Newly added mark node number collection
removeIds: a collection of deleted marker node numbers
ids: a collection of all valid marked node numbers
```ts
engine.on('mark-range:change', (addIds: { [key: string]: Array<string>},removeIds: { [key: string]: Array<string>},ids: { [key:string] : Array<string> }) => {
...
})
```
### Callback when the marked section is selected
Triggered when the cursor changes. If selectInfo has a value, it will carry the nearest cursor position. If it is a nested relationship, then it will return the innermost mark number
```ts
engine.on('mark-range:select', (range: RangeInterface, selectInfo?: { key: string, id: string}) => {
...
})
```
## Style definition
```css

View File

@ -47,30 +47,6 @@ keys: Array<string>
//例如评论 keys = ["comment"]
```
### 标记节点改变回调
在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调
在使用 撤销、重做 相关操作时,也会触发此回调
addIds: 新增的标记节点编号集合
removeIds: 删除的标记节点编号集合
ids: 所有有效的标记节点编号集合
```ts
onChange?: (addIds: { [key: string]: Array<string>},removeIds: { [key: string]: Array<string>},ids: { [key:string] : Array<string> }) => void
```
### 选中标记节时点回调
在光标改变时触发selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号
```ts
onSelect? : (range: RangeInterface, selectInfo?: { key: string, id: string}) => void
```
### 快捷键
默认无快捷键
@ -166,6 +142,36 @@ value 默认获取当前编辑器根节点中的 html 作为值
engine.command.execute('mark-range', key: string, 'wrap', paths: Array<{ id: Array<string>, path: Array<Path>}>, value?: string): string
```
## 事件
### 标记节点改变回调
在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调
在使用 撤销、重做 相关操作时,也会触发此回调
addIds: 新增的标记节点编号集合
removeIds: 删除的标记节点编号集合
ids: 所有有效的标记节点编号集合
```ts
engine.on('mark-range:change', (addIds: { [key: string]: Array<string>},removeIds: { [key: string]: Array<string>},ids: { [key:string] : Array<string> }) => {
...
})
```
### 选中标记节时点回调
在光标改变时触发selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号
```ts
engine.on('mark-range:select', (range: RangeInterface, selectInfo?: { key: string, id: string}) => {
...
})
```
## 样式定义
```css

View File

@ -949,6 +949,10 @@ The style of the selected yes, the default is the border change, optional values
Whether the card toolbar follows the mouse position, the default flase
### `lazyRender`
Whether to enable lazy loading, the rendering is triggered when the card node is visible in the view
## Attributes
### `editor`
@ -1394,6 +1398,14 @@ Triggered after updating the card
didUpdate?(): void;
```
### `beforeRender`
Triggered before the card is rendered after the lazy rendering is turned on
```ts
beforeRender(): void
```
### `didRender`
Triggered after the card is successfully rendered

View File

@ -950,6 +950,10 @@ export default class extends Plugin {
卡片工具栏是否跟随鼠标位置,默认 flase
### `lazyRender`
是否启用懒加载,卡片节点在视图内可见时触发渲染
## 属性
### `editor`
@ -1395,6 +1399,14 @@ didInsert?(): void;
didUpdate?(): void;
```
### `beforeRender`
开启懒惰渲染后,卡片渲染前触发
```ts
beforeRender(): void
```
### `didRender`
卡片渲染成功后触发

View File

@ -33,49 +33,6 @@ export type CommentProps = {
export type CommentRef = {
reload: () => void;
select: (id?: string) => void;
showButton: (range: RangeInterface) => void;
updateStatus: (ids: Array<string>, status: boolean) => void;
};
const getConfig = (
editor: React.MutableRefObject<EditorInterface | null>,
comment: React.MutableRefObject<CommentRef | null>,
) => {
return {
//标记类型集合
keys: ['comment'],
//标记数据更新后触发
onChange: (
addIds: { [key: string]: Array<string> },
removeIds: { [key: string]: Array<string> },
) => {
const commentAddIds = addIds['comment'] || [];
const commentRemoveIds = removeIds['comment'] || [];
//更新状态
comment.current?.updateStatus(commentAddIds, true);
comment.current?.updateStatus(commentRemoveIds, false);
},
//光标改变时触发
onSelect: (
range: RangeInterface,
selectInfo?: { key: string; id: string },
) => {
const { key, id } = selectInfo || {};
comment.current?.showButton(range);
comment.current?.select(key === 'comment' ? id : undefined);
if (comment && key === 'comment' && id) {
editor.current?.command.executeMethod(
'mark-range',
'action',
key,
'preview',
id,
);
}
},
};
};
const Comment: React.FC<CommentProps> = forwardRef<CommentRef, CommentProps>(
@ -210,6 +167,7 @@ const Comment: React.FC<CommentProps> = forwardRef<CommentRef, CommentProps>(
useEffect(() => {
const onMouseDown = (event: MouseEvent) => {
if (editItem) return;
event.preventDefault();
event.stopPropagation();
if (isEngine(editor)) {
const text = editor.command.executeMethod(
@ -240,10 +198,6 @@ const Comment: React.FC<CommentProps> = forwardRef<CommentRef, CommentProps>(
*
*/
useImperativeHandle(ref, () => ({
select,
showButton: (range: RangeInterface) =>
buttonRef.current?.show(range),
updateStatus,
reload: load,
}));
@ -380,6 +334,45 @@ const Comment: React.FC<CommentProps> = forwardRef<CommentRef, CommentProps>(
});
};
useEffect(() => {
//标记数据更新后触发
const markChange = (
addIds: { [key: string]: Array<string> },
removeIds: { [key: string]: Array<string> },
) => {
const commentAddIds = addIds['comment'] || [];
const commentRemoveIds = removeIds['comment'] || [];
//更新状态
updateStatus(commentAddIds, true);
updateStatus(commentRemoveIds, false);
};
editor.on('mark-range:change', markChange);
//光标改变时触发
const markSelect = (
range: RangeInterface,
selectInfo?: { key: string; id: string },
) => {
const { key, id } = selectInfo || {};
buttonRef.current?.show(range);
select(key === 'comment' ? id : undefined);
if (key === 'comment' && id) {
editor.command.executeMethod(
'mark-range',
'action',
key,
'preview',
id,
);
}
};
editor.on('mark-range:select', markSelect);
return () => {
editor.off('mark-range:change', markChange);
editor.off('mark-range:select', markSelect);
};
}, [editor, updateStatus, select]);
/**
* top为目标
* @returns
@ -623,5 +616,3 @@ const Comment: React.FC<CommentProps> = forwardRef<CommentRef, CommentProps>(
);
export default Comment;
export { getConfig };

View File

@ -116,6 +116,10 @@ export const cards: Array<CardEntry> = [
];
export const pluginConfig: { [key: string]: PluginOptions } = {
[MarkRange.pluginName]: {
//标记类型集合
keys: ['comment'],
},
[Italic.pluginName]: {
// 默认为 _ 下划线,这里修改为单个 * 号
markdown: '*',

View File

@ -8,7 +8,7 @@ import EngineComponent, { EngineProps } from '../engine';
import OTComponent, { OTClient, Member, STATUS, ERROR } from './ot';
//Demo相关
import Loading from '../loading';
import CommentLayer, { CommentRef, getConfig } from '../comment';
import CommentLayer, { CommentRef } from '../comment';
import Toc from '../toc';
import { cards, pluginConfig, plugins } from './config';
import Toolbar, { ToolbarItemProps } from './toolbar';
@ -104,7 +104,6 @@ const EditorComponent: React.FC<EditorProps> = ({
config: {
...props.config,
...pluginConfig,
'mark-range': getConfig(engine, comment),
},
// 编辑器值改变事件
onChange: useCallback(
@ -122,7 +121,7 @@ const EditorComponent: React.FC<EditorProps> = ({
// engine.current?.command.executeMethod('mention', 'getList'),
// );
// 获取编辑器的html
//console.log('html:', engine.current?.getHtml());
console.log('html:', engine.current?.getHtml());
},
[loading, autoSave, props.onChange],
),

View File

@ -1,7 +1,9 @@
import {
CARD_EDITABLE_KEY,
CARD_ELEMENT_KEY,
CARD_KEY,
CARD_LEFT_SELECTOR,
CARD_LOADING_KEY,
CARD_RIGHT_SELECTOR,
CARD_TYPE_KEY,
CARD_VALUE_KEY,
@ -27,6 +29,7 @@ import Resize from './resize';
import Toolbar from './toolbar';
import { $ } from '../node';
import { CardType } from './enum';
import { DATA_ELEMENT, UI } from '../constants';
abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
protected readonly editor: EditorInterface;
@ -48,6 +51,7 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
static readonly focus: boolean;
static readonly selectStyleType: 'border' | 'background' = 'border';
static readonly toolbarFollowMouse: boolean = false;
static readonly lazyRender: boolean = false;
private defaultMaximize: MaximizeInterface;
isMaximize: boolean = false;
@ -104,6 +108,10 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
card.activate(component.root);
}
get loading() {
return !!this.root.attributes('data-card-loading');
}
constructor({ editor, value, root }: CardOptions) {
this.editor = editor;
const type =
@ -111,7 +119,6 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
const tagName = type === 'inline' ? 'span' : 'div';
this.root = root ? root : $('<'.concat(tagName, ' />'));
if (typeof value === 'string') value = decodeCardValue(value);
value = value || {};
value.id = this.getId(value.id);
value.type = type;
@ -120,6 +127,10 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
}
init() {
this.root.attributes(
CARD_EDITABLE_KEY,
this.isEditable ? 'true' : 'false',
);
this.toolbarModel?.hide();
this.toolbarModel?.destroy();
if (this.toolbar) {
@ -176,7 +187,7 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
const body = this.root.first() || $([]);
if (key === 'body' || body.length === 0) return body;
const children = body.children();
const index = ['center', 'left', 'right'].indexOf(key);
const index = ['left', 'center', 'right'].indexOf(key);
if (index > -1) {
const child = children.eq(index);
if (child?.attributes(CARD_ELEMENT_KEY) === key) return child;
@ -335,7 +346,23 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
}
didInsert?(): void;
didUpdate?(): void;
beforeRender() {
const center = this.getCenter();
const loadingElement = $(
`<${
this.type === CardType.BLOCK ? 'div' : 'span'
} class="data-card-loading" ${DATA_ELEMENT}="${UI}" />`,
);
loadingElement.append(
'<svg viewBox="0 0 1024 1024" class="data-card-spin" data-icon="loading" width="1em" height="1em" fill="currentColor" aria-hidden="true"> <path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 0 0-94.3-139.9 437.71 437.71 0 0 0-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path></svg>',
);
center.empty().append(loadingElement);
}
didRender() {
if (this.loading) this.find('.data-card-loading').remove();
setTimeout(() => {
this.root.removeAttributes(CARD_LOADING_KEY);
}, 100);
if (this.resize) {
const container =
typeof this.resize === 'function'
@ -345,7 +372,7 @@ abstract class CardEntry<T extends CardValue = {}> implements CardInterface {
this.resizeModel?.render(container);
}
}
if (this.contenteditable.length > 0) {
if (this.isEditable) {
this.editor.nodeId.generateAll(this.getCenter().get<Element>()!);
}
}

View File

@ -10,3 +10,23 @@
.am-engine .card-selected [data-card-element="center"].data-card-border-selected::selection {
background: transparent;
}
.am-engine-view [data-card-element="center"] .data-card-loading,.am-engine [data-card-element="center"] .data-card-loading {
display: inline-block;
font-style: normal;
vertical-align: -0.125em;
text-align: center;
text-transform: none;
line-height: 0;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
margin-right: 5px;
padding: 16px;
width: 100%;
}
.am-engine-view [data-card-element="center"] .data-card-loading .data-card-spin,.am-engine [data-card-element="center"] .data-card-loading .data-card-spin {
display: inline-block;
-webkit-animation: loadingCircle 1s infinite linear;
animation: loadingCircle 1s infinite linear;
}

View File

@ -11,6 +11,7 @@ import {
EDITABLE_SELECTOR,
DATA_TRANSIENT_ELEMENT,
DATA_TRANSIENT_ATTRIBUTES,
CARD_LOADING_KEY,
} from '../constants';
import {
CardEntry,
@ -38,12 +39,16 @@ class CardModel implements CardModelInterface {
[k: string]: CardEntry;
};
components: Array<CardInterface>;
lazyRender: boolean;
private asyncComponents: CardInterface[] = [];
private editor: EditorInterface;
private renderTimeout?: NodeJS.Timeout;
constructor(editor: EditorInterface) {
constructor(editor: EditorInterface, lazyRender: boolean = true) {
this.classes = {};
this.components = [];
this.editor = editor;
this.lazyRender = lazyRender;
}
get active() {
@ -98,8 +103,37 @@ class CardModel implements CardModelInterface {
cards.forEach((card) => {
this.classes[card.cardName] = card;
});
window.addEventListener('resize', this.renderAsnycComponents);
this.editor.scrollNode?.on('scroll', this.renderAsnycComponents);
window.addEventListener('scroll', this.renderAsnycComponents);
}
renderAsnycComponents = () => {
if (this.renderTimeout) clearTimeout(this.renderTimeout);
this.renderTimeout = setTimeout(() => {
const components = this.asyncComponents.concat();
components.forEach((card) => {
// 在视图内才渲染卡片
if (
card.root.length === 0 ||
this.editor.root.inViewport(card.root, true)
) {
if (card.root.length > 0 && card.loading) {
card.getCenter().empty();
this.renderComponent(card);
}
this.asyncComponents.splice(
this.asyncComponents.findIndex(
(component) => component === card,
),
1,
);
}
});
}, 100);
};
add(clazz: CardEntry) {
this.classes[clazz.cardName] = clazz;
}
@ -238,27 +272,7 @@ class CardModel implements CardModelInterface {
) {
block.unwrap(rootParent, range);
}
const result = card.render(...args);
const center = card.getCenter();
if (result !== undefined) {
card.getCenter().append(
typeof result === 'string' ? $(result) : result,
);
}
if (card.contenteditable.length > 0) {
center.find(card.contenteditable.join(',')).each((node) => {
const child = $(node);
child.attributes(
'contenteditable',
!isEngine(this.editor) || this.editor.readonly
? 'false'
: 'true',
);
child.attributes(DATA_ELEMENT, EDITABLE);
});
}
//创建工具栏
card.didRender();
this.renderComponent(card, ...args);
if (card.didInsert) {
card.didInsert();
}
@ -419,17 +433,6 @@ class CardModel implements CardModelInterface {
const { change } = this.editor;
const range = change.range.toTrusty();
const card = this.insertNode(range, component, ...args);
const type = component.type;
if (type === 'inline') {
card.focus(range, false);
}
change.range.select(range);
if (
type === 'block' &&
(component.constructor as CardEntry).autoActivate !== false
) {
this.activate(card.root, CardActiveTrigger.CARD_CHANGE);
}
change.change();
return card;
}
@ -538,6 +541,7 @@ class CardModel implements CardModelInterface {
component.root.attributes(CARD_TYPE_KEY, type);
component.root.attributes(CARD_KEY, name);
component.root.attributes(CARD_LOADING_KEY, 'true');
//如果没有指定是否能聚集那么当card不是只读的时候就可以聚焦
const hasFocus =
clazz.focus !== undefined
@ -628,28 +632,6 @@ class CardModel implements CardModelInterface {
: this.editor.container.find(READY_CARD_SELECTOR);
this.gc();
let setp = 0;
const render = (card: CardInterface) => {
const result = card.render();
const center = card.getCenter();
if (result !== undefined) {
center.append(typeof result === 'string' ? $(result) : result);
}
if (card.contenteditable.length > 0) {
center.find(card.contenteditable.join(',')).each((node) => {
const child = $(node);
if (!child.attributes('contenteditable'))
child.attributes(
'contenteditable',
!isEngine(this.editor) || this.editor.readonly
? 'false'
: 'true',
);
child.attributes(DATA_ELEMENT, EDITABLE);
});
this.render(center);
}
card.didRender();
};
const asyncRenderCards: Array<CardInterface> = [];
cards.each((node) => {
@ -668,6 +650,7 @@ class CardModel implements CardModelInterface {
if (card.destroy) card.destroy();
this.removeComponent(card);
}
cardNode.attributes(CARD_LOADING_KEY, 'true');
}
//ready_card_key 待创建的需要重新生成节点,并替换当前待创建节点
card = this.create(name, {
@ -676,8 +659,9 @@ class CardModel implements CardModelInterface {
});
Object.keys(attributes).forEach((attributesName) => {
if (
attributesName.indexOf('data-') === 0 &&
attributesName.indexOf('data-card') !== 0
(attributesName.indexOf('data-') === 0 &&
attributesName.indexOf('data-card') !== 0) ||
attributesName === CARD_LOADING_KEY
) {
card!.root.attributes(
attributesName,
@ -698,7 +682,21 @@ class CardModel implements CardModelInterface {
});
asyncRenderCards.forEach(async (card) => {
render(card);
if (this.lazyRender && (card.constructor as CardEntry).lazyRender) {
if (card.beforeRender) {
const result = card.beforeRender();
const center = card.getCenter();
if (result !== undefined) {
center.append(
typeof result === 'string' ? $(result) : result,
);
}
}
if (!this.asyncComponents.includes(card))
this.asyncComponents.push(card);
} else {
this.renderComponent(card);
}
setp++;
if (setp === asyncRenderCards.length) {
if (callback) callback(asyncRenderCards.length);
@ -707,6 +705,33 @@ class CardModel implements CardModelInterface {
if (asyncRenderCards.length === 0) {
if (callback) callback(0);
}
if (asyncRenderCards.length > 0) {
// 触发当前在视图内的卡片渲染
this.renderAsnycComponents();
}
}
renderComponent(card: CardInterface, ...args: any) {
const center = card.getCenter();
const result = card.render();
if (result !== undefined) {
center.append(typeof result === 'string' ? $(result) : result);
}
if (card.contenteditable.length > 0) {
center.find(card.contenteditable.join(',')).each((node) => {
const child = $(node);
if (!child.attributes('contenteditable'))
child.attributes(
'contenteditable',
!isEngine(this.editor) || this.editor.readonly
? 'false'
: 'true',
);
child.attributes(DATA_ELEMENT, EDITABLE);
});
this.render(center);
}
card.didRender();
}
removeComponent(card: CardInterface): void {
@ -733,6 +758,13 @@ class CardModel implements CardModelInterface {
}
}
destroy() {
this.gc();
window.removeEventListener('resize', this.renderAsnycComponents);
this.editor.scrollNode?.off('scroll', this.renderAsnycComponents);
window.removeEventListener('scroll', this.renderAsnycComponents);
}
// 焦点移动到上一个 Block
focusPrevBlock(
card: CardInterface,

View File

@ -79,6 +79,13 @@ class Backspace {
change.mergeAfterDelete();
return false;
} else {
// 左侧还是卡片删除卡片
const leftCard = this.engine.card.find(prev);
if (leftCard) {
this.engine.card.remove(leftCard.id);
range.handleBr();
return false;
}
range.select(card.root).collapse(true);
}
change.range.select(range);

View File

@ -173,14 +173,19 @@ class ChangeModel implements ChangeInterface {
this.engine.container.html(value);
this.initValue(undefined, false);
} else {
const parser = new Parser(value, this.engine, (root) => {
const parser = new Parser(
value,
this.engine,
(root) => {
mark.removeEmptyMarks(root);
root.allChildren(true).forEach((child) => {
if (onParse) {
onParse(child);
}
});
});
},
false,
);
container.html(parser.toValue(schema, conversion, false, true));
card.render(undefined, (count) => {
if (callback) callback(count);
@ -283,7 +288,7 @@ class ChangeModel implements ChangeInterface {
const tags = schema.getAllowInTags();
return (
container.children().length === 1 &&
node.isEmptyWithTrim(container) &&
node.isEmpty(container) &&
!container.allChildren().some((child) => tags.includes(child.name))
);
}
@ -428,7 +433,7 @@ class ChangeModel implements ChangeInterface {
if (prev) {
prev.after(node);
} else {
nodeApi.insert(node, range);
nodeApi.insert(node, range, true);
}
prev = node;
if (!next) {

View File

@ -4,6 +4,8 @@ export const READY_CARD_KEY = 'data-ready-card';
export const CARD_TYPE_KEY = 'data-card-type';
export const CARD_VALUE_KEY = 'data-card-value';
export const CARD_ELEMENT_KEY = 'data-card-element';
export const CARD_LOADING_KEY = 'data-card-loading';
export const CARD_EDITABLE_KEY = 'data-card-editable';
export const CARD_SELECTOR = 'div['
.concat(CARD_KEY, '],span[')
.concat(CARD_KEY, ']');

View File

@ -6,6 +6,7 @@ import {
CARD_TYPE_KEY,
CARD_VALUE_KEY,
READY_CARD_KEY,
CARD_EDITABLE_KEY,
} from './card';
const defaultConversion: ConversionData = [
@ -21,6 +22,7 @@ const defaultConversion: ConversionData = [
name: (
attributes[CARD_KEY] || attributes[READY_CARD_KEY]
).toLowerCase(),
editable: attributes[CARD_EDITABLE_KEY],
};
//其它 data 属性
Object.keys(oldAttrs).forEach((attrName) => {

View File

@ -1,5 +1,6 @@
import { SchemaBlock, SchemaGlobal, SchemaRule } from '../types';
import {
CARD_EDITABLE_KEY,
CARD_KEY,
CARD_TYPE_KEY,
CARD_VALUE_KEY,
@ -62,6 +63,7 @@ const defualtSchema: Array<SchemaRule | SchemaBlock | SchemaGlobal> = [
required: true,
value: 'inline',
},
editable: '*',
value: '*',
},
},
@ -78,6 +80,7 @@ const defualtSchema: Array<SchemaRule | SchemaBlock | SchemaGlobal> = [
value: 'inline',
},
[CARD_VALUE_KEY]: '*',
[CARD_EDITABLE_KEY]: '*',
class: '*',
contenteditable: '*',
},
@ -95,6 +98,7 @@ const defualtSchema: Array<SchemaRule | SchemaBlock | SchemaGlobal> = [
value: 'inline',
},
[CARD_VALUE_KEY]: '*',
[CARD_EDITABLE_KEY]: '*',
class: '*',
contenteditable: '*',
},
@ -111,6 +115,7 @@ const defualtSchema: Array<SchemaRule | SchemaBlock | SchemaGlobal> = [
required: true,
value: 'block',
},
editable: '*',
value: '*',
},
},
@ -127,6 +132,7 @@ const defualtSchema: Array<SchemaRule | SchemaBlock | SchemaGlobal> = [
value: 'block',
},
[CARD_VALUE_KEY]: '*',
[CARD_EDITABLE_KEY]: '*',
class: '*',
contenteditable: '*',
},
@ -144,6 +150,7 @@ const defualtSchema: Array<SchemaRule | SchemaBlock | SchemaGlobal> = [
value: 'block',
},
[CARD_VALUE_KEY]: '*',
[CARD_EDITABLE_KEY]: '*',
class: '*',
contenteditable: '*',
},

View File

@ -166,7 +166,7 @@ class Engine implements EngineInterface {
// 历史
this.history = new History(this);
// 卡片
this.card = new CardModel(this);
this.card = new CardModel(this, this.options.lazyRender);
// 剪贴板
this.clipboard = new Clipboard(this);
// http请求
@ -440,7 +440,7 @@ class Engine implements EngineInterface {
this._container.destroy();
this.change.destroy();
this.hotkey.destroy();
this.card.gc();
this.card.destroy();
if (this.ot) {
this.ot.destroy();
}

View File

@ -1041,24 +1041,21 @@ class Mark implements MarkModelInterface {
});
selection?.move();
this.merge(safeRange);
if (isEditable) {
const markNodes: NodeInterface[] = [];
nodes.forEach((editableRoot) => {
editableRoot.allChildren().forEach((child) => {
if (child.isElement() && node.isMark(child)) {
markNodes.push(child);
}
});
});
this.mergeMarks(markNodes);
} else this.merge(safeRange);
if (!range) change?.apply(safeRange);
}
/**
* mark节点
* @param range
*/
merge(range?: RangeInterface): void {
if (!isEngine(this.editor)) return;
const { change, node } = this.editor;
const safeRange = range || change.range.toTrusty();
const marks = this.findMarks(safeRange);
if (marks.length === 0) {
return;
}
const selection = safeRange.shrinkToElementNode().createSelection();
const mergeMarks = (marks: Array<NodeInterface>) => {
mergeMarks(marks: Array<NodeInterface>) {
const { node } = this.editor;
marks.forEach((mark) => {
const prevMark = mark.prev();
const nextMark = mark.next();
@ -1103,11 +1100,24 @@ class Mark implements MarkModelInterface {
}
});
if (childMarks.length > 0) {
mergeMarks(childMarks);
this.mergeMarks(childMarks);
}
});
};
mergeMarks(marks);
}
/**
* mark节点
* @param range
*/
merge(range?: RangeInterface): void {
if (!isEngine(this.editor)) return;
const { change, node } = this.editor;
const safeRange = range || change.range.toTrusty();
const marks = this.findMarks(safeRange);
if (marks.length === 0) {
return;
}
const selection = safeRange.shrinkToElementNode().createSelection();
this.mergeMarks(marks);
selection.move();
safeRange.handleBr();
if (!range) change.apply(safeRange);
@ -1183,8 +1193,12 @@ class Mark implements MarkModelInterface {
nodes.forEach((ancestor) => {
ancestor.traverse(
(child) => {
if (child.isText() || !selection?.anchor) return;
if (isEditable || !child.equal(selection.anchor)) {
if (!isEditable && (child.isText() || !selection?.anchor))
return;
if (
isEditable ||
(selection?.anchor && !child.equal(selection.anchor))
) {
if (started) {
if (isEditable || !child.equal(selection?.focus!)) {
if (
@ -1201,6 +1215,7 @@ class Mark implements MarkModelInterface {
started = true;
// 光标开始位置在 <strong><anchor />abc123</strong> 就把 strong 加进去
if (
selection?.anchor &&
child.equal(selection.anchor) &&
!selection.anchor.prev()
) {
@ -1269,7 +1284,17 @@ class Mark implements MarkModelInterface {
}
});
selection?.move();
this.merge(safeRange);
if (isEditable) {
const markNodes: NodeInterface[] = [];
nodes.forEach((editableRoot) => {
editableRoot.allChildren().forEach((child) => {
if (child.isElement() && node.isMark(child)) {
markNodes.push(child);
}
});
});
this.mergeMarks(markNodes);
} else this.merge(safeRange);
if (!range) change.apply(safeRange);
}

View File

@ -5,7 +5,7 @@ import {
EDITABLE,
EDITABLE_SELECTOR,
} from '../constants/root';
import { CARD_TAG, CARD_TYPE_KEY } from '../constants/card';
import { CARD_EDITABLE_KEY, CARD_TAG, CARD_TYPE_KEY } from '../constants/card';
import { ANCHOR, CURSOR, FOCUS } from '../constants/selection';
import DOMEvent from './event';
import $ from './parse';
@ -167,8 +167,10 @@ class NodeEntry implements NodeInterface {
* @returns
*/
isEditableCard() {
const attributes = this.attributes();
return (
this.attributes(DATA_ELEMENT) === EDITABLE ||
attributes[DATA_ELEMENT] === EDITABLE ||
attributes[CARD_EDITABLE_KEY] === 'true' ||
(this.isElement() &&
!!(this[0] as Element).querySelector(EDITABLE_SELECTOR))
);
@ -398,7 +400,7 @@ class NodeEntry implements NodeInterface {
* @return NodeEntry
*/
find(selector: string): NodeInterface {
if (this.length > 0 && this.isElement()) {
if (this.length > 0 && (this.isElement() || this.fragment)) {
const nodeList = (
this.fragment ? this.fragment : this.get<Element>()
)?.querySelectorAll(selector);
@ -536,6 +538,8 @@ class NodeEntry implements NodeInterface {
if (!element) return {};
const attrs = {};
const elementAttributes = element.attributes;
if (!elementAttributes || elementAttributes.length === 0)
return attrs;
let i = 0,
item = null;
while ((item = elementAttributes[i])) {
@ -1019,14 +1023,14 @@ class NodeEntry implements NodeInterface {
return childNodes;
}
getViewport(node?: NodeInterface) {
getViewport() {
const { innerHeight, innerWidth } = this.window || {
innerHeight: 0,
innerWidth: 0,
};
let top, left, bottom, right;
if (node && node.length > 0) {
const element = node.get<Element>()!;
if (this.length > 0) {
const element = this.get<Element>()!;
const rect = element.getBoundingClientRect();
top = rect.top;
left = rect.left;
@ -1048,7 +1052,7 @@ class NodeEntry implements NodeInterface {
};
}
inViewport(node: NodeInterface, view: NodeInterface) {
inViewport(view: NodeInterface, simpleMode: boolean = false) {
let viewNode = null;
if (view.type !== Node.ELEMENT_NODE) {
if (!view.document) return false;
@ -1063,18 +1067,21 @@ class NodeEntry implements NodeInterface {
const viewElement = view[0] as Element;
const { top, left, right, bottom } =
viewElement.getBoundingClientRect();
const vp = this.getViewport(node);
const vp = this.getViewport();
if (viewNode) viewNode.parentNode?.removeChild(viewNode);
return !(
top < vp.top ||
bottom > vp.bottom ||
left < vp.left ||
right > vp.right
// 简单模式,只判断任一方向是否在视口内
if (simpleMode) {
return top <= vp.bottom || bottom <= vp.bottom;
}
return (
top >= vp.top &&
left >= vp.left &&
bottom <= vp.bottom &&
right <= vp.right
);
}
scrollIntoView(
node: NodeInterface,
view: NodeInterface,
align: 'start' | 'center' | 'end' | 'nearest' = 'nearest',
) {
@ -1089,7 +1096,7 @@ class NodeEntry implements NodeInterface {
view[0].parentNode?.insertBefore(viewElement, view[0]);
view = new NodeEntry(viewElement);
}
if (!this.inViewport(node, view)) {
if (!this.inViewport(view)) {
view.get<Element>()?.scrollIntoView({
block: align,
inline: align,

View File

@ -493,8 +493,13 @@ class NodeModel implements NodeModelInterface {
*
* @param node
* @param range
* @param removeCurrentEmptyBlock
*/
insert(node: Node | NodeInterface, range?: RangeInterface) {
insert(
node: Node | NodeInterface,
range?: RangeInterface,
removeCurrentEmptyBlock: boolean = false,
) {
if (isNodeEntry(node)) {
if (node.length === 0) throw 'Not found node';
node = node[0];
@ -545,7 +550,9 @@ class NodeModel implements NodeModelInterface {
this.isBlock(commonAncestorNode) &&
this.isEmpty(commonAncestorNode)
) {
splitNode = commonAncestorNode;
splitNode = removeCurrentEmptyBlock
? commonAncestorNode
: undefined;
} else splitNode = block.split(range);
let blockNode = block.closest(
range.startNode.isEditable()

View File

@ -13,7 +13,7 @@ import { NodeInterface } from '../types/node';
import { getDocument } from '../utils';
import { isCursorOp, isTransientElement, updateIndex, toDOM } from './utils';
import { $ } from '../node';
import { DATA_ID, EDITABLE_SELECTOR } from '../constants';
import { CARD_LOADING_KEY, DATA_ID, EDITABLE_SELECTOR } from '../constants';
import { RangePath } from '../types';
import { isNodeEntry } from '../node/utils';
@ -197,12 +197,17 @@ class Consumer implements ConsumerInterface {
return domNode;
}
insertNode(root: NodeInterface, path: Path, value: string | Op[] | Op[][]) {
insertNode(
root: NodeInterface,
path: Path,
value: string | Op[] | Op[][],
isRemote?: boolean,
) {
const { engine } = this;
const { startNode, endNode } = this.getElementFromPath(root, path);
const domBegine = $(startNode);
const domEnd = $(endNode);
if (domEnd.length > 0 && !domBegine.isRoot()) {
if (domEnd.length > 0 && !domBegine.isRoot() && !root.isCard()) {
const element =
typeof value === 'string'
? document.createTextNode(value)
@ -213,6 +218,9 @@ class Consumer implements ConsumerInterface {
domEnd.get()?.insertBefore(element, null);
}
const node = $(element);
if (node.isCard()) {
node.attributes(CARD_LOADING_KEY, isRemote ? 'remote' : 'true');
}
engine.card.render(node);
return node;
}
@ -301,7 +309,7 @@ class Consumer implements ConsumerInterface {
} else if ('ld' in op) {
return this.deleteNode(root, path, isRemote);
} else if ('li' in op) {
return this.insertNode(root, path, op.li);
return this.insertNode(root, path, op.li, isRemote);
}
return;
}

View File

@ -115,7 +115,7 @@ class OTModel extends EventEmitter2 implements OTInterface {
handleChange(ops: Op[]) {
this.submitOps(ops);
this.engine.history.handleSelfOps(ops);
this.engine.history.handleSelfOps(ops.filter((op) => !op['nl']));
if (this.doc && this.doc?.type !== null) {
this.updateRangeColoringPosition();
}

View File

@ -21,8 +21,16 @@ import { Op, Path, StringInsertOp, StringDeleteOp, Doc } from 'sharedb';
import { NodeInterface } from '../types/node';
import { DocInterface, RepairOp } from '../types/ot';
import { $ } from '../node';
import { DATA_ID, JSON0_INDEX, UI_SELECTOR } from '../constants';
import {
CARD_ELEMENT_KEY,
CARD_KEY,
CARD_LOADING_KEY,
DATA_ID,
JSON0_INDEX,
UI_SELECTOR,
} from '../constants';
import { getDocument } from '../utils/node';
import { CardValue } from 'src';
class Producer extends EventEmitter2 {
private engine: EngineInterface;
@ -95,6 +103,7 @@ class Producer extends EventEmitter2 {
isTransientMutation(
record: MutationRecord,
transientElements?: Array<Node>,
loadingCards?: NodeInterface[],
) {
const { addedNodes, removedNodes, target, type, attributeName } =
record;
@ -110,17 +119,21 @@ class Producer extends EventEmitter2 {
childs.push(targetNode);
if (
childs.some((child) =>
isTransientElement(child, transientElements),
isTransientElement(child, transientElements, loadingCards),
)
)
return true;
}
return (
(type === 'attributes' &&
(isTransientAttribute(targetNode, attributeName || '') ||
isTransientElement(targetNode, transientElements))) ||
(isTransientElement(
targetNode,
transientElements,
loadingCards,
) ||
isTransientAttribute(targetNode, attributeName || ''))) ||
(type === 'characterData' &&
isTransientElement(targetNode, transientElements))
isTransientElement(targetNode, transientElements, loadingCards))
);
}
@ -242,14 +255,18 @@ class Producer extends EventEmitter2 {
p = p.concat([...path!], [rIndex]);
let op: Path = [];
op = op.concat([...oldPath], [rIndex]);
ops.push({
const newOp = {
id: rootId,
bi: beginIndex,
ld: true,
p,
newPath: p.slice(),
oldPath: op,
});
};
if (record['nl']) {
newOp['nl'] = true;
}
ops.push(newOp);
}
});
}
@ -272,8 +289,9 @@ class Producer extends EventEmitter2 {
});
const index =
getIndex(domAddedNode) + JSON0_INDEX.ELEMENT;
let p: Path = [];
p = p.concat([...path!], [index]);
let p: Path = [...(path || [])];
// 卡片没有完全渲染就在插入body位置插入
p = p.concat([index]);
const op = {
id: rootId,
bi: beginIndex,
@ -282,6 +300,10 @@ class Producer extends EventEmitter2 {
p,
newPath: p.slice(),
};
if (record['nl']) {
op['nl'] = true;
}
ops.push(op);
cacheNodes.push(addedNode);
this.cacheNode(addedNode);
@ -296,13 +318,17 @@ class Producer extends EventEmitter2 {
typeof getValue(this.doc?.data, oldPath) === 'string' &&
(record['text-data'] || target['data']).length > 0
) {
attrOps.push({
const newOp = {
id: rootId,
bi: beginIndex,
path,
oldPath,
newValue: record['text-data'] || target['data'],
});
};
if (record['nl']) {
newOp['nl'] = true;
}
attrOps.push(newOp);
isValueString = true;
}
}
@ -325,7 +351,9 @@ class Producer extends EventEmitter2 {
);
if (oldValue) newOp.od = oldValue;
if (attrValue) newOp.oi = attrValue;
if (record['nl']) {
newOp.nl = true;
}
attrOps.push(newOp);
}
}
@ -342,6 +370,7 @@ class Producer extends EventEmitter2 {
bi: beginIndex,
ld: pathValue,
p: op.p,
nl: op['nl'],
};
// 重复删除的过滤掉
if (
@ -360,8 +389,9 @@ class Producer extends EventEmitter2 {
bi: beginIndex,
li: op.li,
p: op.p,
nl: op['nl'],
addNode: op['addNode'],
});
} as any);
}
});
allOps.push(...attrOps);
@ -374,12 +404,46 @@ class Producer extends EventEmitter2 {
return op;
});
}
/**
* doc
* @param data
* @param name
* @param callback
* @returns
*/
findCardForDoc = (
data: any,
name: string,
callback?: (attriables: { [key: string]: string }) => boolean,
): { attriables: any; rendered: boolean } | void => {
for (let i = 1; i < data.length; i++) {
if (i === 1) {
const attriables = data[i];
if (attriables && attriables['data-card-key'] === name) {
if (callback && callback(attriables)) {
const body = data[i + 1];
return {
attriables,
rendered:
Array.isArray(body) &&
Array.isArray(body[2]) &&
Array.isArray(body[2][2]),
};
}
}
} else if (Array.isArray(data[i])) {
const result = this.findCardForDoc(data[i], name, callback);
if (result) return result;
}
}
};
/**
* DOM节点变更记录
* @param records
*/
handleMutations(records: MutationRecord[]) {
// 初次加载中的卡片在提交ops后把loading状态移除
const loadingCards: NodeInterface[] = [];
//需要先过滤标记为非协同节点的变更,包括 data-element=ui、data-transient-element 等标记的节点,可以在 isTransientMutation 中查看逻辑
//记录大于300的时候先获取所有的不需要参与协同交互的节点以提高效率
if (records.length > 299) {
@ -387,10 +451,16 @@ class Producer extends EventEmitter2 {
//非可编辑卡片的子节点
const { card, container } = this.engine;
card.each((card) => {
if (!card.isEditable) {
// 增加异步加载的卡片子节点
if (!card.isEditable || card.loading) {
// 正在加载的可编辑卡片要获取内部子节点
const isEditableLoading = card.isEditable && card.loading;
card.root.allChildren().forEach((child) => {
if (child.type === getDocument().ELEMENT_NODE)
if (child.type === getDocument().ELEMENT_NODE) {
if (isEditableLoading)
child[0]['__card_root'] = card.root;
this.cacheTransientElements?.push(child[0]);
}
});
}
});
@ -405,11 +475,44 @@ class Producer extends EventEmitter2 {
});
}
const targetElements: Node[] = [];
const cardMap = new Map<Node, boolean>();
records = records.filter((record) => {
const isTransient = this.isTransientMutation(
const beginCardIndex = loadingCards.length;
let isTransient = this.isTransientMutation(
record,
this.cacheTransientElements,
loadingCards,
);
// 判断要过滤的卡片是否在协同数据中渲染成功
if (loadingCards.length > beginCardIndex && this.doc) {
const cardElement = loadingCards[loadingCards.length - 1];
const tMapValue = cardMap.get(cardElement[0]);
if (tMapValue === undefined && cardElement.isEditableCard()) {
const cardName = cardElement.attributes(CARD_KEY);
const result = this.findCardForDoc(
this.doc.data,
cardName,
(attriables) => {
return (
attriables[DATA_ID] ===
cardElement.attributes(DATA_ID)
);
},
);
if (
!result?.rendered &&
cardElement.attributes(CARD_LOADING_KEY) !== 'remote'
) {
isTransient = false;
record['nl'] = true;
} else {
isTransient = true;
}
cardMap.set(cardElement[0], isTransient);
} else if (tMapValue !== undefined) {
isTransient = tMapValue;
}
}
if (
!isTransient &&
!targetElements.includes(record.target) &&
@ -431,15 +534,17 @@ class Producer extends EventEmitter2 {
return !isTransient;
});
this.clearCache();
let ops = this.generateOps(records);
//重置缓存
this.cacheTransientElements = undefined;
let ops = this.generateOps(records);
ops = filterOperations(ops);
if (!ops.every((op) => isCursorOp(op))) {
targetElements.map((element) => {
targetElements.forEach((element) => {
let node = $(element);
if (node.isEditable() && !node.isRoot()) {
node = this.engine.card.find(node, true)?.root || node;
const card = this.engine.card.find(node, true);
node = card?.root || node;
}
updateIndex(
node,

View File

@ -1,7 +1,13 @@
import { isEqual } from 'lodash-es';
import { NodeInterface } from '../types/node';
import { FOCUS, ANCHOR, CURSOR } from '../constants/selection';
import { CARD_KEY, CARD_SELECTOR, READY_CARD_KEY } from '../constants/card';
import {
CARD_EDITABLE_KEY,
CARD_KEY,
CARD_LOADING_KEY,
CARD_SELECTOR,
READY_CARD_KEY,
} from '../constants/card';
import {
Op,
Path,
@ -24,19 +30,21 @@ import { getParentInRoot, toHex, unescapeDots, unescape } from '../utils';
export const isTransientElement = (
node: NodeInterface,
transientElements?: Array<Node>,
loadingCards?: NodeInterface[],
) => {
if (node.isElement()) {
const nodeAttributes = node.attributes();
//范围标记
if (
[CURSOR, ANCHOR, FOCUS].indexOf(node.attributes(DATA_ELEMENT)) > -1
[CURSOR, ANCHOR, FOCUS].indexOf(nodeAttributes[DATA_ELEMENT]) > -1
) {
return true;
}
//data-element=ui 属性
if (
!!node.attributes(DATA_TRANSIENT_ELEMENT) ||
node.attributes(DATA_ELEMENT) === UI
!!nodeAttributes[DATA_TRANSIENT_ELEMENT] ||
nodeAttributes[DATA_ELEMENT] === UI
) {
return true;
}
@ -45,28 +53,35 @@ export const isTransientElement = (
const isCard = node.isCard();
//父级是卡片,并且没有可编辑区域
const parentIsLoading = parent?.attributes(CARD_LOADING_KEY);
if (parentIsLoading && parent) loadingCards?.push(parent);
if (!isCard && parent?.isCard() && !parent.isEditableCard()) {
return true;
}
if (transientElements) {
if (
!isCard &&
transientElements.find((element) => element === node[0])
)
if (isCard) return false;
const element = transientElements.find(
(element) => element === node[0],
);
if (element) {
if (element['__card_root'])
loadingCards?.push(element['__card_root']);
return true;
} else {
}
}
let closestNode = node.closest(
`${CARD_SELECTOR},${UI_SELECTOR}`,
getParentInRoot,
);
if (
closestNode.length > 0 &&
closestNode.attributes(DATA_ELEMENT) === UI
) {
const attributes = closestNode?.attributes() || {};
if (closestNode.length > 0 && attributes[DATA_ELEMENT] === UI) {
return true;
}
//在卡片里面,并且卡片不是可编辑卡片 或者是标记为正在异步渲染时的卡片
//在卡片里面,并且卡片不是可编辑卡片 或者是标记为正在异步渲染时的卡片的子节点
if (attributes[CARD_LOADING_KEY]) {
loadingCards?.push(closestNode);
}
if (
!isCard &&
closestNode.length > 0 &&
@ -76,10 +91,15 @@ export const isTransientElement = (
return true;
}
if (closestNode.length === 0) return false;
}
if (!isCard || node.isEditableCard()) return false;
//当前是卡片,父级也是卡片
const parentCard = parent?.closest(CARD_SELECTOR, getParentInRoot);
// 如果父级是可编辑卡片,并且在加载中,过滤掉其子节点
const loadingCard = parentCard?.attributes(CARD_LOADING_KEY);
if (loadingCard && parentCard) {
loadingCards?.push(parentCard);
}
if (parentCard && parentCard.isCard() && !parentCard.isEditableCard()) {
return true;
}
@ -89,7 +109,13 @@ export const isTransientElement = (
export const isTransientAttribute = (node: NodeInterface, attr: string) => {
if (node.isRoot() && !/^data-selection-/.test(attr)) return true;
if (node.isCard() && ['id', 'class', 'style'].includes(attr)) return true;
if (
node.isCard() &&
['id', 'class', 'style', CARD_LOADING_KEY, CARD_EDITABLE_KEY].includes(
attr,
)
)
return true;
const transient = node.attributes(DATA_TRANSIENT_ATTRIBUTES);
if (
transient === '*' ||
@ -218,6 +244,10 @@ export const opsSort = (ops: Op[]) => {
if (diff === -1) return 1;
if (diff === 0) return 0;
}
// sd 小于ld就放在前面
if ('ld' in op2) {
return diff;
}
return -1;
}
// 属性删除,排在节点删除最前面
@ -246,7 +276,11 @@ export const opsSort = (ops: Op[]) => {
('ld' in op1 && 'ld' in op2) || ('od' in op1 && 'od' in op2);
// 都是新增节点,越小排越前面
if (isLi) {
if (op1.p.length < op2.p.length) return -1;
if (
op1.p.length < op2.p.length &&
op1.p.every((p, i) => p <= op2.p[i])
)
return -1;
return diff;
}
// 都是删除节点,越大排越前面

View File

@ -1,11 +1,6 @@
import { cloneDeep } from 'lodash-es';
import {
CARD_KEY,
CARD_TYPE_KEY,
CARD_VALUE_KEY,
DATA_ELEMENT,
READY_CARD_KEY,
} from '../constants';
import { getStyleMap } from '../utils';
import { DATA_ELEMENT } from '../constants';
import {
EditorInterface,
NodeInterface,
@ -48,7 +43,7 @@ class Conversion implements ConversionInterface {
) {
let name = node.name;
let attributes = node.attributes();
let style = node.css();
let style = getStyleMap(attributes.style || '');
//删除属性中的style属性
delete attributes.style;
// 光标相关节点

View File

@ -22,6 +22,7 @@ import {
toHex,
transformCustomTags,
getListStyle,
getStyleMap,
} from '../utils';
import TextParser from './text';
import { $ } from '../node';
@ -186,7 +187,7 @@ class Parser implements ParserInterface {
const filter = (node: NodeInterface) => {
//获取节点属性样式
const attributes = node.attributes();
const style = node.css();
const style = getStyleMap(attributes.style || '');
delete attributes.style;
if (
Object.keys(attributes).length === 0 &&
@ -281,7 +282,7 @@ class Parser implements ParserInterface {
if (child.isElement()) {
let name = child.name;
let attributes = child.attributes();
let styles = child.css();
let styles = getStyleMap(attributes.style || '');
//删除属性中的style属性
delete attributes.style;
@ -486,8 +487,9 @@ class Parser implements ParserInterface {
*/
toHTML(inner?: Node, outter?: Node) {
const element = $('<div />');
const style = this.editor.container.css();
if (inner && outter) {
$(inner).append(this.root).css(this.editor.container.css());
$(inner).append(this.root).css(style);
element.append(outter);
} else {
element.append(this.root);
@ -500,7 +502,7 @@ class Parser implements ParserInterface {
}
});
this.editor.trigger('parse:html', element);
element.find('p').css(this.editor.container.css());
element.find('p').css(style);
this.editor.trigger('parse:html-after', element);
return element.html();
}

View File

@ -580,16 +580,15 @@ class Range implements RangeInterface {
}
};
scrollIntoViewIfNeeded = (node: NodeInterface, view: NodeInterface) => {
scrollIntoViewIfNeeded = (view: NodeInterface) => {
if (this.collapsed) {
node.scrollIntoView(view, $(this.getEndOffsetNode()));
this.editor.container.scrollIntoView($(this.getEndOffsetNode()));
} else {
const startNode = this.getStartOffsetNode();
const endNode = this.getEndOffsetNode();
node.scrollIntoView(view, $(startNode));
if (!node.inViewport(view, $(endNode)))
node.scrollIntoView(view, $(endNode));
$(startNode).scrollIntoView(view);
$(endNode).scrollIntoView(view);
}
};

View File

@ -122,6 +122,10 @@ export interface CardEntry {
* toolbar
*/
readonly toolbarFollowMouse: boolean;
/**
* false
*/
readonly lazyRender: boolean;
}
export interface CardInterface {
@ -157,6 +161,7 @@ export interface CardInterface {
*
*/
readonly contenteditable: Array<string>;
readonly loading: boolean;
/**
* card重新渲染
*/
@ -300,6 +305,10 @@ export interface CardInterface {
*
*/
minimize(): void;
/**
*
*/
beforeRender?(): void;
/**
*
*/
@ -553,6 +562,8 @@ export interface CardModelInterface {
range: RangeInterface,
hasModify: boolean,
): void;
destroy(): void;
}
export interface MaximizeInterface {

View File

@ -73,6 +73,10 @@ export interface EditorInterface {
*
*/
container: NodeInterface;
/**
*
*/
readonly scrollNode: NodeInterface | null;
/**
*
*/
@ -465,6 +469,10 @@ export type EngineOptions = {
*
*/
readonly?: boolean;
/**
* lazyRender true
*/
lazyRender?: boolean;
};
export interface Engine {
@ -479,10 +487,6 @@ export interface EngineInterface extends EditorInterface {
*
*/
options: EngineOptions;
/**
*
*/
readonly scrollNode: NodeInterface | null;
/**
*
*/

View File

@ -508,7 +508,7 @@ export interface NodeInterface {
* window对象的视图边界
* @param node
*/
getViewport(node?: NodeInterface): {
getViewport(): {
top: number;
left: number;
bottom: number;
@ -517,19 +517,17 @@ export interface NodeInterface {
/**
* view是否在node节点根据当前节点的顶级window对象计算的视图边界内
* @param node
* @param view
* @param simpleMode true
*/
inViewport(node: NodeInterface, view: NodeInterface): boolean;
inViewport(view: NodeInterface, simpleMode?: boolean): boolean;
/**
* view节点不可见align位置nearest
* @param node
* @param view
* @param align
*/
scrollIntoView(
node: NodeInterface,
view: NodeInterface,
align?: 'start' | 'center' | 'end' | 'nearest',
): void;
@ -634,10 +632,12 @@ export interface NodeModelInterface {
*
* @param node
* @param range
* @param removeCurrentEmptyBlock
*/
insert(
node: Node | NodeInterface,
range?: RangeInterface,
removeCurrentEmptyBlock?: boolean,
): RangeInterface | undefined;
/**
*

View File

@ -52,4 +52,12 @@ export type ContentViewOptions = {
*
*/
root?: Node;
/**
* overflow overflow-y auto scroll
*/
scrollNode?: Node | (() => Node | null);
/**
* lazyRender true
*/
lazyRender?: boolean;
};

View File

@ -30,12 +30,13 @@ class Backspace implements TypingHandleInterface {
}
trigger(event: KeyboardEvent) {
const { change } = this.engine;
const { change, container } = this.engine;
const range = change.range.get();
change.cacheRangeBeforeCommand();
// 编辑器没有内容
if (change.isEmpty()) {
event.preventDefault();
container.empty();
change.initValue();
return;
}

View File

@ -1,5 +1,6 @@
import { ANCHOR, CURSOR, FOCUS } from '../constants/selection';
import {
CARD_EDITABLE_KEY,
CARD_TYPE_KEY,
CARD_VALUE_KEY,
READY_CARD_KEY,
@ -112,7 +113,7 @@ export const getStyleMap = (style: string): { [k: string]: string } => {
const key = match[1].toLowerCase().trim();
let val = match[2].trim();
if (val.toLowerCase().includes('rgb')) {
val = toHex(match[2]);
val = toHex(val);
}
map[key] = val;
}
@ -241,12 +242,18 @@ export const transformCustomTags = (value: string) => {
.replace(/(<card\s+[^>]+>).*?<\/card>/gi, (_, tag) => {
//获取Card属性
const attributes = getAttrMap(tag);
const { type, name, value } = attributes;
const { type, name, value, editable } = attributes;
const isInline = type === 'inline';
const tagName = isInline ? 'span' : 'div';
const list = ['<'.concat(tagName)];
list.push(' '.concat(CARD_TYPE_KEY, '="').concat(type || '', '"'));
list.push(' '.concat(READY_CARD_KEY, '="').concat(name || '', '"'));
if (editable !== '')
list.push(
' '
.concat(CARD_EDITABLE_KEY, '="')
.concat(editable || '', '"'),
);
Object.keys(attributes).forEach((attrsName) => {
if (
attrsName.indexOf('data-') === 0 &&

View File

@ -63,6 +63,7 @@ class View implements ViewInterface {
command: CommandInterface;
request: RequestInterface;
nodeId: NodeIdInterface;
#_scrollNode: NodeInterface | null = null;
constructor(selector: Selector, options?: ContentViewOptions) {
this.options = { ...this.options, ...options };
@ -72,7 +73,7 @@ class View implements ViewInterface {
this.schema = new Schema();
this.schema.add(schemaDefaultData);
this.conversion = new Conversion(this);
this.card = new CardModel(this);
this.card = new CardModel(this, this.options.lazyRender);
this.clipboard = new Clipboard(this);
this.plugin = new PluginModel(this);
this.node = new NodeModel(this);
@ -97,6 +98,37 @@ class View implements ViewInterface {
this.nodeId.init();
}
setScrollNode(node?: HTMLElement) {
this.#_scrollNode = node ? $(node) : null;
}
get scrollNode(): NodeInterface | null {
if (this.#_scrollNode) return this.#_scrollNode;
const { scrollNode } = this.options;
let sn = scrollNode
? typeof scrollNode === 'function'
? scrollNode()
: scrollNode
: null;
// 查找父级样式 overflow 或者 overflow-y 为 auto 或者 scroll 的节点
const targetValues = ['auto', 'scroll'];
let parent = this.container.parent();
while (parent && parent.length > 0 && parent.name !== 'body') {
if (
targetValues.includes(parent.css('overflow')) ||
targetValues.includes(parent.css('overflow-y'))
) {
sn = parent.get<HTMLElement>();
break;
} else {
parent = parent.parent();
}
}
if (sn === null) sn = document.documentElement;
this.#_scrollNode = sn ? $(sn) : null;
return this.#_scrollNode;
}
on(eventType: string, listener: EventListener, rewrite?: boolean) {
this.event.on(eventType, listener, rewrite);
return this;

View File

@ -42,6 +42,10 @@ class CodeBlcok extends Card<CodeBlockValue> {
return modeDatas;
}
static get lazyRender() {
return true;
}
resize = () => {
return this.codeEditor?.container.find('.data-codeblock-content');
};
@ -121,6 +125,7 @@ class CodeBlcok extends Card<CodeBlockValue> {
focusEditor() {
this.codeEditor?.focus();
this.editor.card.activate(this.root);
}
render() {

View File

@ -38,6 +38,10 @@ class CodeBlcok extends Card<CodeBlockValue> {
return false;
}
static get lazyRender() {
return true;
}
static getModes() {
return modeDatas;
}
@ -118,6 +122,7 @@ class CodeBlcok extends Card<CodeBlockValue> {
focusEditor() {
this.codeEditor?.focus();
this.editor.card.activate(this.root);
}
render() {

View File

@ -158,17 +158,26 @@ export default class extends InlinePlugin<Options> {
(match = reg.exec(textNode.textContent))
) {
//从匹配到的位置切断
let regNode = textNode.splitText(match.index + 1);
let regNode = textNode.splitText(match.index);
if (
textNode.textContent.endsWith('!') ||
match[2].startsWith('!')
) {
newText += textNode.textContent;
textNode = regNode.splitText(match[0].length);
newText += regNode.textContent;
continue;
}
newText += textNode.textContent;
//从匹配结束位置分割
textNode = regNode.splitText(match[0].length - 1);
textNode = regNode.splitText(match[0].length);
const text = match[2];
const url = match[3];
const inlineNode = $(`<${this.tagName} />`);
this.setAttributes(inlineNode, '_blank', url);
inlineNode.text(!!text ? text : url);
inlineNode.html(!!text ? text : url);
newText += inlineNode.get<Element>()?.outerHTML;
}

View File

@ -45,30 +45,6 @@ keys: Array<string>
//例如评论 keys = ["comment"]
```
### 标记节点改变回调
在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调
在使用 撤销、重做 相关操作时,也会触发此回调
addIds: 新增的标记节点编号集合
removeIds: 删除的标记节点编号集合
ids: 所有有效的标记节点编号集合
```ts
onChange?: (addIds: { [key: string]: Array<string>},removeIds: { [key: string]: Array<string>},ids: { [key:string] : Array<string> }) => void
```
### 选中标记节时点回调
在光标改变时触发selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号
```ts
onSelect? : (range: RangeInterface, selectInfo?: { key: string, id: string}) => void
```
### 快捷键
默认无快捷键
@ -164,6 +140,36 @@ value 默认获取当前编辑器根节点中的 html 作为值
engine.command.executeMethod('mark-range', 'action', key: string, 'wrap', paths: Array<{ id: Array<string>, path: Array<Path>}>, value?: string): string
```
## 事件
### 标记节点改变回调
在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调
在使用 撤销、重做 相关操作时,也会触发此回调
addIds: 新增的标记节点编号集合
removeIds: 删除的标记节点编号集合
ids: 所有有效的标记节点编号集合
```ts
engine.on('mark-range:change', (addIds: { [key: string]: Array<string>},removeIds: { [key: string]: Array<string>},ids: { [key:string] : Array<string> }) => {
...
})
```
### 选中标记节时点回调
在光标改变时触发selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号
```ts
engine.on('mark-range:select', (range: RangeInterface, selectInfo?: { key: string, id: string}) => {
...
})
```
## 样式定义
```css

View File

@ -18,15 +18,6 @@ import { Path } from 'sharedb';
export interface Options extends PluginOptions {
keys: Array<string>;
hotkey?: string | Array<string>;
onChange?: (
addIds: { [key: string]: Array<string> },
removeIds: { [key: string]: Array<string> },
ids: { [key: string]: Array<string> },
) => void;
onSelect?: (
range: RangeInterface,
selectInfo?: { key: string; id: string },
) => void;
}
const PLUGIN_NAME = 'mark-range';
@ -262,7 +253,6 @@ export default class extends MarkPlugin<Options> {
markNode.attributes(this.getPreviewName(key), 'true');
});
} else if (this.range) {
const { onSelect } = this.options;
const { block, node, card } = this.editor;
let range = this.range;
//光标重合时选择整个block块
@ -270,15 +260,15 @@ export default class extends MarkPlugin<Options> {
const blockNode = block.closest(range.startNode);
if (!node.isBlock(blockNode)) return;
range.select(blockNode, true);
if (isEngine(this.editor)) {
this.editor.change.range.select(range);
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range.toRange());
}
const selectInfo = this.getSelectInfo(range, true);
//当前光标已存在标记
if (selectInfo && selectInfo.key === key) {
//触发选择
if (onSelect) onSelect(range, selectInfo);
this.editor.trigger(`${PLUGIN_NAME}:select`, range, selectInfo);
return;
}
//包裹标记预览样式
@ -544,14 +534,13 @@ export default class extends MarkPlugin<Options> {
if (!selection) return;
const range = Range.from(this.editor, selection);
if (!range) return;
const { onSelect } = this.options;
//不在编辑器内
if (
!$(range.getStartOffsetNode()).inEditor() ||
!$(range.getEndOffsetNode()).inEditor()
) {
if (onSelect) onSelect(range);
this.editor.trigger(`${PLUGIN_NAME}:select`, range);
this.range = undefined;
return;
}
@ -559,12 +548,11 @@ export default class extends MarkPlugin<Options> {
this.triggerChange();
const selectInfo = this.getSelectInfo(range, true);
if (onSelect) onSelect(range, selectInfo);
this.editor.trigger(`${PLUGIN_NAME}:select`, range, selectInfo);
this.range = range;
}
triggerChange() {
const { onChange } = this.options;
const addIds: { [key: string]: Array<string> } = {};
const removeIds: { [key: string]: Array<string> } = {};
const ids = this.getIds();
@ -585,7 +573,7 @@ export default class extends MarkPlugin<Options> {
});
});
this.ids = ids;
if (onChange) onChange(addIds, removeIds, ids);
this.editor.trigger(`${PLUGIN_NAME}:change`, addIds, removeIds, ids);
}
/**

View File

@ -49,6 +49,10 @@ class TableComponent extends Card<TableValue> implements TableInterface {
return false;
}
static get lazyRender() {
return true;
}
static colors = Palette.getColors().map((group) =>
group.map((color) => {
return { color, border: Palette.getStroke(color) };
@ -257,7 +261,7 @@ class TableComponent extends Card<TableValue> implements TableInterface {
endElement.name !== 'td' ||
startElement.equal(endElement)
)
return [range];
return;
const startRect = startElement
.get<HTMLElement>()!

View File

@ -358,12 +358,19 @@ class Table extends Plugin<Options> {
root.find(`[${CARD_KEY}=${TableComponent.cardName}`).each(
(tableNode) => {
const node = $(tableNode);
const table = node.find('table');
const card = this.editor.card.find(node) as TableComponent;
const value = card?.getValue();
if (value && value.html) {
let table = node.find('table');
if (table.length === 0) {
table = $(value.html);
if (table.length === 0) {
node.remove();
return;
}
const width = table.attributes('width') || table.css('width');
}
const width =
table.attributes('width') || table.css('width');
table.css({
outline: 'none',
'border-collapse': 'collapse',
@ -388,10 +395,13 @@ class Table extends Plugin<Options> {
});
});
table.find(Template.TABLE_TD_BG_CLASS).remove();
table.find(Template.TABLE_TD_CONTENT_CLASS).each((content) => {
table
.find(Template.TABLE_TD_CONTENT_CLASS)
.each((content) => {
this.editor.node.unwrap($(content));
});
node.replaceWith(table);
}
},
);
}