diff --git a/.dumi/theme/components/Navbar.less b/.dumi/theme/components/Navbar.less index 6a32ee32..fa65e6c5 100644 --- a/.dumi/theme/components/Navbar.less +++ b/.dumi/theme/components/Navbar.less @@ -72,7 +72,7 @@ display: flex; > span { position: relative; - margin-left: 40px; + margin-left: 24px; display: inline-block; color: @c-text; height: @s-nav-height; @@ -113,7 +113,7 @@ } + *:not(a) { - margin-left: 40px; + margin-left: 24px; } // second nav diff --git a/.gitignore b/.gitignore index c32275d8..2f07a329 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ # log *.log -.vscode \ No newline at end of file +.vscode + +**/ffmpeg \ No newline at end of file diff --git a/docs/plugin/plugin-mention.md b/docs/plugin/plugin-mention.md index e5860e7d..6f66a8ef 100644 --- a/docs/plugin/plugin-mention.md +++ b/docs/plugin/plugin-mention.md @@ -31,47 +31,7 @@ new Engine(...,{ }) ``` -`defaultData`: Default drop-down query list display data - -`onSearch`: the method to query, or configure the action, choose one of the two - -`onSelect`: Call back after selecting an item in the list, here you can return a custom value combined with key and name to form a new value and store it in cardValue. And it will return together after executing the getList command - -`onClick`: Triggered when clicking on the "mention" - -`onMouseEnter`: Triggered when the mouse moves over the "mention" - -`onRender`: custom rendering list - -`onRenderItem`: custom rendering list item - -`onLoading`: custom rendering loading status - -`onEmpty`: custom render empty state - -`action`: query address, always use `GET` request, parameter `keyword` - -`data`: When querying, these data will be sent to the server at the same time - ```ts -//List data displayed by default -defaultData?: Array<{ key: string, name: string, avatar?: string}> -//Method for query, or configure action, choose one of the two -onSearch?:(keyword: string) => Promise> -//Call back after selecting an item in the list, here you can return a custom value combined with key and name to form a new value and store it in cardValue. And it will return together after executing the getList command -onSelect?: (data: {[key:string]: string}) => void | {[key: string]: string} -//Click event on "mention" -onClick?:(data: {[key:string]: string}) => void -// Triggered when the mouse moves over the "mention" -onMouseEnter?:(node: NodeInterface, data: {[key:string]: string}) => void -//Customize the rendering list, bindItem can bind the required properties and events for the list item -onRender?: (data: MentionItem, root: NodeInterface, bindItem: (node: NodeInterface, data: {[key:string]: string}) => NodeInterface) => Promise; -//Custom rendering list items -onRenderItem?: (item: MentionItem, root: NodeInterface) => string | NodeInterface | void -// Customize the rendering loading status -onLoading?: (root: NodeInterface) => string | NodeInterface | void -// Custom render empty state -onEmpty?: (root: NodeInterface) => string | NodeInterface | void /** * look for the address */ @@ -124,3 +84,136 @@ Get all mentions in the document //Return Array<{ key: string, name: string}> engine.command.executeMethod('mention', 'getList'); ``` + +## Plug-in events + +`mention:default`: default drop-down query list to display data + +```ts +this.engine.on('mention:default', () => { + return []; +}); +``` + +`mention:search`: Method of query, or configure action, choose one of the two + +```ts +this.engine.on('mention:search', (keyword) => { + return new Promise((resolve) => { + query({ keyword }) + .then((result) => { + resolve(result); + }) + .catch(() => resolve([])); + }); +}); +``` + +`mention:select`: Call back after selecting an item in the list, here you can return a custom value combined with key and name to form a new value and store it in cardValue. And will return together after executing the getList command + +```ts +this.engine.on('mention:select', (data) => { + data['test'] = 'test'; + return data; +}); +``` + +`mention:item-click`: triggered when clicking on "mention" + +```ts +this.engine.on( + 'mention:item-click', + (root: NodeInterface, { key, name }: { key: string; name: string }) => { + console.log('mention click:', key, '-', name); + }, +); +``` + +`mention:enter`: Triggered when the mouse moves over the "mention" + +```ts +this.engine.on( + 'mention:enter', + (layout: NodeInterface, { name }: { key: string; name: string }) => { + ReactDOM.render( +
+

This is name: {name}

+

Configure the mention:enter event of the mention plugin

+

Use ReactDOM.render to customize rendering here

+

Use ReactDOM.render to customize rendering here

+
, + layout.get()!, + ); + }, +); +``` + +`mention:render`: custom rendering list + +```ts +this.engine.on( + 'mention:render', + ( + root: NodeInterface, + data: Array, + bindItem: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => NodeInterface, + ) => { + return new Promise((resolve) => { + const renderCallback = (items: { [key: string]: Element }) => { + // Traverse the DOM node of each item + Object.keys(items).forEach((key) => { + const element = items[key]; + const item = data.find((d) => d.key === key); + if (!item) return; + // Bind the attributes and events of each list item to meet the functional needs of the up, down, left, and right selection in the editor + bindItem($(element), item); + }); + resolve(); + }; + ReactDOM.render( + , + root.get()!, + ); + }); + }, +); +``` + +`mention:render-item`: custom rendering list item + +```ts +this.engine.on('mention:render-item', (data, root) => { + const item = $(`
${data}
`); + root.append(item); + return item; +}); +``` + +`mention:loading`: custom rendering loading status + +```ts +this.engine.on('mention:loading', (data, root) => { + root.html(`
${data}
`); + // or + ReactDOM.render( +
Loading...
, + root.get()!, + ); +}); +``` + +`mention:empty`: custom render empty state + +```ts +this.engine.on('mention:empty', (root) => { + root.html('
No data found
'); + // or + ReactDOM.render( +
Empty
, + root.get()!, + ); +}); +``` diff --git a/docs/plugin/plugin-mention.zh-CN.md b/docs/plugin/plugin-mention.zh-CN.md index deed8e70..a81ec0f4 100644 --- a/docs/plugin/plugin-mention.zh-CN.md +++ b/docs/plugin/plugin-mention.zh-CN.md @@ -31,49 +31,9 @@ new Engine(...,{ }) ``` -`defaultData`: 默认下拉查询列表展示数据 - -`onSearch`: 查询时的方法,或者配置 action,二选其一 - -`onSelect`: 选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 - -`onClick`: 在“提及”上单击时触发 - -`onMouseEnter`: 鼠标移入“提及”上时触发 - -`onRender`: 自定义渲染列表 - -`onRenderItem`: 自定义渲染列表项 - -`onLoading`: 自定渲染加载状态 - -`onEmpty`: 自定渲染空状态 - -`action`: 查询地址,始终使用 `GET` 请求,参数 `keyword` - -`data`: 查询时同时将这些数据一起传到到服务端 - ```ts -//默认展示的列表数据 -defaultData?: Array<{ key: string, name: string, avatar?: string}> -//查询时的方法,或者配置 action,二选其一 -onSearch?:(keyword: string) => Promise> -//选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 -onSelect?: (data: {[key:string]: string}) => void | {[key: string]: string} -//在“提及”上单击事件 -onClick?:(data: {[key:string]: string}) => void -//鼠标移入“提及”上时触发 -onMouseEnter?:(node: NodeInterface, data: {[key:string]: string}) => void -//自定义渲染列表,bindItem 可以为列表项绑定需要的属性和事件 -onRender?: (data: MentionItem, root: NodeInterface, bindItem: (node: NodeInterface, data: {[key:string]: string}) => NodeInterface) => Promise; -//自定义渲染列表项 -onRenderItem?: (item: MentionItem, root: NodeInterface) => string | NodeInterface | void -// 自定渲染加载状态 -onLoading?: (root: NodeInterface) => string | NodeInterface | void -// 自定渲染空状态 -onEmpty?: (root: NodeInterface) => string | NodeInterface | void /** - * 查询地址 + * 查询地址,或者监听 mention:search 事件执行查询 */ action?: string; /** @@ -124,3 +84,136 @@ parse?: ( //返回 Array<{ key: string, name: string}> engine.command.executeMethod('mention', 'getList'); ``` + +## 插件事件 + +`mention:default`: 默认下拉查询列表展示数据 + +```ts +this.engine.on('mention:default', () => { + return []; +}); +``` + +`mention:search`: 查询时的方法,或者配置 action,二选其一 + +```ts +this.engine.on('mention:search', (keyword) => { + return new Promise((resolve) => { + query({ keyword }) + .then((result) => { + resolve(result); + }) + .catch(() => resolve([])); + }); +}); +``` + +`mention:select`: 选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 + +```ts +this.engine.on('mention:select', (data) => { + data['test'] = 'test'; + return data; +}); +``` + +`mention:item-click`: 在“提及”上单击时触发 + +```ts +this.engine.on( + 'mention:item-click', + (root: NodeInterface, { key, name }: { key: string; name: string }) => { + console.log('mention click:', key, '-', name); + }, +); +``` + +`mention:enter`: 鼠标移入“提及”上时触发 + +```ts +this.engine.on( + 'mention:enter', + (layout: NodeInterface, { name }: { key: string; name: string }) => { + ReactDOM.render( +
+

This is name: {name}

+

配置 mention 插件的 mention:enter 事件

+

此处使用 ReactDOM.render 自定义渲染

+

Use ReactDOM.render to customize rendering here

+
, + layout.get()!, + ); + }, +); +``` + +`mention:render`: 自定义渲染列表 + +```ts +this.engine.on( + 'mention:render', + ( + root: NodeInterface, + data: Array, + bindItem: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => NodeInterface, + ) => { + return new Promise((resolve) => { + const renderCallback = (items: { [key: string]: Element }) => { + // 遍历每个项的DOM节点 + Object.keys(items).forEach((key) => { + const element = items[key]; + const item = data.find((d) => d.key === key); + if (!item) return; + // 绑定每个列表项所属的属性、事件,以满足编辑器中上下左右选择的功能需要 + bindItem($(element), item); + }); + resolve(); + }; + ReactDOM.render( + , + root.get()!, + ); + }); + }, +); +``` + +`mention:render-item`: 自定义渲染列表项 + +```ts +this.engine.on('mention:render-item', (data, root) => { + const item = $(`
${data}
`); + root.append(item); + return item; +}); +``` + +`mention:loading`: 自定渲染加载状态 + +```ts +this.engine.on('mention:loading', (data, root) => { + root.html(`
${data}
`); + // or + ReactDOM.render( +
Loading...
, + root.get()!, + ); +}); +``` + +`mention:empty`: 自定渲染空状态 + +```ts +this.engine.on('mention:empty', (root) => { + root.html('
没有查询到数据
'); + // or + ReactDOM.render( +
Empty
, + root.get()!, + ); +}); +``` diff --git a/docs/plugin/plugin-table.md b/docs/plugin/plugin-table.md index 63bfa7b7..57cea8ac 100644 --- a/docs/plugin/plugin-table.md +++ b/docs/plugin/plugin-table.md @@ -41,6 +41,17 @@ new Engine(...,{ }) ``` +### Overflow display + +```ts +overflow?: { + // Relative to the maximum displayable width on the left side of the editor + maxLeftWidth?: () => number; + // Relative to the maximum displayable width on the right side of the editor + maxRightWidth?: () => number; +}; +``` + ## Command ```ts diff --git a/docs/plugin/plugin-table.zh-CN.md b/docs/plugin/plugin-table.zh-CN.md index 5461ea00..9b799e15 100644 --- a/docs/plugin/plugin-table.zh-CN.md +++ b/docs/plugin/plugin-table.zh-CN.md @@ -41,6 +41,17 @@ new Engine(...,{ }) ``` +### 溢出展示 + +```ts +overflow?: { + // 相对编辑器左侧最大能展示的宽度 + maxLeftWidth?: () => number; + // 相对于编辑器右侧最大能展示的宽度 + maxRightWidth?: () => number; +}; +``` + ## 命令 ```ts diff --git a/docs/plugin/plugin-video.md b/docs/plugin/plugin-video.md index 2890192c..3548a7ea 100644 --- a/docs/plugin/plugin-video.md +++ b/docs/plugin/plugin-video.md @@ -40,6 +40,14 @@ new Engine(...,{ }) ``` +### Whether to display the video title + +Default Display + +```ts +showTitle?: boolean +``` + ### File Upload `action`: upload address, always use `POST` request diff --git a/docs/plugin/plugin-video.zh-CN.md b/docs/plugin/plugin-video.zh-CN.md index 2ac6a537..f4018c2f 100644 --- a/docs/plugin/plugin-video.zh-CN.md +++ b/docs/plugin/plugin-video.zh-CN.md @@ -40,6 +40,14 @@ new Engine(...,{ }) ``` +### 是否显示视频标题 + +默认显示 + +```ts +showTitle?: boolean +``` + ### 文件上传 `action`: 上传地址,始终使用 `POST` 请求 diff --git a/examples/react/components/comment/index.css b/examples/react/components/comment/index.css index e6ee3685..777ae6b9 100644 --- a/examples/react/components/comment/index.css +++ b/examples/react/components/comment/index.css @@ -44,9 +44,9 @@ .doc-comment-layer { position: absolute; top: 20px; - width: calc(50% - 454px); - left: calc(50% + 428px); - padding-left: 24px; + padding-left: 16px; + min-width: 260px; + right: 16px; } .doc-comment-title { diff --git a/examples/react/components/editor/config.tsx b/examples/react/components/editor/config.tsx index 62cde96f..4521e821 100644 --- a/examples/react/components/editor/config.tsx +++ b/examples/react/components/editor/config.tsx @@ -3,6 +3,7 @@ import { CardEntry, PluginOptions, NodeInterface, + $, } from '@aomao/engine'; //引入插件 begin import Redo from '@aomao/plugin-redo'; @@ -43,7 +44,6 @@ import LineHeight from '@aomao/plugin-line-height'; import Mention, { MentionComponent } from '@aomao/plugin-mention'; import Embed, { EmbedComponent } from '@aomao/plugin-embed'; import Test, { TestComponent } from './plugins/test'; -//import Mind, { MindComponent } from '@aomao/plugin-mind'; import { ToolbarPlugin, ToolbarComponent, @@ -98,7 +98,6 @@ export const plugins: Array = [ Mention, Embed, Test, - //Mind ]; export const cards: Array = [ @@ -115,10 +114,35 @@ export const cards: Array = [ MentionComponent, TestComponent, EmbedComponent, - //MindComponent ]; export const pluginConfig: { [key: string]: PluginOptions } = { + [Table.pluginName]: { + overflow: { + maxLeftWidth: () => { + // 编辑区域位置 + const rect = $('.editor-content') + .get() + ?.getBoundingClientRect(); + const editorLeft = rect?.left || 0; + // 减去大纲的宽度 + const width = editorLeft - $('.data-toc-wrapper').width(); + // 留 16px 的间隔 + return width <= 0 ? 100 : width - 16; + }, + maxRightWidth: () => { + // 编辑区域位置 + const rect = $('.editor-content') + .get() + ?.getBoundingClientRect(); + const editorRigth = (rect?.right || 0) - (rect?.width || 0); + // 减去评论区域的宽度 + const width = editorRigth - $('.doc-comment-layer').width(); + // 留 16px 的间隔 + return width <= 0 ? 100 : width - 16; + }, + }, + }, [MarkRange.pluginName]: { //标记类型集合 keys: ['comment'], @@ -153,7 +177,7 @@ export const pluginConfig: { [key: string]: PluginOptions } = { }, [Video.pluginName]: { onBeforeRender: (status: string, url: string) => { - return url + `?token=12323`; + return url; }, }, [Math.pluginName]: { diff --git a/examples/react/components/editor/index.less b/examples/react/components/editor/index.less index 20e851a8..9266db42 100644 --- a/examples/react/components/editor/index.less +++ b/examples/react/components/editor/index.less @@ -90,8 +90,6 @@ } .editor-container { - background: #fafafa; - background-color: #fafafa; padding: 24px 0 64px; height: calc(100vh - 138px); width: 100%; @@ -111,7 +109,6 @@ width: 812px; margin: 0 auto; background: #fff; - border: 1px solid #f0f0f0; min-height: 800px; @media @mobile { width: auto; @@ -121,7 +118,7 @@ } .editor-content .am-engine { - padding: 40px 60px 60px; + padding: 40px 0 60px; @media @mobile { padding: 18px 0 0 0; diff --git a/examples/react/components/toc/index.css b/examples/react/components/toc/index.css index 7a740512..55e20b29 100644 --- a/examples/react/components/toc/index.css +++ b/examples/react/components/toc/index.css @@ -1,9 +1,8 @@ .data-toc-wrapper { position: absolute; top: 20px; - width: calc(50% - 454px); - right: calc(50% + 428px); - padding-right: 24px; + min-width: 210px; + padding: 0 16px; } .data-toc-title { diff --git a/examples/react/editor.css b/examples/react/editor.css deleted file mode 100644 index ff277c75..00000000 --- a/examples/react/editor.css +++ /dev/null @@ -1,9 +0,0 @@ -.doc-editor-mode { - font-size: 12px; - background: #ffffff; - padding: 0; - z-index: 9999; - position: fixed; - left: 10px; - top: 68px; -} \ No newline at end of file diff --git a/examples/react/editor.less b/examples/react/editor.less new file mode 100644 index 00000000..6513bb1c --- /dev/null +++ b/examples/react/editor.less @@ -0,0 +1,38 @@ +.doc-editor-mode { + font-size: 12px; + background: #ffffff; + padding: 0; + z-index: 9999; + position: fixed; + left: 10px; + top: 68px; +} + +body { + .am-engine h1, + .am-engine h2, + .am-engine h3, + .am-engine h4, + .am-engine h5, + .am-engine h6 { + margin-top: 1em; + margin-bottom: 8px; + } + + .am-engine > [data-id], + .am-engine > div[data-card-key] { + margin: 8px 0; + } + + .am-engine table, + .am-engine tbody, + .am-engine tr, + .am-engine td { + margin: 0; + } + + .am-engine > ul > li { + margin-top: 4px; + margin-bottom: 4px; + } +} diff --git a/examples/react/editor.tsx b/examples/react/editor.tsx index 71bc1271..2d974086 100644 --- a/examples/react/editor.tsx +++ b/examples/react/editor.tsx @@ -10,7 +10,7 @@ import Space from 'antd/es/space'; import Button from 'antd/es/button'; import 'antd/es/space/style'; import 'antd/es/button/style'; -import './editor.css'; +import './editor.less'; const localMember = typeof localStorage === 'undefined' ? null : localStorage.getItem('member'); @@ -72,7 +72,7 @@ export default () => { return ( - + {/* - + */} implements CardInterface { const className = 'card-selected-other'; if (selected) this.root.addClass(className); else this.root.removeClass(className); + return center; } onActivate(activated: boolean) { if (!this.resize) return; @@ -335,7 +336,7 @@ abstract class CardEntry implements CardInterface { rgb: string; }, ): NodeInterface | void { - this.onSelectByOther(activated, value); + return this.onSelectByOther(activated, value); } onChange?(trigger: 'remote' | 'local', node: NodeInterface): void; destroy() { diff --git a/packages/engine/src/card/toolbar/index.ts b/packages/engine/src/card/toolbar/index.ts index dc931f40..ffda0ab1 100644 --- a/packages/engine/src/card/toolbar/index.ts +++ b/packages/engine/src/card/toolbar/index.ts @@ -14,6 +14,7 @@ import { DATA_ELEMENT, TRIGGER_CARD_ID, UI } from '../../constants'; import { $ } from '../../node'; import { isEngine, isMobile } from '../../utils'; import Position from '../../position'; +import placements from '../../position/placements'; import './index.css'; export const isCardToolbarItemOptions = ( @@ -30,6 +31,7 @@ class CardToolbar implements CardToolbarInterface { private position: Position; #hideTimeout: NodeJS.Timeout | null = null; #showTimeout: NodeJS.Timeout | null = null; + #defaultAlign: keyof typeof placements = 'topLeft'; constructor(editor: EditorInterface, card: CardInterface) { this.editor = editor; @@ -41,6 +43,10 @@ class CardToolbar implements CardToolbarInterface { } } + setDefaultAlign(align: keyof typeof placements) { + this.#defaultAlign = align; + } + clearHide = () => { if (this.#hideTimeout) clearTimeout(this.#hideTimeout); this.#hideTimeout = null; @@ -287,14 +293,14 @@ class CardToolbar implements CardToolbarInterface { (this.card.constructor as CardEntry).cardName, ); if (this.toolbar) this.toolbar.show(); - let prevAlign = 'topLeft'; + let prevAlign = this.#defaultAlign; setTimeout(() => { this.position.bind( container, this.card.isMaximize ? this.card.getCenter().first()! : this.card.root, - 'topLeft', + this.#defaultAlign, this.offset, (rect) => { if ( @@ -311,7 +317,7 @@ class CardToolbar implements CardToolbarInterface { this.position.update(false); } else if ( this.offset && - rect.align === 'topLeft' && + rect.align === this.#defaultAlign && rect.align !== prevAlign ) { this.position.setOffset(this.offset); diff --git a/packages/engine/src/change/index.ts b/packages/engine/src/change/index.ts index 874af7ec..9f45bbf0 100644 --- a/packages/engine/src/change/index.ts +++ b/packages/engine/src/change/index.ts @@ -441,6 +441,7 @@ class ChangeModel implements ChangeInterface { let node: NodeInterface | null = $(childNodes[0]); let prev: NodeInterface | null = null; const appendNodes = []; + let startRangeNodeParent = startRange.node.parent(); while (node && node.length > 0) { nodeApi.removeSide(node); const next: NodeInterface | null = node.next(); @@ -458,6 +459,22 @@ class ChangeModel implements ChangeInterface { if (!next) { range.select(node, true).collapse(false); } + // 被删除了重新设置开始节点位置 + if ( + startRange && + (!startRangeNodeParent || startRangeNodeParent.length === 0) + ) { + const children = node.children(); + startRangeNodeParent = node.parent(); + startRange = { + node: node, + offset: + children.length === 1 && + children[0].nodeName === 'BR' + ? 0 + : range.startOffset, + }; + } node = next; } if (mergeNode[0]) { diff --git a/packages/engine/src/change/native-event.ts b/packages/engine/src/change/native-event.ts index d575afe9..e21c118c 100644 --- a/packages/engine/src/change/native-event.ts +++ b/packages/engine/src/change/native-event.ts @@ -490,11 +490,11 @@ class NativeEvent { return; if (files.length === 0) { change.cacheRangeBeforeCommand(); + this.paste(source); setTimeout(() => { // 如果 text 和 html 都有,就解析 text pasteMarkdown(source, text || ''); }, 200); - this.paste(source); } }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 1cd2cb5c..9550145b 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -27,6 +27,7 @@ import Request, { import Scrollbar from './scrollbar'; import Position from './position'; import { $, getHashId, uuid } from './node'; +import Resizer from './resizer'; export * from './types'; export * from './utils'; @@ -69,4 +70,5 @@ export { isRangeInterface, isRange, isSelection, + Resizer, }; diff --git a/packages/engine/src/ot/range-coloring.ts b/packages/engine/src/ot/range-coloring.ts index 4a1f2d05..59ccfc6c 100644 --- a/packages/engine/src/ot/range-coloring.ts +++ b/packages/engine/src/ot/range-coloring.ts @@ -105,12 +105,6 @@ class RangeColoring implements RangeColoringInterface { child = $( `
`, ); - child.css({ - position: 'absolute', - top: 0, - left: 0, - 'pointer-events': 'none', - }); this.root.append(child); targetCanvas = new TinyCanvas({ container: child.get()!, @@ -118,6 +112,12 @@ class RangeColoring implements RangeColoringInterface { child[0]['__canvas'] = targetCanvas; } + child.css({ + position: 'absolute', + top: 0, + left: 0, + 'pointer-events': 'none', + }); child[0]['__range'] = range.cloneRange(); const parentWidth = this.root.width(); const parentHeight = this.root.height(); @@ -140,6 +140,14 @@ class RangeColoring implements RangeColoringInterface { if (!!result) { if (Array.isArray(result)) subRanges = result; else { + if (result.x < 0) { + targetCanvas.resize( + parentWidth - result.x, + parentHeight, + ); + child.css('left', `${result.x}px`); + result.x = 0; + } targetCanvas.clearRect(result); targetCanvas.drawRect({ ...result.toJSON(), ...fill }); return [range]; diff --git a/packages/engine/src/plugin/base.ts b/packages/engine/src/plugin/base.ts index 249322af..c36f57f1 100644 --- a/packages/engine/src/plugin/base.ts +++ b/packages/engine/src/plugin/base.ts @@ -3,10 +3,10 @@ import { EditorInterface } from '../types/engine'; import { PluginOptions, PluginInterface } from '../types/plugin'; abstract class PluginEntry - implements PluginInterface + implements PluginInterface { protected readonly editor: EditorInterface; - protected options: T; + options: T; constructor(editor: EditorInterface, options: PluginOptions) { this.editor = editor; this.options = (options || {}) as T; diff --git a/packages/engine/src/position/index.ts b/packages/engine/src/position/index.ts index ffe44b12..72224d0e 100644 --- a/packages/engine/src/position/index.ts +++ b/packages/engine/src/position/index.ts @@ -14,7 +14,7 @@ class Position { #root?: NodeInterface; #onUpdate?: (rect: any) => void; #updateTimeout?: NodeJS.Timeout; - #observer?: MutationObserver; + #observer?: ResizeObserver; constructor(editor: EditorInterface) { this.#editor = editor; @@ -43,7 +43,7 @@ class Position { this.#editor.scrollNode?.on('scroll', this.updateListener); } let size = { width: target.width(), height: target.height() }; - this.#observer = new MutationObserver(() => { + this.#observer = new ResizeObserver(() => { const width = target.width(); const height = target.height(); @@ -54,13 +54,7 @@ class Position { }; this.updateListener(); }); - this.#observer.observe(target.get()!, { - attributes: true, - attributeFilter: ['style'], - attributeOldValue: true, - childList: true, - subtree: true, - }); + this.#observer.observe(target.get()!); this.update(); } diff --git a/plugins/image/src/component/resizer/index.css b/packages/engine/src/resizer/index.css similarity index 72% rename from plugins/image/src/component/resizer/index.css rename to packages/engine/src/resizer/index.css index e2560064..c05a5a70 100644 --- a/plugins/image/src/component/resizer/index.css +++ b/packages/engine/src/resizer/index.css @@ -1,4 +1,4 @@ -.data-image-resizer { +.data-resizer { position: absolute; width: 100%; height: 100%; @@ -7,37 +7,10 @@ bottom: 0px; right: 0; z-index: 1; + outline: 2px solid #1890FF; + max-width: initial !important; } -.data-image-resizer-holder { - position: absolute; - width: 12px; - height: 12px; - border: 2px solid #fff; - background: #1890FF; - display: inline-block; -} -.data-image-resizer-holder-right-top { - top: -6px; - right: -6px; - cursor: nesw-resize; -} -.data-image-resizer-holder-right-bottom { - bottom: -6px; - right: -6px; - cursor: nwse-resize; -} -.data-image-resizer-holder-left-bottom { - bottom: -6px; - left: -6px; - cursor: nesw-resize; -} -.data-image-resizer-holder-left-top { - left: -6px; - top: -6px; - cursor: nwse-resize; -} - -.data-image-resizer-bg { +.data-resizer img { position: absolute; top: 0; left: 0; @@ -46,13 +19,39 @@ cursor: pointer; width: 100%; height: 100%; - opacity: 0; -} -.data-image-resizer-bg-active { opacity: 0.3; } +.data-resizer-holder { + position: absolute; + width: 14px; + height: 14px; + border: 2px solid #fff; + border-radius: 50%; + background: #1890FF; + display: inline-block; +} +.data-resizer-holder-right-top { + top: -6px; + right: -6px; + cursor: nesw-resize; +} +.data-resizer-holder-right-bottom { + bottom: -6px; + right: -6px; + cursor: nwse-resize; +} +.data-resizer-holder-left-bottom { + bottom: -6px; + left: -6px; + cursor: nesw-resize; +} +.data-resizer-holder-left-top { + left: -6px; + top: -6px; + cursor: nwse-resize; +} -.data-image-resizer-number { +.data-resizer-number { position: absolute; display: inline-block; line-height: 24px; @@ -68,31 +67,31 @@ transform: scale(0.8); } -.data-image-resizer-number-right-top { +.data-resizer-number-right-top { top: 0px; right: -6px; transform: translateX(100%) scale(0.8); } -.data-image-resizer-number-right-bottom { +.data-resizer-number-right-bottom { right: -6px; bottom: 0px; transform: translateX(100%) scale(0.8); } -.data-image-resizer-number-left-bottom { +.data-resizer-number-left-bottom { left: -6px; bottom: 0px; transform: translateX(-100%) scale(0.8); } -.data-image-resizer-number-left-top { +.data-resizer-number-left-top { left: -6px; top: 0px; transform: translateX(-100%) scale(0.8); } -.data-image-resizer-number-active { +.data-resizer-number-active { opacity: 1; visibility: visible; } \ No newline at end of file diff --git a/plugins/image/src/component/resizer/index.ts b/packages/engine/src/resizer/index.ts similarity index 66% rename from plugins/image/src/component/resizer/index.ts rename to packages/engine/src/resizer/index.ts index de654577..316bb1e4 100644 --- a/plugins/image/src/component/resizer/index.ts +++ b/packages/engine/src/resizer/index.ts @@ -1,38 +1,22 @@ -import { $, NodeInterface, EventListener, isMobile } from '@aomao/engine'; +import type { NodeInterface, EventListener } from '../types'; +import type { + ResizerInterface, + ResizerOptions, + Point, + ResizerPosition, + Size, +} from '../types'; +import { $ } from '../node'; +import { isMobile } from '../utils'; import './index.css'; -export type Options = { - src: string; - width: number; - height: number; - maxWidth: number; - rate: number; - onChange?: (size: Size) => void; -}; - -export type Position = - | 'right-top' - | 'left-top' - | 'right-bottom' - | 'left-bottom'; - -export type Point = { - x: number; - y: number; -}; - -export type Size = { - width: number; - height: number; -}; - -class Resizer { - private options: Options; +class Resizer implements ResizerInterface { + private options: ResizerOptions; private root: NodeInterface; - private image: NodeInterface; + private image?: NodeInterface; private resizerNumber: NodeInterface; private point: Point = { x: 0, y: 0 }; - private position?: Position; + private position?: ResizerPosition; private size: Size; maxWidth: number; /** @@ -40,11 +24,12 @@ class Resizer { */ private resizing: boolean = false; - constructor(options: Options) { + constructor(options: ResizerOptions) { this.options = options; - this.root = $(this.renderTemplate(options.src)); - this.image = this.root.find('img'); - this.resizerNumber = this.root.find('.data-image-resizer-number'); + this.root = $(this.renderTemplate(options.imgUrl)); + if (options.imgUrl) this.image = this.root.find('img'); + this.image?.hide(); + this.resizerNumber = this.root.find('.data-resizer-number'); const { width, height } = this.options; this.size = { width, @@ -53,19 +38,19 @@ class Resizer { this.maxWidth = this.options.maxWidth; } - renderTemplate(src: string) { + renderTemplate(imgUrl?: string) { return ` -
- -
-
-
-
- +
+ ${imgUrl ? `` : ''} +
+
+
+
+
`; } - onMouseDown(event: MouseEvent | TouchEvent, position: Position) { + onMouseDown(event: MouseEvent | TouchEvent, position: ResizerPosition) { if (this.resizing) return; event.preventDefault(); event.stopPropagation(); @@ -97,11 +82,10 @@ class Resizer { }; this.position = position; this.resizing = true; - this.resizerNumber.addClass( - `data-image-resizer-number-${this.position}`, - ); - this.resizerNumber.addClass('data-image-resizer-number-active'); - this.image.show(); + this.root.addClass('data-resizing'); + this.resizerNumber.addClass(`data-resizer-number-${this.position}`); + this.resizerNumber.addClass('data-resizer-number-active'); + this.image?.show(); document.addEventListener( isMobile ? 'touchmove' : 'mousemove', this.onMouseMove, @@ -140,13 +124,11 @@ class Resizer { width: clientWidth, height: clientHeight, }; - this.resizerNumber.removeClass( - `data-image-resizer-number-${this.position}`, - ); - this.resizerNumber.removeClass('data-image-resizer-number-active'); + this.resizerNumber.removeClass(`data-resizer-number-${this.position}`); + this.resizerNumber.removeClass('data-resizer-number-active'); this.position = undefined; this.resizing = false; - + this.root.removeClass('data-resizing'); document.removeEventListener( isMobile ? 'touchmove' : 'mousemove', this.onMouseMove, @@ -157,7 +139,7 @@ class Resizer { ); const { onChange } = this.options; if (onChange) onChange(this.size); - this.image.hide(); + this.image?.hide(); }; updateSize(width: number, height: number) { @@ -166,6 +148,10 @@ class Resizer { } else { width = this.size.width + width; } + this.setSize(width, height); + } + + setSize(width: number, height: number) { if (width < 24) { width = 24; } @@ -181,10 +167,6 @@ class Resizer { } width = Math.round(width); height = Math.round(height); - this.setSize(width, height); - } - - setSize(width: number, height: number) { this.root.css({ width: width + 'px', height: height + 'px', @@ -193,37 +175,33 @@ class Resizer { } on(eventType: string, listener: EventListener) { - this.image.on(eventType, listener); + this.image?.on(eventType, listener); } off(eventType: string, listener: EventListener) { - this.image.off(eventType, listener); + this.image?.off(eventType, listener); } render() { const { width, height } = this.options; - this.root.css({ - width: `${width}px`, - height: `${height}px`, - }); - + this.setSize(width, height); this.root - .find('.data-image-resizer-holder-right-top') + .find('.data-resizer-holder-right-top') .on(isMobile ? 'touchstart' : 'mousedown', (event) => { return this.onMouseDown(event, 'right-top'); }); this.root - .find('.data-image-resizer-holder-right-bottom') + .find('.data-resizer-holder-right-bottom') .on(isMobile ? 'touchstart' : 'mousedown', (event) => { return this.onMouseDown(event, 'right-bottom'); }); this.root - .find('.data-image-resizer-holder-left-bottom') + .find('.data-resizer-holder-left-bottom') .on(isMobile ? 'touchstart' : 'mousedown', (event) => { return this.onMouseDown(event, 'left-bottom'); }); this.root - .find('.data-image-resizer-holder-left-top') + .find('.data-resizer-holder-left-top') .on(isMobile ? 'touchstart' : 'mousedown', (event) => { return this.onMouseDown(event, 'left-top'); }); diff --git a/packages/engine/src/scrollbar/index.css b/packages/engine/src/scrollbar/index.css index 90a2d8e1..1ba6b7a8 100644 --- a/packages/engine/src/scrollbar/index.css +++ b/packages/engine/src/scrollbar/index.css @@ -40,7 +40,6 @@ background: #888; } .data-scrollable .data-scrollbar.data-scrollbar-x { - width: 100%; height: 8px; bottom: 0px; } diff --git a/packages/engine/src/scrollbar/index.ts b/packages/engine/src/scrollbar/index.ts index c35ca87f..76a00765 100644 --- a/packages/engine/src/scrollbar/index.ts +++ b/packages/engine/src/scrollbar/index.ts @@ -1,4 +1,5 @@ import { EventEmitter2 } from 'eventemitter2'; +import domAlign from 'dom-align'; import { DATA_ELEMENT, UI } from '../constants'; import { NodeInterface } from '../types'; import { $ } from '../node'; @@ -11,6 +12,12 @@ export type ScrollbarDragging = { position: number; }; +export type ScrollbarCustomeOptions = { + onScrollX?: (x: number) => number; + getOffsetWidth?: (width: number) => number; + getScrollLeft?: (left: number) => number; +}; + class Scrollbar extends EventEmitter2 { private container: NodeInterface; private x: boolean; @@ -36,6 +43,7 @@ class Scrollbar extends EventEmitter2 { #content?: NodeInterface; shadowTimer?: NodeJS.Timeout; #enableScroll: boolean = true; + #scroll?: ScrollbarCustomeOptions; /** * @param {nativeNode} container 需要添加滚动条的元素 * @param {boolean} x 横向滚动条 @@ -47,12 +55,14 @@ class Scrollbar extends EventEmitter2 { x: boolean = true, y: boolean = false, shadow: boolean = true, + scroll?: ScrollbarCustomeOptions, ) { super(); this.container = isNode(container) ? $(container) : container; this.x = x; this.y = y; this.shadow = shadow; + this.#scroll = scroll; this.init(); } @@ -72,7 +82,7 @@ class Scrollbar extends EventEmitter2 { const children = this.container.children(); let hasScrollbar = false; children.each((child) => { - if ($(child).hasClass('data-scrollbar')) { + if (!hasScrollbar && $(child).hasClass('data-scrollbar')) { hasScrollbar = true; } }); @@ -81,7 +91,7 @@ class Scrollbar extends EventEmitter2 { this.container.addClass('data-scrollable'); if (this.x) { this.scrollBarX = $( - `
`, + `
`, ); this.slideX = this.scrollBarX.find('.data-scrollbar-trigger'); this.container.append(this.scrollBarX); @@ -89,7 +99,7 @@ class Scrollbar extends EventEmitter2 { } if (this.y) { this.scrollBarY = $( - `
`, + `
`, ); this.slideY = this.scrollBarY.find('.data-scrollbar-trigger'); this.container.append(this.scrollBarY); @@ -110,12 +120,14 @@ class Scrollbar extends EventEmitter2 { } } - refresh() { + refresh = () => { const element = this.container.get(); if (element) { - const { offsetWidth, offsetHeight, scrollLeft, scrollTop } = - element; + const offsetWidth = this.#scroll?.getOffsetWidth + ? this.#scroll.getOffsetWidth(element.offsetWidth) + : element.offsetWidth; + const { offsetHeight, scrollTop } = element; const contentElement = this.#content?.get(); const sPLeft = removeUnit(this.container.css('padding-left')); const sPRight = removeUnit(this.container.css('padding-right')); @@ -170,14 +182,30 @@ class Scrollbar extends EventEmitter2 { if ( this.x && contentElement && - element.scrollWidth - sPLeft - sPRight !== + element.scrollWidth - sPLeft - sPRight > contentElement.offsetWidth ) { - element.scrollLeft -= + let left = element.scrollWidth - sPLeft - sPRight - contentElement.offsetWidth; + if (this.#scroll) { + const { onScrollX, getScrollLeft } = this.#scroll; + + left = getScrollLeft + ? getScrollLeft(-0) + element.scrollLeft - left + : element.scrollLeft - left; + + if (onScrollX) { + const result = onScrollX(left); + if (result > 0) element.scrollLeft = result; + else element.scrollLeft = 0; + } + this.scroll({ left }); + } else { + element.scrollLeft -= left; + } return; } // 实际内容高度小于容器滚动高度(有内容删除了) @@ -194,10 +222,13 @@ class Scrollbar extends EventEmitter2 { contentElement.offsetHeight; return; } - this.reRenderX(scrollLeft); + const left = this.#scroll?.getScrollLeft + ? this.#scroll.getScrollLeft(element.scrollLeft) + : element.scrollLeft; + this.reRenderX(left); this.reRenderY(scrollTop); } - } + }; /** * 启用鼠标在内容节点上滚动或在移动设备使用手指滑动 @@ -212,13 +243,26 @@ class Scrollbar extends EventEmitter2 { this.#enableScroll = false; } - scroll = (event: Event) => { - const { target } = event; - if (!target) return; + scroll = (event: Event | { top?: number; left?: number }) => { + let top = 0; + let left = 0; + if (!this.#scroll && event instanceof Event) { + const { scrollTop, scrollLeft } = event.target as HTMLElement; + top = scrollTop; + left = scrollLeft; + } else if (!(event instanceof Event)) { + if (event.top === undefined) { + event.top = this.container.get()?.scrollTop || 0; + } + if (event.left === undefined) { + event.left = this.container.get()?.scrollLeft || 0; + } + top = event.top; + left = event.left; + } else return; - const { scrollTop, scrollLeft } = target as HTMLElement; - this.reRenderX(scrollLeft); - this.reRenderY(scrollTop); + this.reRenderX(left); + this.reRenderY(top); }; wheelXScroll = (event: any) => { @@ -227,12 +271,29 @@ class Scrollbar extends EventEmitter2 { const dir = wheelValue > 0 ? 'up' : 'down'; const containerElement = this.container.get(); if (!containerElement) return; - let left = containerElement.scrollLeft + (dir === 'up' ? -20 : 20); + const containerWidth = this.#scroll?.getOffsetWidth + ? this.#scroll.getOffsetWidth(containerElement.offsetWidth) + : containerElement.offsetWidth; + const step = Math.max(containerWidth / 8, 20); + let left = + (this.#scroll?.getScrollLeft + ? this.#scroll.getScrollLeft(containerElement.scrollLeft) + : containerElement.scrollLeft) + (dir === 'up' ? -step : step); left = dir === 'up' ? Math.max(0, left) : Math.min(left, this.sWidth - this.oWidth); - containerElement.scrollLeft = left; + if (this.#scroll) { + const { onScrollX } = this.#scroll; + if (onScrollX) { + const result = onScrollX(left); + if (result > 0) containerElement.scrollLeft = result; + else containerElement.scrollLeft = 0; + } + this.scroll({ left }); + } else { + containerElement.scrollLeft = left; + } }; wheelYScroll = (event: any) => { @@ -241,7 +302,9 @@ class Scrollbar extends EventEmitter2 { const dir = wheelValue > 0 ? 'up' : 'down'; const containerElement = this.container.get(); if (!containerElement) return; - let top = containerElement.scrollTop + (dir === 'up' ? -20 : 20); + const containerHeight = containerElement.offsetHeight; + const step = Math.max(containerHeight / 8, 20); + let top = containerElement.scrollTop + (dir === 'up' ? -step : step); top = dir === 'up' ? Math.max(0, top) @@ -325,6 +388,7 @@ class Scrollbar extends EventEmitter2 { childList: true, subtree: true, }); + window.addEventListener('resize', this.refresh); // 绑定滚动条事件 this.bindXScrollEvent(); this.bindYScrollEvent(); @@ -361,8 +425,19 @@ class Scrollbar extends EventEmitter2 { this.slideX?.css('left', left + 'px'); let min = left / (this.oWidth - this.xWidth); min = Math.min(1, min); - this.container.get()!.scrollLeft = - (this.sWidth - this.oWidth) * min; + const containerElement = this.container.get()!; + const x = (this.sWidth - this.oWidth) * min; + if (this.#scroll) { + const { onScrollX } = this.#scroll; + if (onScrollX) { + const result = onScrollX(x); + if (result > 0) containerElement.scrollLeft = result; + else containerElement.scrollLeft = 0; + } + this.scroll({ left: x }); + } else { + containerElement.scrollLeft = x; + } } }; @@ -463,7 +538,13 @@ class Scrollbar extends EventEmitter2 { reRenderShadow = (width: number) => { if (this.shadow) { - this.shadowLeft?.css('left', width + 'px'); + const element = this.container.get(); + if (element) { + this.shadowLeft?.css( + 'left', + (this.#scroll ? element.scrollLeft : width) + 'px', + ); + } this.shadowRight?.css('left', width + this.oWidth - 4 + 'px'); } }; @@ -476,7 +557,12 @@ class Scrollbar extends EventEmitter2 { min = Math.min(1, min); this.slideX?.css('left', (this.oWidth - this.xWidth) * min + 'px'); this.reRenderShadow(left); - this.emit('change'); + if (left === removeUnit(this.scrollBarX?.css('left') || '0')) + return; + this.emit('change', { + x: left, + y: removeUnit(this.scrollBarY?.css('top') || '0'), + }); } }; @@ -487,7 +573,11 @@ class Scrollbar extends EventEmitter2 { let min = value <= 0 ? 0 : top / value; min = Math.min(1, min); this.slideY?.css('top', (this.oHeight - this.yHeight) * min + 'px'); - this.emit('change'); + if (top === removeUnit(this.scrollBarX?.css('top') || '0')) return; + this.emit('change', { + x: removeUnit(this.scrollBarX?.css('left') || '0'), + y: top, + }); } }; @@ -526,6 +616,7 @@ class Scrollbar extends EventEmitter2 { this.shadowRight?.remove(); } this.#observer?.disconnect(); + window.removeEventListener('resize', this.refresh); } } diff --git a/packages/engine/src/toolbar/tooltip/index.ts b/packages/engine/src/toolbar/tooltip/index.ts index 5a2e042a..2a206bd3 100644 --- a/packages/engine/src/toolbar/tooltip/index.ts +++ b/packages/engine/src/toolbar/tooltip/index.ts @@ -1,18 +1,9 @@ import { DATA_ELEMENT } from '../../constants/root'; import { NodeInterface } from '../../types/node'; +import { Placement } from '../../types/position'; import { $ } from '../../node'; import './index.css'; -type Placement = - | 'top' - | 'topLeft' - | 'topRight' - | 'bottom' - | 'bottomLeft' - | 'bottomRight' - | 'left' - | 'right'; - const template = (options: { placement: Placement }) => { return `
diff --git a/packages/engine/src/types/card.ts b/packages/engine/src/types/card.ts index f9a56426..056b6422 100644 --- a/packages/engine/src/types/card.ts +++ b/packages/engine/src/types/card.ts @@ -8,6 +8,7 @@ import { ToolbarItemOptions, } from './toolbar'; import { CardActiveTrigger, CardType } from '../card/enum'; +import { Placement } from './position'; export type CardOptions = { editor: EditorInterface; @@ -53,6 +54,7 @@ export interface CardToolbarInterface { * @param offset 偏移量 [tx,ty,bx,by] */ setOffset(offset: Array): void; + setDefaultAlign(align: Placement): void; /** * 销毁 */ diff --git a/packages/engine/src/types/index.ts b/packages/engine/src/types/index.ts index 12ca3ef7..2d77dde0 100644 --- a/packages/engine/src/types/index.ts +++ b/packages/engine/src/types/index.ts @@ -21,3 +21,5 @@ export * from './block'; export * from './request'; export * from './tiny-canvas'; export * from './parser'; +export * from './resizer'; +export * from './position'; diff --git a/packages/engine/src/types/plugin.ts b/packages/engine/src/types/plugin.ts index 4a59312d..a31f60de 100644 --- a/packages/engine/src/types/plugin.ts +++ b/packages/engine/src/types/plugin.ts @@ -22,8 +22,12 @@ export interface PluginEntry { readonly pluginName: string; } -export interface PluginInterface { +export interface PluginInterface { readonly kind: string; + /** + * 可选项 + **/ + options: T; /** * 是否禁用,默认不禁用。在默认不指定的情况下,编辑器为 readonly 的时候全部禁用 */ diff --git a/packages/engine/src/types/position.ts b/packages/engine/src/types/position.ts new file mode 100644 index 00000000..c7b2bdff --- /dev/null +++ b/packages/engine/src/types/position.ts @@ -0,0 +1,13 @@ +export type Placement = + | 'top' + | 'topLeft' + | 'topRight' + | 'bottom' + | 'bottomLeft' + | 'bottomRight' + | 'left' + | 'leftTop' + | 'leftBottom' + | 'right' + | 'rightTop' + | 'rightBottom'; diff --git a/packages/engine/src/types/resizer.ts b/packages/engine/src/types/resizer.ts new file mode 100644 index 00000000..f42a4bd5 --- /dev/null +++ b/packages/engine/src/types/resizer.ts @@ -0,0 +1,33 @@ +export interface ResizerInterface { + on(eventType: string, listener: EventListener): void; + off(eventType: string, listener: EventListener): void; + setSize(width: number, height: number): void; + maxWidth: number; + render(): void; + destroy(): void; +} + +export type ResizerOptions = { + imgUrl?: string; + width: number; + height: number; + maxWidth: number; + rate: number; + onChange?: (size: Size) => void; +}; + +export type ResizerPosition = + | 'right-top' + | 'left-top' + | 'right-bottom' + | 'left-bottom'; + +export type Point = { + x: number; + y: number; +}; + +export type Size = { + width: number; + height: number; +}; diff --git a/packages/toolbar/src/collapse/item.tsx b/packages/toolbar/src/collapse/item.tsx index fddca966..6f48b6f6 100644 --- a/packages/toolbar/src/collapse/item.tsx +++ b/packages/toolbar/src/collapse/item.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import classnames from 'classnames-es-ts'; -import { EngineInterface } from '@aomao/engine'; +import { EngineInterface, Placement } from '@aomao/engine'; import Popover from 'antd/es/popover'; import 'antd/es/popover/style'; @@ -17,19 +17,7 @@ export type CollapseItemProps = { disabled?: boolean; onDisabled?: () => boolean; className?: string; - placement?: - | 'right' - | 'top' - | 'left' - | 'bottom' - | 'topLeft' - | 'topRight' - | 'bottomLeft' - | 'bottomRight' - | 'leftTop' - | 'leftBottom' - | 'rightTop' - | 'rightBottom'; + placement?: Placement; onClick?: (event: React.MouseEvent, name: string) => void | boolean; onMouseDown?: (event: React.MouseEvent) => void; }; diff --git a/plugins/image/src/component/image/index.ts b/plugins/image/src/component/image/index.ts index 7e7aa5ca..07a450be 100644 --- a/plugins/image/src/component/image/index.ts +++ b/plugins/image/src/component/image/index.ts @@ -1,17 +1,16 @@ -import { PswpInterface } from '@/types'; +import type { PswpInterface } from '@/types'; +import type { EditorInterface, NodeInterface } from '@aomao/engine'; import { $, - EditorInterface, isEngine, escape, - NodeInterface, sanitizeUrl, Tooltip, isMobile, + Resizer, CardType, } from '@aomao/engine'; import Pswp from '../pswp'; -import Resizer from '../resizer'; import './index.css'; export type Status = 'uploading' | 'done' | 'error'; @@ -295,12 +294,12 @@ class Image { this.meta.css({ 'background-color': '', width: '', - height: '', + //height: '', }); this.image.css({ width: '', - height: '', + //height: '', }); const img = this.image.get(); @@ -334,7 +333,7 @@ class Image { } this.image.css('width', `${width}px`); - this.image.css('height', `${height}px`); + //this.image.css('height', `${height}px`); } changeSize(width: number, height: number) { @@ -359,7 +358,7 @@ class Image { this.size.height = height; this.image.css({ width: `${width}px`, - height: `${height}px`, + //height: `${height}px`, }); const { onChange } = this.options; @@ -450,7 +449,7 @@ class Image { if (isMobile || !isEngine(this.editor) || this.editor.readonly) return; // 拖动调整图片大小 const resizer = new Resizer({ - src: this.getSrc(), + imgUrl: this.getSrc(), width: clientWidth, height: clientHeight, rate: this.rate, @@ -542,7 +541,7 @@ class Image { if (this.src) { this.image.css({ width: width + 'px', - height: height + 'px', + //height: height + 'px', }); const { onChange } = this.options; if (width > 0 && height > 0) { diff --git a/plugins/image/src/component/index.ts b/plugins/image/src/component/index.ts index a2f527d1..69fc358a 100644 --- a/plugins/image/src/component/index.ts +++ b/plugins/image/src/component/index.ts @@ -228,6 +228,23 @@ class ImageComponent extends Card { else this.image?.blur(); } + onSelectByOther( + selected: boolean, + value?: { + color: string; + rgb: string; + }, + ): NodeInterface | void { + this.image?.root?.css( + 'outline', + selected ? '2px solid ' + value!.color : '', + ); + const className = 'card-selected-other'; + if (selected) this.root.addClass(className); + else this.root.removeClass(className); + return this.image?.root; + } + render(loadingBg?: string): string | void | NodeInterface { const value = this.getValue(); if (!value) return; @@ -253,20 +270,6 @@ class ImageComponent extends Card { }, onChange: (size) => { if (size) this.setSize(size); - if (this.type === CardType.BLOCK && this.image) { - const maxWidth = this.image.getMaxWidth(); - const offset = (maxWidth - this.image.root.width()) / 2; - if (value.status === 'done') { - this.toolbarModel?.setOffset([ - -offset - 12, - 0, - -offset - 12, - 0, - ]); - } - if (this.activated) - this.toolbarModel?.showCardToolbar(); - } }, onError: () => { this.isLocalError = true; @@ -290,12 +293,8 @@ class ImageComponent extends Card { } didRender() { - if ( - this.type === CardType.INLINE && - this.getValue()?.status === 'done' - ) { - this.toolbarModel?.setOffset([-12, 0, -12, 0]); - } + super.didRender(); + this.toolbarModel?.setDefaultAlign('top'); } } diff --git a/plugins/image/src/component/pswp/index.css b/plugins/image/src/component/pswp/index.css index 623ad791..69a8600f 100644 --- a/plugins/image/src/component/pswp/index.css +++ b/plugins/image/src/component/pswp/index.css @@ -20,7 +20,7 @@ } .pswp .data-pswp-tool-bar .btn { - color: #D9D9D9; + color: #f8f9fa; display: inline-block; width: 32px; height: 32px; diff --git a/plugins/image/src/component/pswp/index.ts b/plugins/image/src/component/pswp/index.ts index 9dd253cf..b7993de9 100644 --- a/plugins/image/src/component/pswp/index.ts +++ b/plugins/image/src/component/pswp/index.ts @@ -42,6 +42,7 @@ class Pswp extends EventEmitter2 implements PswpInterface { hideAnimationDuration: 0, closeOnVerticalDrag: isMobile, tapToClose: true, + bgOpacity: 0.8, barsSize: { top: 44, bottom: 80, diff --git a/plugins/mention/README.md b/plugins/mention/README.md index deed8e70..a81ec0f4 100644 --- a/plugins/mention/README.md +++ b/plugins/mention/README.md @@ -31,49 +31,9 @@ new Engine(...,{ }) ``` -`defaultData`: 默认下拉查询列表展示数据 - -`onSearch`: 查询时的方法,或者配置 action,二选其一 - -`onSelect`: 选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 - -`onClick`: 在“提及”上单击时触发 - -`onMouseEnter`: 鼠标移入“提及”上时触发 - -`onRender`: 自定义渲染列表 - -`onRenderItem`: 自定义渲染列表项 - -`onLoading`: 自定渲染加载状态 - -`onEmpty`: 自定渲染空状态 - -`action`: 查询地址,始终使用 `GET` 请求,参数 `keyword` - -`data`: 查询时同时将这些数据一起传到到服务端 - ```ts -//默认展示的列表数据 -defaultData?: Array<{ key: string, name: string, avatar?: string}> -//查询时的方法,或者配置 action,二选其一 -onSearch?:(keyword: string) => Promise> -//选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 -onSelect?: (data: {[key:string]: string}) => void | {[key: string]: string} -//在“提及”上单击事件 -onClick?:(data: {[key:string]: string}) => void -//鼠标移入“提及”上时触发 -onMouseEnter?:(node: NodeInterface, data: {[key:string]: string}) => void -//自定义渲染列表,bindItem 可以为列表项绑定需要的属性和事件 -onRender?: (data: MentionItem, root: NodeInterface, bindItem: (node: NodeInterface, data: {[key:string]: string}) => NodeInterface) => Promise; -//自定义渲染列表项 -onRenderItem?: (item: MentionItem, root: NodeInterface) => string | NodeInterface | void -// 自定渲染加载状态 -onLoading?: (root: NodeInterface) => string | NodeInterface | void -// 自定渲染空状态 -onEmpty?: (root: NodeInterface) => string | NodeInterface | void /** - * 查询地址 + * 查询地址,或者监听 mention:search 事件执行查询 */ action?: string; /** @@ -124,3 +84,136 @@ parse?: ( //返回 Array<{ key: string, name: string}> engine.command.executeMethod('mention', 'getList'); ``` + +## 插件事件 + +`mention:default`: 默认下拉查询列表展示数据 + +```ts +this.engine.on('mention:default', () => { + return []; +}); +``` + +`mention:search`: 查询时的方法,或者配置 action,二选其一 + +```ts +this.engine.on('mention:search', (keyword) => { + return new Promise((resolve) => { + query({ keyword }) + .then((result) => { + resolve(result); + }) + .catch(() => resolve([])); + }); +}); +``` + +`mention:select`: 选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 + +```ts +this.engine.on('mention:select', (data) => { + data['test'] = 'test'; + return data; +}); +``` + +`mention:item-click`: 在“提及”上单击时触发 + +```ts +this.engine.on( + 'mention:item-click', + (root: NodeInterface, { key, name }: { key: string; name: string }) => { + console.log('mention click:', key, '-', name); + }, +); +``` + +`mention:enter`: 鼠标移入“提及”上时触发 + +```ts +this.engine.on( + 'mention:enter', + (layout: NodeInterface, { name }: { key: string; name: string }) => { + ReactDOM.render( +
+

This is name: {name}

+

配置 mention 插件的 mention:enter 事件

+

此处使用 ReactDOM.render 自定义渲染

+

Use ReactDOM.render to customize rendering here

+
, + layout.get()!, + ); + }, +); +``` + +`mention:render`: 自定义渲染列表 + +```ts +this.engine.on( + 'mention:render', + ( + root: NodeInterface, + data: Array, + bindItem: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => NodeInterface, + ) => { + return new Promise((resolve) => { + const renderCallback = (items: { [key: string]: Element }) => { + // 遍历每个项的DOM节点 + Object.keys(items).forEach((key) => { + const element = items[key]; + const item = data.find((d) => d.key === key); + if (!item) return; + // 绑定每个列表项所属的属性、事件,以满足编辑器中上下左右选择的功能需要 + bindItem($(element), item); + }); + resolve(); + }; + ReactDOM.render( + , + root.get()!, + ); + }); + }, +); +``` + +`mention:render-item`: 自定义渲染列表项 + +```ts +this.engine.on('mention:render-item', (data, root) => { + const item = $(`
${data}
`); + root.append(item); + return item; +}); +``` + +`mention:loading`: 自定渲染加载状态 + +```ts +this.engine.on('mention:loading', (data, root) => { + root.html(`
${data}
`); + // or + ReactDOM.render( +
Loading...
, + root.get()!, + ); +}); +``` + +`mention:empty`: 自定渲染空状态 + +```ts +this.engine.on('mention:empty', (root) => { + root.html('
没有查询到数据
'); + // or + ReactDOM.render( +
Empty
, + root.get()!, + ); +}); +``` diff --git a/plugins/mention/src/component/collapse.ts b/plugins/mention/src/component/collapse.ts index 528e6041..1c3cfbdf 100644 --- a/plugins/mention/src/component/collapse.ts +++ b/plugins/mention/src/component/collapse.ts @@ -249,7 +249,12 @@ class CollapseComponent implements CollapseComponentInterface { if (result) body?.append(result); } else if ( CollapseComponent.render || - (result = this.engine.trigger('mention:render', this.root)) + (result = this.engine.trigger( + 'mention:render', + this.root, + data, + this.bindItem, + )) ) { (CollapseComponent.render ? CollapseComponent.render(this.root, data, this.bindItem) diff --git a/plugins/table/README.md b/plugins/table/README.md index 5461ea00..9b799e15 100644 --- a/plugins/table/README.md +++ b/plugins/table/README.md @@ -41,6 +41,17 @@ new Engine(...,{ }) ``` +### 溢出展示 + +```ts +overflow?: { + // 相对编辑器左侧最大能展示的宽度 + maxLeftWidth?: () => number; + // 相对于编辑器右侧最大能展示的宽度 + maxRightWidth?: () => number; +}; +``` + ## 命令 ```ts diff --git a/plugins/table/src/component/helper.ts b/plugins/table/src/component/helper.ts index dd82c562..8516e6f4 100644 --- a/plugins/table/src/component/helper.ts +++ b/plugins/table/src/component/helper.ts @@ -251,7 +251,7 @@ class Helper implements HelperInterface { const $tr = trs.eq(index); if (!$tr) return; let height = parseInt($tr.css('height')); - height = height || 33; + height = height || 35; $tr.css('height', height + 'px'); }); //补充可编辑器区域 @@ -603,7 +603,7 @@ class Helper implements HelperInterface { const $tr = trs.eq(index); if (!$tr) return; let height = parseInt($tr.css('height')); - height = height || 33; + height = height || 35; $tr.css('height', height + 'px'); }); return table; diff --git a/plugins/table/src/component/index.ts b/plugins/table/src/component/index.ts index 27d8968b..567fe651 100644 --- a/plugins/table/src/component/index.ts +++ b/plugins/table/src/component/index.ts @@ -5,9 +5,11 @@ import { CardType, EDITABLE_SELECTOR, isEngine, + isMobile, NodeInterface, Parser, RangeInterface, + removeUnit, Scrollbar, ToolbarItemOptions, } from '@aomao/engine'; @@ -65,7 +67,7 @@ class TableComponent extends Card implements TableInterface { selection: TableSelectionInterface = new TableSelection(this.editor, this); conltrollBar: ControllBarInterface = new ControllBar(this.editor, this, { col_min_width: 40, - row_min_height: 33, + row_min_height: 35, }); command: TableCommandInterface = new TableCommand(this.editor, this); scrollbar?: Scrollbar; @@ -80,6 +82,138 @@ class TableComponent extends Card implements TableInterface { if (isEngine(this.editor)) { this.editor.on('undo', this.doChange); this.editor.on('redo', this.doChange); + // tab 键选择 + if (!this.editor.event.listeners['keydown:tab']) + this.editor.event.listeners['keydown:tab'] = []; + this.editor.event.listeners['keydown:tab'].unshift( + (event: KeyboardEvent) => { + if (!isEngine(this.editor)) return; + const { change, block, node } = this.editor; + + const range = change.range.get(); + const td = range.endNode.closest('td'); + if (td.length === 0) return; + const closestBlock = block.closest(range.endNode); + if ( + td.length > 0 && + (block.isLastOffset(range, 'end') || + (closestBlock.name !== 'li' && + node.isEmptyWidthChild(closestBlock))) + ) { + let next = td.next(); + if (!next) { + const nextRow = td.parent()?.next(); + // 最后一行,最后一列 + if (!nextRow) { + // 新建一行 + this.command.insertRowDown(); + next = + td + .parent() + ?.next() + ?.find('td:first-child') || null; + } else { + next = nextRow.find('td:first-child') || null; + } + } + if (next) { + event.preventDefault(); + this.selection.focusCell(next); + return false; + } + } + return; + }, + ); + // 下键选择 + this.editor.on('keydown:down', (event) => { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + + const range = change.range.get(); + const td = range.endNode.closest('td'); + if (td.length === 0) return; + const contentElement = td.find('.table-main-content'); + if (!contentElement) return; + const tdRect = contentElement + .get()! + .getBoundingClientRect(); + const rangeRect = range.getBoundingClientRect(); + if ( + td.length > 0 && + (rangeRect.bottom === 0 || + tdRect.bottom - rangeRect.bottom < 10) + ) { + const index = td.index(); + const nextRow = td.parent()?.next(); + if (nextRow) { + let nextIndex = 0; + let nextTd = nextRow.find('td:last-child'); + this.selection.tableModel?.table[nextRow.index()].some( + (cell) => { + if ( + !this.helper.isEmptyModelCol(cell) && + nextIndex >= index && + cell.element + ) { + nextTd = $(cell.element); + return true; + } + nextIndex++; + }, + ); + if (nextTd) { + event.preventDefault(); + this.selection.focusCell(nextTd, true); + return false; + } + } + } + }); + // 上键选择 + this.editor.on('keydown:up', (event) => { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + + const range = change.range.get(); + const td = range.endNode.closest('td'); + if (td.length === 0) return; + const contentElement = td.find('.table-main-content'); + if (!contentElement) return; + const tdRect = contentElement + .get()! + .getBoundingClientRect(); + const rangeRect = range.getBoundingClientRect(); + if ( + td.length > 0 && + (rangeRect.top === 0 || rangeRect.top - tdRect.top < 10) + ) { + const index = td.index(); + const prevRow = td.parent()?.prev(); + if (prevRow) { + let prevIndex = 0; + let prevTd = prevRow.find('td:first-child'); + this.selection.tableModel?.table[prevRow.index()].some( + (cell) => { + if ( + !this.helper.isEmptyModelCol(cell) && + prevIndex >= index && + cell.element + ) { + prevTd = $(cell.element); + return true; + } + prevIndex++; + }, + ); + if (prevTd) { + event.preventDefault(); + this.selection.focusCell(prevTd); + return false; + } + } + } + }); } if (this.colorTool) return; this.colorTool = new ColorTool(this.editor, this.id, { @@ -172,10 +306,7 @@ class TableComponent extends Card implements TableInterface { }, ]; if (this.isMaximize) return funBtns; - return [ - { - type: 'dnd', - }, + const toolbars: Array = [ { type: 'maximize', }, @@ -190,6 +321,12 @@ class TableComponent extends Card implements TableInterface { }, ...funBtns, ]; + if (removeUnit(this.wrapper?.css('margin-left') || '0') === 0) { + toolbars.unshift({ + type: 'dnd', + }); + } + return toolbars; } updateAlign(event: MouseEvent, align: 'top' | 'middle' | 'bottom' = 'top') { @@ -291,12 +428,10 @@ class TableComponent extends Card implements TableInterface { super.activate(activated); if (activated) { this.wrapper?.addClass('active'); - this.scrollbar?.enableScroll(); } else { this.selection.clearSelect(); this.conltrollBar.hideContextMenu(); this.wrapper?.removeClass('active'); - this.scrollbar?.disableScroll(); } this.scrollbar?.refresh(); } @@ -344,6 +479,33 @@ class TableComponent extends Card implements TableInterface { return nodes; } + overflow(max: number) { + // 表格宽度 + const tableWidth = this.wrapper?.find('.data-table')?.width() || 0; + const rootWidth = this.getCenter().width(); + // 溢出的宽度 + const overflowWidth = tableWidth - rootWidth; + if (overflowWidth > 0) { + this.wrapper?.css( + 'margin-right', + `-${overflowWidth > max ? max : overflowWidth}px`, + ); + } else if (overflowWidth < 0) { + this.wrapper?.css('margin-right', ''); + } + } + + updateScrollbar = () => { + if (!this.scrollbar) return; + const hideHeight = + (this.wrapper?.getBoundingClientRect()?.bottom || 0) - + (this.wrapper?.getViewport().bottom || 0); + console.log(hideHeight); + this.wrapper?.find('.data-scrollbar-x').css({ + bottom: `${hideHeight > 0 ? hideHeight + 2 : 0}px`, + }); + }; + didRender() { super.didRender(); this.viewport = isEngine(this.editor) @@ -356,8 +518,49 @@ class TableComponent extends Card implements TableInterface { if (!isEngine(this.editor) || this.editor.readonly) this.toolbarModel?.setOffset([0, 0]); else this.toolbarModel?.setOffset([0, -28, 0, -6]); + const tablePlugin = this.editor.plugin.components['table']; + const tableOptions = tablePlugin?.options['overflow'] || {}; if (this.viewport) { - this.scrollbar = new Scrollbar(this.viewport, true, false, true); + const overflowLeftConfig = tableOptions['maxLeftWidth'] + ? { + onScrollX: (x: number) => { + const max = tableOptions['maxLeftWidth'](); + this.wrapper?.css( + 'margin-left', + `-${x > max ? max : x}px`, + ); + if (x > 0) { + this.editor.root.find('.data-card-dnd').hide(); + } else { + this.editor.root.find('.data-card-dnd').show(); + } + return x - max; + }, + getScrollLeft: (left: number) => { + return ( + left - + removeUnit( + this.wrapper?.css('margin-left') || '0', + ) + ); + }, + getOffsetWidth: (width: number) => { + return ( + width + + removeUnit( + this.wrapper?.css('margin-left') || '0', + ) + ); + }, + } + : undefined; + this.scrollbar = new Scrollbar( + this.viewport, + true, + false, + true, + overflowLeftConfig, + ); this.scrollbar.setContentNode(this.viewport.find('.data-table')!); this.scrollbar.on('display', (display: 'node' | 'block') => { if (display === 'block') { @@ -367,14 +570,18 @@ class TableComponent extends Card implements TableInterface { } }); this.scrollbar.disableScroll(); - let changeTimeout: NodeJS.Timeout | undefined; const handleScrollbarChange = () => { - if (changeTimeout) clearTimeout(changeTimeout); - changeTimeout = setTimeout(() => { - if (isEngine(this.editor)) this.editor.ot.initSelection(); - }, 50); + if (tableOptions['maxRightWidth']) + this.overflow(tableOptions['maxRightWidth']()); + if (isEngine(this.editor)) this.editor.ot.initSelection(); }; this.scrollbar.on('change', handleScrollbarChange); + if (!isMobile) + window.addEventListener('scroll', this.updateScrollbar); + window.addEventListener('resize', this.updateScrollbar); + if (isEngine(this.editor) && !isMobile) { + this.editor.scrollNode?.on('scroll', this.updateScrollbar); + } } this.selection.on('select', () => { this.conltrollBar.refresh(); @@ -401,6 +608,8 @@ class TableComponent extends Card implements TableInterface { if (!silence) { this.onChange(); } + if (tableOptions['maxRightWidth']) + this.overflow(tableOptions['maxRightWidth']()); this.scrollbar?.refresh(); }); @@ -412,6 +621,9 @@ class TableComponent extends Card implements TableInterface { if (tableValue) this.setValue(tableValue); this.onChange(); } + if (tableOptions['maxRightWidth']) + this.overflow(tableOptions['maxRightWidth']()); + this.scrollbar?.refresh(); } render() { diff --git a/plugins/table/src/component/selection.ts b/plugins/table/src/component/selection.ts index 1eac4a1f..2bc7c9e8 100644 --- a/plugins/table/src/component/selection.ts +++ b/plugins/table/src/component/selection.ts @@ -458,7 +458,7 @@ class TableSelection extends EventEmitter2 implements TableSelectionInterface { this.emit('select', this.selectArea); } - focusCell(cell: NodeInterface | Node) { + focusCell(cell: NodeInterface | Node, start: boolean = false) { if (!isEngine(this.editor)) return; const { change } = this.editor; if (isNode(cell)) cell = $(cell); @@ -469,7 +469,7 @@ class TableSelection extends EventEmitter2 implements TableSelectionInterface { .select(editableElement, true) .shrinkToElementNode() .shrinkToTextNode() - .collapse(false); + .collapse(start); setTimeout(() => { change.range.select(range); }, 20); @@ -1090,9 +1090,10 @@ class TableSelection extends EventEmitter2 implements TableSelectionInterface { top += rect.top - (vRect?.top || 0) - 13; left += rect.left - (vRect?.left || 0); } - const sLeft = removeUnit( - this.table.wrapper?.find('.data-scrollbar')?.css('left') || '0', - ); + const sLeft = + removeUnit( + this.table.wrapper?.find('.data-scrollbar')?.css('left') || '0', + ) + removeUnit(this.table.wrapper?.css('margin-left') || '0'); left += sLeft; const headerHeight = diff --git a/plugins/table/src/component/template.ts b/plugins/table/src/component/template.ts index a75c8704..f05e37d6 100644 --- a/plugins/table/src/component/template.ts +++ b/plugins/table/src/component/template.ts @@ -13,6 +13,7 @@ import { } from '../types'; const TABLE_WRAPPER_CLASS_NAME = 'table-wrapper'; +const TABLE_OVERFLOW_CLASS_NAME = 'table-overflow'; const TABLE_CLASS_NAME = 'data-table'; const COLS_HEADER_CLASS_NAME = 'table-cols-header'; const COLS_HEADER_ITEM_CLASS_NAME = 'table-cols-header-item'; @@ -41,6 +42,7 @@ const TABLE_TD_BG_CLASS_NAME = 'table-main-bg'; class Template implements TemplateInterface { static readonly TABLE_WRAPPER_CLASS = `.${TABLE_WRAPPER_CLASS_NAME}`; + static readonly TABLE_OVERFLOW_CLASS = `.${TABLE_OVERFLOW_CLASS_NAME}`; static readonly TABLE_CLASS = `.${TABLE_CLASS_NAME}`; static readonly COLS_HEADER_CLASS = `.${COLS_HEADER_CLASS_NAME}`; static readonly COLS_HEADER_ITEM_CLASS = `.${COLS_HEADER_ITEM_CLASS_NAME}`; @@ -122,7 +124,7 @@ class Template implements TemplateInterface { * @return {string} 返回 html 字符串 */ htmlEdit( - { rows, cols, html, noBorder }: TableValue, + { rows, cols, html, noBorder, overflow }: TableValue, menus: TableMenu, ): string { cols = cols === -Infinity ? 1 : cols; @@ -188,7 +190,9 @@ class Template implements TemplateInterface { noBorder === true ? " data-table-no-border='true'" : '' } ${DATA_TRANSIENT_ATTRIBUTES}="class">${colgroup}${trs}`; - return `
${tableHeader}
${this.renderColsHeader( + return `
${tableHeader}
${this.renderColsHeader( cols, )}${table}${placeholder}${tableHighlight}
${this.renderRowsHeader( rows, diff --git a/plugins/table/src/index.css b/plugins/table/src/index.css index f025f060..79a11111 100644 --- a/plugins/table/src/index.css +++ b/plugins/table/src/index.css @@ -27,7 +27,7 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] } .data-table tr { - height: 33px; + height: 35px; } .data-table tr td { @@ -67,7 +67,7 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] } .table-wrapper.scrollbar-show { - margin-bottom: -8px; + margin-bottom: -10px; } .table-wrapper.data-table-highlight tr td[table-cell-selection]:after { @@ -98,9 +98,8 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] background-color: #ffffff; } -.table-wrapper.data-table-highlight-all .table-header { +.table-wrapper.data-table-highlight-all .table-header .table-header-item { background: rgba(255, 77, 79, 0.4) !important; - border-color: rgba(255, 77, 79, 0.4) !important; } .table-wrapper .table-header-item:hover{ @@ -113,16 +112,15 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] .table-wrapper .table-header.selected .table-header-item { background: #4daaff; + border-color: #4daaff; } .table-wrapper .table-cols-header { position: relative; - height: 14px; + height: 13px; display: none; width: 100%; cursor: default; - margin-bottom: -1px; - z-index: 2; } .table-wrapper.active .table-cols-header { @@ -131,7 +129,7 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] .table-wrapper .table-cols-header .table-cols-header-item { position: relative; - height: 14px; + height: 13px; width: auto; border: 1px solid #dfdfdf; border-bottom: 0 none; @@ -161,7 +159,6 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] background: #fff; z-index: 1; border-radius: 0; - height: 14px; border-bottom: 0; cursor: move; } @@ -250,11 +247,11 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] width: 14px; z-index: 1; border-right: 0; - visibility: hidden; + display: none; } .table-wrapper.active .table-rows-header { - visibility: visible; + display: block; } .table-wrapper .table-rows-header .table-rows-header-item { @@ -387,7 +384,7 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] .table-wrapper .table-viewport .scrollbar-shadow-left { top: 0; - bottom: 8px; + bottom: 10px; } .table-wrapper.active .table-viewport .scrollbar-shadow-left { @@ -397,7 +394,7 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] .table-wrapper .table-viewport .scrollbar-shadow-right { top: 0; - bottom: 8px; + bottom: 10px; } .table-wrapper.active .table-viewport .scrollbar-shadow-right { @@ -469,6 +466,10 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] z-index: 3; } +.table-wrapper .table-main-content * { + max-width: 100%; +} + .table-wrapper .table-main-bg { position: absolute; top: 0; @@ -573,7 +574,7 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] } .table-wrapper.scrollbar-show .data-scrollable.scroll-x { - padding-bottom: 8px; + padding-bottom: 10px; } .table-wrapper .data-scrollable.scroll-x { @@ -584,20 +585,17 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] overflow: hidden; } -.table-wrapper.scrollbar-show .data-scrollable.scroll-x:hover { - /**overflow-x: auto;**/ -} - .table-wrapper.scrollbar-show .data-scrollable.scroll-x .data-scrollbar-x{ margin-bottom: 2px; } .table-wrapper .data-scrollable .data-scrollbar.data-scrollbar-x { - height: 4px; + height: 6px; + z-index: 5; } .table-wrapper .data-scrollable .data-scrollbar.data-scrollbar-x .data-scrollbar-trigger { - height: 4px; + height: 6px; } .table-wrapper .table-rows-header .table-row-delete-button,.table-wrapper .table-rows-header .table-row-add-button { @@ -697,11 +695,11 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] } .data-table-reader.data-scrollable.scroll-x { - padding-bottom: 8px; + padding-bottom: 10px; } .data-table-reader .scrollbar-shadow-left, .data-table-reader .scrollbar-shadow-right { - bottom: 8px; + bottom: 10px; } .data-table-reader.scrollbar-show.data-scrollable.scroll-x .data-scrollbar-x{ @@ -709,14 +707,20 @@ div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"] } .data-table-reader.data-scrollable .data-scrollbar.data-scrollbar-x { - height: 4px; + height: 6px; } .data-table-reader.data-scrollable .data-scrollbar.data-scrollbar-x .data-scrollbar-trigger { - height: 4px; + height: 6px; } [data-card-key="table"].data-card-block-max > [data-card-element="body"] > [data-card-element="center"] { padding: 48px; margin-top: 4px; +} +/** +表格可溢出样式 +**/ +.table-wrapper.table-overflow { + width: auto; } \ No newline at end of file diff --git a/plugins/table/src/index.ts b/plugins/table/src/index.ts index 8c5f1bb4..17675acc 100644 --- a/plugins/table/src/index.ts +++ b/plugins/table/src/index.ts @@ -21,6 +21,10 @@ import { TableInterface } from './types'; export interface Options extends PluginOptions { hotkey?: string | Array; + overflow?: { + maxLeftWidth?: () => number; + maxRightWidth?: () => number; + }; markdown?: boolean; } @@ -265,6 +269,7 @@ class Table extends Plugin { this.editor.card.insert(TableComponent.cardName, { rows: rows || 3, cols: cols || 3, + overflow: this.options.overflow, }); } diff --git a/plugins/table/src/types.ts b/plugins/table/src/types.ts index 562d58f9..b4d1d1ad 100644 --- a/plugins/table/src/types.ts +++ b/plugins/table/src/types.ts @@ -107,6 +107,7 @@ export type TableValue = { html?: string; color?: string; noBorder?: boolean; + overflow?: boolean; }; export type TableMenuItem = { @@ -367,7 +368,7 @@ export interface TableSelectionInterface extends EventEmitter2 { hideHighlight(): void; - focusCell(cell: NodeInterface | Node): void; + focusCell(cell: NodeInterface | Node, start?: boolean): void; selectCellRange(cell: NodeInterface | Node): void; diff --git a/plugins/video/README.md b/plugins/video/README.md index 2ac6a537..f4018c2f 100644 --- a/plugins/video/README.md +++ b/plugins/video/README.md @@ -40,6 +40,14 @@ new Engine(...,{ }) ``` +### 是否显示视频标题 + +默认显示 + +```ts +showTitle?: boolean +``` + ### 文件上传 `action`: 上传地址,始终使用 `POST` 请求 diff --git a/plugins/video/src/component/index.css b/plugins/video/src/component/index.css index 6fa0d264..f4995423 100644 --- a/plugins/video/src/component/index.css +++ b/plugins/video/src/component/index.css @@ -1,5 +1,7 @@ -[data-card-key="video"] { - outline: 1px solid #ddd; +.data-video { + margin: 0 auto; + position: relative; + cursor: pointer; } .data-video-content { position: relative; @@ -9,6 +11,8 @@ .data-video-content video { width: 100%; outline: none; + position: relative; + z-index: 1; } .data-video-uploading, .data-video-uploaded, @@ -23,7 +27,7 @@ line-height: 0; } .data-video-active { - outline: 1px solid #d9d9d9; + user-select: none; } .data-video-center { position: absolute; @@ -80,4 +84,21 @@ border-radius: 100%; vertical-align: middle; margin: -2px 5px 0 0; - } \ No newline at end of file + } + +.data-video-title { + text-align: center; +} +.data-video-title::selection { + background: transparent +} +.data-video .data-resizer { + z-index: inherit; +} +.data-video .data-resizer .data-resizer-holder { + z-index: 2; +} + +.data-video .data-resizing { + z-index: 3; +} \ No newline at end of file diff --git a/plugins/video/src/component/index.ts b/plugins/video/src/component/index.ts index 739ecf87..bb80ee0c 100644 --- a/plugins/video/src/component/index.ts +++ b/plugins/video/src/component/index.ts @@ -1,16 +1,18 @@ -import { Tooltip } from '@aomao/engine'; +import type { + CardToolbarItemOptions, + ToolbarItemOptions, + NodeInterface, + ResizerInterface, +} from '@aomao/engine'; import { $, Card, - CardToolbarItemOptions, CardType, escape, getFileSize, isEngine, - isMobile, - NodeInterface, sanitizeUrl, - ToolbarItemOptions, + Resizer, } from '@aomao/engine'; import './index.css'; @@ -49,6 +51,22 @@ export type VideoValue = { * 视频大小 */ size?: number; + /** + * 宽度 + */ + width?: number; + /** + * 高度 + */ + height?: number; + /** + * 真实宽度 + */ + naturalWidth?: number; + /** + * 真实高度 + */ + naturalHeight?: number; /** * 错误状态下的错误信息 */ @@ -56,6 +74,14 @@ export type VideoValue = { }; class VideoComponent extends Card { + maxWidth: number = 0; + resizer?: ResizerInterface; + video?: NodeInterface; + rate: number = 1; + isLoad: boolean = false; + container?: NodeInterface; + videoContainer?: NodeInterface; + title?: NodeInterface; static get cardName() { return 'video'; } @@ -68,7 +94,9 @@ class VideoComponent extends Card { return false; } - private container?: NodeInterface; + static get singleSelectable() { + return false; + } getLocales() { return this.editor.language.get<{ [key: string]: string }>('video'); @@ -102,7 +130,9 @@ class VideoComponent extends Card { } const fileSize: string = size ? getFileSize(size) : ''; - + const titleElement = name + ? `
${escape(name)}
` + : ''; if (status === 'uploading') { return `
@@ -143,10 +173,11 @@ class VideoComponent extends Card {
`; } - + const videoPlugin = this.editor.plugin.components['video']; return `
+ ${videoPlugin && videoPlugin.options.showTitle !== false ? titleElement : ''}
`; } @@ -176,12 +207,18 @@ class VideoComponent extends Card { if (cover) { video.poster = sanitizeUrl(this.onBeforeRender('cover', cover)); } - + this.maxWidth = this.getMaxWidth(); + if (value.naturalHeight && value.naturalWidth) + this.rate = value.naturalHeight / value.naturalWidth; this.container?.find('.data-video-content').append(video); - + this.videoContainer = this.container?.find('.data-video-content'); video.oncontextmenu = function () { return false; }; + + this.video = $(video); + this.title = this.container?.find('.data-video-title'); + this.resetSize(); // 一次渲染时序开启 controls 会触发一次内容为空的 window.onerror,疑似 chrome bug setTimeout(() => { video.controls = true; @@ -234,9 +271,158 @@ class VideoComponent extends Card { this.container?.find('.percent').html(`${percent}%`); } + getMaxWidth(node: NodeInterface = this.getCenter()) { + const block = this.editor.block.closest(node).get(); + if (!block) return 0; + return block.clientWidth - 6; + } + + /** + * 重置大小 + */ + resetSize() { + if (!this.videoContainer) return; + const value = this.getValue(); + if (!value) return; + this.videoContainer.css({ + width: '', + //height: '', + }); + this.container?.css({ + width: '', + }); + + const video = this.video?.get(); + if (!video) return; + let { width, height, naturalWidth, naturalHeight } = value; + if (!naturalWidth) { + naturalWidth = video.videoWidth; + } + if (!naturalHeight) { + naturalHeight = video.videoHeight; + } + + if (!height) { + width = naturalWidth; + height = Math.round(this.rate * width); + } else if (!width) { + height = naturalHeight; + width = Math.round(height / this.rate); + } else if (width && height) { + // 修正非正常的比例 + height = Math.round(this.rate * width); + } else { + width = naturalWidth; + height = naturalHeight; + } + + if (width > this.maxWidth) { + width = this.maxWidth; + height = Math.round(width * this.rate); + } + this.container?.css({ + width: `${width}px`, + }); + this.videoContainer.css('width', `${width}px`); + //this.videoContainer.css('height', `${height}px`); + } + + changeSize(width: number, height: number) { + if (width < 24) { + width = 24; + height = width * this.rate; + } + + if (width > this.maxWidth) { + width = this.maxWidth; + height = width * this.rate; + } + + if (height < 24) { + height = 24; + width = height / this.rate; + } + + width = Math.round(width); + height = Math.round(height); + this.videoContainer?.css({ + width: `${width}px`, + //height: `${height}px`, + }); + this.container?.css({ + width: `${width}px`, + }); + this.setValue({ + width, + height, + }); + this.resizer?.destroy(); + this.initResizer(); + } + + onWindowResize = () => { + if (!isEngine(this.editor)) return; + this.maxWidth = this.getMaxWidth(); + this.resetSize(); + + if (this.resizer) { + this.resizer.maxWidth = this.maxWidth; + this.resizer.setSize( + this.videoContainer?.width() || 0, + this.videoContainer?.height() || 0, + ); + } + }; + + initResizer() { + const value = this.getValue(); + if (!value) return; + const { naturalHeight, naturalWidth, status } = value; + if (!naturalHeight || !naturalWidth || status !== 'done') return; + const { width, height, cover } = value; + this.maxWidth = this.getMaxWidth(); + this.rate = naturalHeight / naturalWidth; + window.removeEventListener('resize', this.onWindowResize); + window.addEventListener('resize', this.onWindowResize); + // 拖动调整视频大小 + const resizer = new Resizer({ + imgUrl: cover, + width: width || naturalWidth, + height: height || naturalHeight, + rate: this.rate, + maxWidth: this.maxWidth, + onChange: ({ width, height }) => this.changeSize(width, height), + }); + this.resizer = resizer; + const resizerNode = resizer.render(); + this.videoContainer?.append(resizerNode); + } + onActivate(activated: boolean) { - if (activated) this.container?.addClass('data-video-active'); - else this.container?.removeClass('data-video-active'); + if (activated) { + this.container?.addClass('data-video-active'); + this.initResizer(); + } else { + this.container?.removeClass('data-video-active'); + this.resizer?.destroy(); + } + } + + onSelectByOther( + selected: boolean, + value?: { + color: string; + rgb: string; + }, + ): NodeInterface | void { + this.container?.css( + 'outline', + selected ? '2px solid ' + value!.color : '', + ); + const className = 'card-selected-other'; + if (selected) this.root.addClass(className); + else this.root.removeClass(className); + return this.container; } checker( @@ -284,6 +470,8 @@ class VideoComponent extends Card { const { command, plugin } = this.editor; const { video_id, status } = value; const locales = this.getLocales(); + + this.maxWidth = this.getMaxWidth(); //阅读模式 if (!isEngine(this.editor)) { if (status === 'done') { @@ -418,6 +606,7 @@ class VideoComponent extends Card { : value.download, }; this.container = $(this.renderTemplate(newValue)); + this.video = this.container.find('video'); center.empty(); center.append(this.container); this.initPlayer(); @@ -436,17 +625,27 @@ class VideoComponent extends Card { ); return this.container; } else { - return $(this.renderTemplate(value)); + this.container = $(this.renderTemplate(value)); + return this.container; } } + handleClick = () => { + if (isEngine(this.editor) && !this.activated) { + this.editor.card.activate(this.root); + } + }; + didRender() { super.didRender(); - this.container?.on(isMobile ? 'touchstart' : 'click', () => { - if (isEngine(this.editor) && !this.activated) { - this.editor.card.activate(this.root); - } - }); + this.toolbarModel?.setDefaultAlign('top'); + this.container?.on('click', this.handleClick); + } + + destroy() { + super.destroy(); + this.container?.off('click', this.handleClick); + window.removeEventListener('resize', this.onWindowResize); } } diff --git a/plugins/video/src/index.ts b/plugins/video/src/index.ts index 770a84e4..6b4ca7d9 100644 --- a/plugins/video/src/index.ts +++ b/plugins/video/src/index.ts @@ -10,6 +10,7 @@ import { NodeInterface, Plugin, PluginEntry, + PluginOptions, READY_CARD_KEY, sanitizeUrl, SchemaInterface, @@ -18,12 +19,15 @@ import VideoComponent, { VideoValue } from './component'; import VideoUploader from './uploader'; import locales from './locales'; -export default class VideoPlugin extends Plugin<{ +export interface VideoOptions extends PluginOptions { onBeforeRender?: ( action: 'download' | 'query' | 'cover', url: string, ) => string; -}> { + showTitle?: boolean; +} + +export default class VideoPlugin extends Plugin { static get pluginName() { return 'video'; } @@ -46,6 +50,10 @@ export default class VideoPlugin extends Plugin<{ cover?: string, size?: number, download?: string, + naturalWidth?: number, + naturalHeight?: number, + width?: number, + height?: number, ): void { const value: VideoValue = { status, @@ -55,6 +63,10 @@ export default class VideoPlugin extends Plugin<{ name: name || url, size, download, + width, + height, + naturalWidth, + naturalHeight, }; if (status === 'error') { value.url = ''; diff --git a/plugins/video/src/uploader.ts b/plugins/video/src/uploader.ts index ebd26141..9b1d5035 100644 --- a/plugins/video/src/uploader.ts +++ b/plugins/video/src/uploader.ts @@ -14,7 +14,7 @@ import { import VideoComponent from './component'; -export interface Options extends PluginOptions { +export interface VideoUploaderOptions extends PluginOptions { /** * 视频上传地址 */ @@ -70,6 +70,9 @@ export interface Options extends PluginOptions { id?: string; cover?: string; status?: string; + name?: string; + width?: number; + height?: number; } | string; }; @@ -96,7 +99,7 @@ export interface Options extends PluginOptions { }; } -export default class extends Plugin { +export default class extends Plugin { private cardComponents: { [key: string]: VideoComponent } = {}; static get pluginName() { @@ -219,6 +222,12 @@ export default class extends Plugin { const download: string = response.download || (response.data && response.data.download); + const width: number = + response.width || + (response.data && response.data.width); + const height: number = + response.height || + (response.data && response.data.height); let status: string = response.status || (response.data && response.data.status); @@ -232,6 +241,8 @@ export default class extends Plugin { cover?: string; download?: string; status?: string; + width?: number; + height?: number; } | string; } = { @@ -242,6 +253,8 @@ export default class extends Plugin { cover, download, status, + width, + height, }, }; if (parse) { @@ -253,6 +266,9 @@ export default class extends Plugin { cover?: string; download?: string; status?: string; + name?: string; + width?: number; + height?: number; }; if (typeof customizeResult.data === 'string') result.data = { @@ -307,6 +323,8 @@ export default class extends Plugin { ? { url: result.data } : { ...result.data, + naturalWidth: result.data.width, + naturalHeight: result.data.height, }, ); } diff --git a/site-ssr/app/controller/upload.js b/site-ssr/app/controller/upload.js index 9bfb7cac..3de014ca 100644 --- a/site-ssr/app/controller/upload.js +++ b/site-ssr/app/controller/upload.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const sendToWormhole = require('stream-wormhole'); +const ffmpeg = require('fluent-ffmpeg'); const { Controller } = require('egg'); class UploadController extends Controller { @@ -159,10 +160,53 @@ class UploadController extends Controller { // 监听写入完成事件 remoteFileStrem.on('finish', () => { if (errFlag) return; - resolve({ + const result = { url, download: url, - }); + name: sourceName, + }; + try { + ffmpeg.setFfmpegPath( + path.join(app.baseDir, './app/ffmpeg/win/ffmpeg.exe'), + ); + ffmpeg.setFfprobePath( + path.join(app.baseDir, './app/ffmpeg/win/ffprobe.exe'), + ); + ffmpeg.ffprobe(filePath, (err, metadata) => { + const fileName = new Date().getTime() + '-v-image.png'; // stream对象也包含了文件名,大小等基本信息 + + // 创建文件写入路径 + const imagePath = path.join( + app.baseDir, + `/app/public/upload/${fileName}`, + ); + + if (err) { + console.error(err); + reject(err); + return; + } else { + const { width, height } = metadata.streams[0]; + result.width = width; + result.height = height; + ffmpeg(filePath) + .screenshots({ + timestamps: ['50%'], + filename: fileName, + folder: path.join( + app.baseDir, + '/app/public/upload', + ), + }) + .on('end', () => { + result.cover = `${this.domain}/upload/${fileName}`; + resolve(result); + }); + } + }); + } catch { + resolve(result); + } }); }); diff --git a/site-ssr/app/data/comment.json b/site-ssr/app/data/comment.json index f540c1b8..88be019d 100644 --- a/site-ssr/app/data/comment.json +++ b/site-ssr/app/data/comment.json @@ -52,15 +52,28 @@ ] }, { - "id": "yreo1zOnA0tpLMpO4h", - "title": "g12s", - "status": "true", + "id": "B2apyT5NIgXPe4tRX7", + "title": "hj", + "status": "false", "children": [ { "id": 5, - "username": "test", + "username": "Guest-undefined", + "content": "ghjhj", + "createdAt": 1639325623775 + } + ] + }, + { + "id": "kAoP518hzaPYD9Mx9z", + "title": "dsfdf", + "status": "true", + "children": [ + { + "id": 6, + "username": "Guest-2", "content": "sdfdf", - "createdAt": 1639328040653 + "createdAt": 1639571978834 } ] } diff --git a/site-ssr/app/data/doc.json b/site-ssr/app/data/doc.json index da67e0c9..bb9ed24c 100644 --- a/site-ssr/app/data/doc.json +++ b/site-ssr/app/data/doc.json @@ -1,13 +1,13 @@ { "id": "demo", "content": { - "value": "


sdfabcdefg123dsf1111d1g12s

12ab12c2112312345

1243sffffd

", + "value": "

sdfsdfsdf

sdfdsfdsfdfdfg

fgfdgfdg

", "paths": [ { - "id": ["yreo1zOnA0tpLMpO4h"], + "id": ["kAoP518hzaPYD9Mx9z"], "path": [ - [2, 0, 22], - [2, 0, 26] + [1, 0, 6], + [1, 0, 11] ] } ] diff --git a/site-ssr/package.json b/site-ssr/package.json index 9d20de32..63bf5c8f 100755 --- a/site-ssr/package.json +++ b/site-ssr/package.json @@ -25,6 +25,7 @@ "egg-scripts": "^2.13.0", "egg-view-assets": "^1.7.0", "egg-view-nunjucks": "^2.3.0", + "fluent-ffmpeg": "^2.1.2", "jsdom": "^16.4.0", "prop-types": "^15.6.2", "qs": "^6.7.0",