feat(toolbar): add toolbar popup

This commit is contained in:
yanmao 2021-12-29 00:28:48 +08:00
parent 6ea0cd9256
commit 20b549643d
25 changed files with 761 additions and 367 deletions

View File

@ -116,7 +116,12 @@ export default defineComponent({
outline: none;
line-height: 32px;
}
.editor-toolbar.editor-toolbar-popup .toolbar-button {
min-width: 24px;
line-height: 24px;
border-radius: 4px;
margin: 0 4px;
}
.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button {
padding: 0 4px;
}

View File

@ -163,7 +163,7 @@ export default defineComponent({
.toolbar-dropdown .toolbar-dropdown-trigger-arrow .data-icon-arrow {
position: absolute;
right: 6px;
top: 15px;
top: calc(100% / 2 - 2px);
width: 8px;
height: 8px;
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjhweCIgaGVpZ2h0PSI1cHgiIHZpZXdCb3g9IjAgMCA4IDUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1Mi41ICg2NzQ2OSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+DQogICAgPHRpdGxlPkdyb3VwIENvcHkgNjwvdGl0bGU+DQogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG9wYWNpdHk9IjAuNDUiPg0KICAgICAgICA8ZyBpZD0idG9vbGJhciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTMyOC4wMDAwMDAsIC0xOC4wMDAwMDApIj4NCiAgICAgICAgICAgIDxnIGlkPSJwYXJhZ3JhcGgtc3R5bGUiPg0KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyNi4wMDAwMDAsIDQuMDAwMDAwKSI+DQogICAgICAgICAgICAgICAgICAgIDxnIGlkPSJHcm91cC1Db3B5LTYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwMi4wMDAwMDAsIDEyLjAwMDAwMCkiPg0KICAgICAgICAgICAgICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjgiIGhlaWdodD0iOCI+PC9yZWN0Pg0KICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTk2MDkzNzUsMi41NTcwMzEyNSBMMy43NDUzMTI1LDYuMzc4MTI1IEMzLjg3NzM0Mzc1LDYuNTI1NzgxMjUgNC4xMDg1OTM3NSw2LjUyNTc4MTI1IDQuMjQwNjI1LDYuMzc4MTI1IEw3LjQwNTQ2ODc1LDIuNTU3MDMxMjUgQzcuNTk2MDkzNzUsMi4zNDI5Njg3NSA3LjQ0NDUzMTI1LDIuMDAzOTA2MjUgNy4xNTc4MTI1LDIuMDAzOTA2MjUgTDAuODQ0NTMxMjUsMi4wMDM5MDYyNSBDMC41NTcwMzEyNSwyLjAwMzkwNjI1IDAuNDA0Njg3NSwyLjM0Mjk2ODc1IDAuNTk2MDkzNzUsMi41NTcwMzEyNSBaIiBpZD0iU2hhcGUiIGZpbGw9IiMwMDAwMDAiIGZpbGwtcnVsZT0ibm9uemVybyI+PC9wYXRoPg0KICAgICAgICAgICAgICAgICAgICA8L2c+DQogICAgICAgICAgICAgICAgPC9nPg0KICAgICAgICAgICAgPC9nPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+);
@ -191,14 +191,14 @@ export default defineComponent({
left: 0px;
}
.editor-toolbar-mobile .toolbar-dropdown .toolbar-dropdown-list {
.editor-toolbar-mobile .toolbar-dropdown .toolbar-dropdown-list,.editor-toolbar-popup .toolbar-dropdown .toolbar-dropdown-list{
bottom: 32px;
top: auto;
max-height: calc(30vh);
overflow: auto;
}
.editor-toolbar-mobile .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list {
.editor-toolbar-mobile .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list,.editor-toolbar-popup .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list{
right: 0px;
}

View File

@ -8,7 +8,8 @@
:placement="isMobile ? 'topRight' : 'bottom'"
>
<template #content>
<div :class="['editor-toolbar', {'editor-toolbar-mobile': isMobile}]" data-element="ui">
<div :class="['editor-toolbar', {'editor-toolbar-mobile': isMobile && !popup,
'editor-toolbar-popup': popup,}]" data-element="ui">
<template v-for="(item , index) in items" :key="index">
<am-button v-if="item.type === 'button'" :key="index" v-bind="item" :engine="engine" />
<am-dropdown v-if="item.type === 'dropdown'" :key="index" v-bind="item" :engine="engine" />

View File

@ -267,16 +267,29 @@ export {
box-shadow: none;
}
.editor-toolbar.editor-toolbar-popup {
position: initial;
box-shadow: none;
top: 0;
left: 0;
border: 0 none;
}
.editor-toolbar-mobile .editor-toolbar-content {
text-align: left;
padding: 0 12px;
}
.editor-toolbar-mobile .editor-toolbar-group {
.editor-toolbar-mobile .editor-toolbar-group,.editor-toolbar-popup .editor-toolbar-group {
border: 0 none;
padding: 0;
}
.editor-toolbar-popup .editor-toolbar-content {
text-align: center;
padding: 0;
}
.editor-toolbar-popover .editor-toolbar {
position: relative;
box-shadow: none;

View File

@ -1,5 +1,5 @@
import { h } from 'vue';
import { CARD_SELECTOR, EngineInterface } from '@aomao/engine';
import { CARD_SELECTOR, EngineInterface, isEngine, Range } from '@aomao/engine';
import { ToolbarItemProps } from '../types';
import TableSelector from '../components/table.vue';
import fontfamily, { defaultData as fontFamilyDefaultData } from './fontfamily';
@ -716,8 +716,11 @@ export const getToolbarDefaultConfig = (
command: { name: 'link', args: ['_blank'] },
title: language['link']['title'],
onDisabled: () => {
const { change, card } = engine;
const range = change.range.get();
const { card } = engine;
const range = isEngine(engine)
? engine.change.range.get()
: Range.from(engine);
if (!range) return !engine.command.queryEnabled('link');
const cardComponent = card.find(range.startNode);
return (
(!!cardComponent &&

View File

@ -31,4 +31,14 @@
z-index: 999;
max-height: calc(80vh);
overflow: auto;
}
/** ------------------- popup ---------------------- **/
.data-toolbar-popup-wrapper {
position: absolute;
z-index: 9999;
padding: 4px;
background-color: #fff;
border-radius: 4px;
border: 1px solid #dee0e3;
box-shadow: 0 4px 8px 0 rgba(31, 35, 41, 0.1);
}

View File

@ -15,6 +15,7 @@ import {
} from '../../types';
import { getToolbarDefaultConfig } from '../../config';
import CollapseComponent, { CollapseComponentInterface } from './collapse';
import ToolbarPopup from './popup';
import './index.css';
type Data = Array<CollapseGroupProps>;
@ -23,7 +24,7 @@ export interface ToolbarValue extends CardValue {
data: Data;
}
class ToolbarComponent<V extends ToolbarValue> extends Card<V> {
class ToolbarComponent<V extends ToolbarValue = ToolbarValue> extends Card<V> {
private keyword?: NodeInterface;
private placeholder?: NodeInterface;
private component?: CollapseComponentInterface;
@ -265,3 +266,4 @@ class ToolbarComponent<V extends ToolbarValue> extends Card<V> {
}
export default ToolbarComponent;
export { ToolbarPopup };

View File

@ -0,0 +1,139 @@
import { createApp, App } from 'vue';
import { $, isEngine, isMobile, Range } from '@aomao/engine';
import type { NodeInterface, EditorInterface } from '@aomao/engine';
import Toolbar from '../../components/toolbar.vue';
import type { GroupItemProps } from '../../types';
type PopupOptions = {
items?: GroupItemProps[];
};
export default class Popup {
#editor: EditorInterface;
#root: NodeInterface;
#point: Record<'left' | 'top', number> = { left: 0, top: -9999 };
#align: 'top' | 'bottom' = 'bottom';
#options: PopupOptions = {};
#vm?: App;
constructor(editor: EditorInterface, options: PopupOptions = {}) {
this.#options = options;
this.#editor = editor;
this.#root = $(`<div class="data-toolbar-popup-wrapper">Test</div>`);
document.body.append(this.#root[0]);
if (isEngine(editor)) {
this.#editor.on('select', this.onSelect);
} else {
document.addEventListener('selectionchange', this.onSelect);
}
if (!isMobile) window.addEventListener('scroll', this.onSelect);
window.addEventListener('resize', this.onSelect);
this.#editor.scrollNode?.on('scroll', this.onSelect);
}
onSelect = () => {
const range = Range.from(this.#editor)
?.cloneRange()
.shrinkToTextNode();
const selection = window.getSelection();
if (
!range ||
!selection ||
!selection.focusNode ||
range.collapsed ||
(!range.commonAncestorNode.inEditor() &&
!range.commonAncestorNode.isRoot())
) {
this.hide();
return;
}
const subRanges = range.getSubRanges();
if (
subRanges.length === 0 ||
(this.#editor.card.active && !this.#editor.card.active.isEditable)
) {
this.hide();
return;
}
const topRange = subRanges[0];
const bottomRange = subRanges[subRanges.length - 1];
const topRect = topRange.getBoundingClientRect();
const bottomRect = bottomRange.getBoundingClientRect();
let rootRect: DOMRect | undefined = undefined;
this.showContent(() => {
rootRect = this.#root.get<HTMLElement>()?.getBoundingClientRect();
console.log(rootRect);
if (!rootRect) {
this.hide();
return;
}
this.#align =
bottomRange.startNode.equal(selection.focusNode!) &&
!topRange.startNode.equal(selection.focusNode!)
? 'bottom'
: 'top';
const space = 12;
let targetRect = this.#align === 'bottom' ? bottomRect : topRect;
if (
this.#align === 'top' &&
targetRect.top - rootRect.height - space <
window.innerHeight -
(this.#editor.scrollNode?.height() || 0)
) {
this.#align = 'bottom';
} else if (
this.#align === 'bottom' &&
targetRect.bottom + rootRect.height + space > window.innerHeight
) {
this.#align = 'top';
}
targetRect = this.#align === 'bottom' ? bottomRect : topRect;
const top =
this.#align === 'top'
? targetRect.top - rootRect.height - space
: targetRect.bottom + space;
this.#point = {
left: targetRect.left + targetRect.width - rootRect.width / 2,
top,
};
this.#root.css({
left: `${this.#point.left}px`,
top: `${this.#point.top}px`,
});
});
};
showContent(callback?: () => void) {
const result = this.#editor.trigger('toolbar-render', this.#options);
this.#vm = createApp(
typeof result === 'object' ? result : Toolbar,
this.#options,
);
this.#vm.mount(this.#root.get<HTMLDivElement>()!);
setTimeout(() => {
if (callback) callback();
}, 100);
}
hide() {
this.#root.css({
left: '0px',
top: '-9999px',
});
}
destroy() {
this.#root.remove();
if (isEngine(this.#editor)) {
this.#editor.on('select', this.onSelect);
} else {
document.removeEventListener('selectionchange', this.onSelect);
}
if (!isMobile) window.removeEventListener('scroll', this.onSelect);
window.removeEventListener('resize', this.onSelect);
this.#editor.scrollNode?.off('scroll', this.onSelect);
if (this.#vm) this.#vm.unmount();
}
}
export type { GroupItemProps };

View File

@ -6,9 +6,10 @@ import {
Plugin,
PluginOptions,
} from '@aomao/engine';
import { CollapseItemProps } from '../types';
import { CollapseItemProps, GroupItemProps } from '../types';
import locales from '../locales';
import ToolbarComponent, { ToolbarValue } from './component';
import ToolbarComponent, { ToolbarPopup } from './component';
import type { ToolbarValue } from './component';
type Config = Array<{
title: string;
@ -16,6 +17,9 @@ type Config = Array<{
}>;
export interface ToolbarOptions extends PluginOptions {
config: Config;
popup?: {
items: GroupItemProps[];
};
}
const defaultConfig = (editor: EditorInterface): Config => {
@ -39,7 +43,9 @@ const defaultConfig = (editor: EditorInterface): Config => {
];
};
class ToolbarPlugin<T extends ToolbarOptions> extends Plugin<T> {
class ToolbarPlugin<
T extends ToolbarOptions = ToolbarOptions,
> extends Plugin<T> {
static get pluginName() {
return 'toolbar';
}
@ -50,6 +56,9 @@ class ToolbarPlugin<T extends ToolbarOptions> extends Plugin<T> {
this.editor.on('parse:value', (node) => this.paserValue(node));
}
this.editor.language.add(locales);
if (this.options.popup) {
new ToolbarPopup(this.editor);
}
}
paserValue(node: NodeInterface) {

View File

@ -293,6 +293,10 @@ export const groupProps = {
>,
default: [],
},
popup: {
type: [Boolean, undefined] as PropType<boolean | undefined>,
default: undefined,
},
icon: collapseItemProps.icon,
content: buttonProps.content,
};
@ -326,7 +330,7 @@ export type GroupItemDataProps = {
items: Array<ToolbarItemProps | string>;
};
type GroupItemProps =
export type GroupItemProps =
| Array<
| ToolbarItemProps
| string

View File

@ -0,0 +1,333 @@
import { merge, omit } from 'lodash-es';
import React, {
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from 'react';
import classnames from 'classnames-es-ts';
import { EngineInterface, isMobile } from '@aomao/engine';
import ToolbarGroup from './group';
import {
getToolbarDefaultConfig,
fontFamilyDefaultData,
fontfamily,
} from './config/toolbar';
import { ButtonProps, DropdownProps, ColorProps, CollapseProps } from './types';
import ToolbarPlugin, { ToolbarComponent } from './plugin';
import { CollapseGroupProps } from './collapse/group';
import { CollapseItemProps } from './collapse/item';
import locales from './locales';
import './index.css';
export type ToolbarItemProps =
| ButtonProps
| DropdownProps
| ColorProps
| CollapseProps;
type GroupItemDataProps = {
// 分组图标-mobile
icon?: React.ReactNode;
// 分组内容-mobile
content?: React.ReactNode | (() => React.ReactNode);
// 子项
items: Array<ToolbarItemProps | string>;
};
export type GroupItemProps =
| Array<
| ToolbarItemProps
| string
| (Omit<CollapseProps, 'groups'> & {
groups: Array<
Omit<CollapseGroupProps, 'items'> & {
items: Array<
Omit<CollapseItemProps, 'engine'> | 'string'
>;
}
>;
})
>
| GroupItemDataProps;
type GroupProps = Omit<GroupItemDataProps, 'items'> & {
items: Array<ToolbarItemProps>;
};
export type ToolbarProps = {
engine: EngineInterface;
items: GroupItemProps[];
className?: string;
popup?: boolean;
};
const Toolbar: React.FC<ToolbarProps> = ({
engine,
className,
popup,
items = [],
}) => {
const [data, setData] = useState<Array<GroupProps>>([]);
//移动端浏览器视图信息
const toolbarRef = useRef<HTMLDivElement | null>(null);
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [mobileView, setMobileView] = useState({ top: 0 });
const caluTimeoutRef = useRef<NodeJS.Timeout | null>(null);
//计算移动浏览器的视图变化
const calcuMobileView = () => {
if (!engine.isFocus() || engine.readonly) return;
if (caluTimeoutRef.current) clearTimeout(caluTimeoutRef.current);
caluTimeoutRef.current = setTimeout(() => {
const rect = toolbarRef.current?.getBoundingClientRect();
const height = rect?.height || 0;
setMobileView({
top:
global.Math.max(
document.body.scrollTop,
document.documentElement.scrollTop,
) +
(window.visualViewport.height || 0) -
height,
});
}, 100);
};
/**
*
*/
const updateState = useCallback(() => {
if (isMobile) calcuMobileView();
const data: Array<GroupProps> = [];
const defaultConfig = getToolbarDefaultConfig(engine);
items.forEach((group) => {
const dataGroup: GroupProps = { items: [] };
if (!Array.isArray(group)) {
dataGroup.icon = group.icon;
dataGroup.content = group.content;
group = group.items;
}
group.forEach((item) => {
let customItem:
| ButtonProps
| DropdownProps
| ColorProps
| CollapseProps
| undefined = undefined;
if (typeof item === 'string') {
const defaultItem = defaultConfig.find((config) =>
item === 'collapse'
? config.type === item
: config.type !== 'collapse' &&
config.name === item,
);
if (defaultItem) customItem = defaultItem;
} else {
const defaultItem = defaultConfig.find((config) =>
item.type === 'collapse'
? config.type === item.type
: config.type !== 'collapse' &&
config.name === item.name,
);
// 解析collapse item 为字符串时
if (item.type === 'collapse') {
const customCollapse: CollapseProps = {
...merge(
omit({ ...defaultItem }, 'groups'),
omit({ ...item }, 'groups'),
),
groups: [],
};
item.groups.forEach((group) => {
const items: Array<
Omit<CollapseItemProps, 'engine'>
> = [];
group.items.forEach((cItem) => {
let targetItem = undefined;
(defaultItem as CollapseProps).groups.some(
(g) =>
g.items.some((i) => {
const isEqual =
i.name ===
(typeof cItem === 'string'
? cItem
: cItem.name);
if (isEqual) {
targetItem = {
...i,
...(typeof cItem ===
'string'
? {}
: cItem),
};
}
return isEqual;
}),
);
if (targetItem) items.push(targetItem);
else if (typeof cItem === 'object')
items.push(cItem);
});
if (items.length > 0) {
customCollapse.groups.push({
...omit(group, 'itmes'),
items,
});
}
});
customItem =
customCollapse.groups.length > 0
? customCollapse
: undefined;
} else if (item.type === 'dropdown') {
customItem = defaultItem
? merge(
defaultItem,
omit({ ...item }, 'type', 'items'),
)
: { ...item };
(customItem as DropdownProps).items = item.items;
} else {
customItem = defaultItem
? merge(defaultItem, omit({ ...item }, 'type'))
: { ...item };
}
}
if (customItem) {
if (customItem.type === 'button') {
if (customItem.onActive)
customItem.active = customItem.onActive();
else if (engine.command.queryEnabled(customItem.name))
customItem.active = engine.command.queryState(
customItem.name,
);
} else if (customItem.type === 'dropdown') {
if (customItem.onActive)
customItem.values = customItem.onActive(
customItem.items,
);
else
customItem.values = engine.command.queryState(
customItem.name,
);
}
if (customItem.type !== 'collapse')
customItem.disabled = customItem.onDisabled
? customItem.onDisabled()
: !engine.command.queryEnabled(customItem.name);
else {
customItem.groups.forEach((group) =>
group.items.forEach((item) => {
item.disabled = item.onDisabled
? item.onDisabled()
: !engine.command.queryEnabled(item.name);
}),
);
}
dataGroup.items.push(customItem);
}
});
if (dataGroup.items.length > 0) data.push(dataGroup);
});
setData(data);
}, [engine, items]);
useMemo(() => {
updateState();
}, [engine, items]);
const update = () => {
if (updateTimeoutRef.current) clearTimeout(updateTimeoutRef.current);
updateTimeoutRef.current = setTimeout(() => {
updateState();
}, 100);
};
useEffect(() => {
engine.language.add(locales);
engine.on('select', update);
engine.on('change', update);
engine.on('blur', update);
engine.on('focus', update);
let scrollTimer: NodeJS.Timeout;
const hideMobileToolbar = () => {
setMobileView({
top: -120,
});
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
calcuMobileView();
}, 200);
};
const handleReadonly = () => {
if (engine.readonly) {
hideMobileToolbar();
} else {
calcuMobileView();
}
};
if (isMobile) {
engine.on('readonly', handleReadonly);
engine.on('blur', hideMobileToolbar);
document.addEventListener('scroll', hideMobileToolbar);
visualViewport.addEventListener('resize', calcuMobileView);
visualViewport.addEventListener('scroll', calcuMobileView);
} else {
engine.on('readonly', update);
}
return () => {
engine.off('select', update);
engine.off('change', update);
engine.off('blur', update);
engine.off('focus', update);
if (isMobile) {
engine.off('readonly', handleReadonly);
engine.off('blur', hideMobileToolbar);
document.removeEventListener('scroll', hideMobileToolbar);
visualViewport.removeEventListener('resize', calcuMobileView);
visualViewport.removeEventListener('scroll', calcuMobileView);
} else {
engine.off('readonly', update);
}
};
}, [engine]);
const onMouseDown = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
const nodeName = (event.target as Node).nodeName;
if (nodeName !== 'INPUT' && nodeName !== 'TEXTAREA')
event.preventDefault();
};
return (
<div
ref={toolbarRef}
className={classnames('editor-toolbar', className, {
'editor-toolbar-mobile': isMobile && !popup,
'editor-toolbar-popup': popup,
})}
style={isMobile ? { top: `${mobileView.top}px` } : {}}
data-element="ui"
onMouseDown={onMouseDown}
onMouseOver={(event) => event.preventDefault()}
onMouseMove={(event) => event.preventDefault()}
onContextMenu={(event) => event.preventDefault()}
>
<div className="editor-toolbar-content">
{data.map((group, index) => (
<ToolbarGroup key={index} engine={engine} {...group} />
))}
</div>
</div>
);
};
export default Toolbar;
export { ToolbarPlugin, ToolbarComponent, fontFamilyDefaultData, fontfamily };

View File

@ -17,6 +17,13 @@
line-height: 32px;
}
.editor-toolbar.editor-toolbar-popup .toolbar-button {
min-width: 24px;
line-height: 24px;
border-radius: 4px;
margin: 0 4px;
}
.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button {
padding: 0 4px;
}

View File

@ -1,19 +1,20 @@
import React from 'react';
import { CARD_SELECTOR, EngineInterface } from '@aomao/engine';
import {
import { CARD_SELECTOR, isEngine, Range } from '@aomao/engine';
import type { EditorInterface } from '@aomao/engine';
import type {
ButtonProps,
DropdownProps,
ColorProps,
CollapseProps,
} from '../../types';
import TableSelector from '../../table';
import './index.css';
import fontfamily, { defaultData as fontFamilyDefaultData } from './fontfamily';
import './index.css';
export { fontfamily, fontFamilyDefaultData };
export const getToolbarDefaultConfig = (
engine: EngineInterface,
engine: EditorInterface,
): Array<ButtonProps | DropdownProps | ColorProps | CollapseProps> => {
const language = engine.language.get('toolbar');
const headingLanguage = language['heading'];
@ -867,8 +868,11 @@ export const getToolbarDefaultConfig = (
command: { name: 'link', args: ['_blank'] },
title: language['link']['title'],
onDisabled: () => {
const { change, card } = engine;
const range = change.range.get();
const { card } = engine;
const range = isEngine(engine)
? engine.change.range.get()
: Range.from(engine);
if (!range) return !engine.command.queryEnabled('link');
const cardComponent = card.find(range.startNode);
return (
(!!cardComponent &&

View File

@ -19,7 +19,7 @@
.toolbar-dropdown .toolbar-dropdown-trigger-arrow .data-icon-arrow {
position: absolute;
right: 6px;
top: 15px;
top: calc(100% / 2 - 2px);
width: 8px;
height: 8px;
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjhweCIgaGVpZ2h0PSI1cHgiIHZpZXdCb3g9IjAgMCA4IDUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1Mi41ICg2NzQ2OSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+DQogICAgPHRpdGxlPkdyb3VwIENvcHkgNjwvdGl0bGU+DQogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG9wYWNpdHk9IjAuNDUiPg0KICAgICAgICA8ZyBpZD0idG9vbGJhciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTMyOC4wMDAwMDAsIC0xOC4wMDAwMDApIj4NCiAgICAgICAgICAgIDxnIGlkPSJwYXJhZ3JhcGgtc3R5bGUiPg0KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyNi4wMDAwMDAsIDQuMDAwMDAwKSI+DQogICAgICAgICAgICAgICAgICAgIDxnIGlkPSJHcm91cC1Db3B5LTYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwMi4wMDAwMDAsIDEyLjAwMDAwMCkiPg0KICAgICAgICAgICAgICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjgiIGhlaWdodD0iOCI+PC9yZWN0Pg0KICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTk2MDkzNzUsMi41NTcwMzEyNSBMMy43NDUzMTI1LDYuMzc4MTI1IEMzLjg3NzM0Mzc1LDYuNTI1NzgxMjUgNC4xMDg1OTM3NSw2LjUyNTc4MTI1IDQuMjQwNjI1LDYuMzc4MTI1IEw3LjQwNTQ2ODc1LDIuNTU3MDMxMjUgQzcuNTk2MDkzNzUsMi4zNDI5Njg3NSA3LjQ0NDUzMTI1LDIuMDAzOTA2MjUgNy4xNTc4MTI1LDIuMDAzOTA2MjUgTDAuODQ0NTMxMjUsMi4wMDM5MDYyNSBDMC41NTcwMzEyNSwyLjAwMzkwNjI1IDAuNDA0Njg3NSwyLjM0Mjk2ODc1IDAuNTk2MDkzNzUsMi41NTcwMzEyNSBaIiBpZD0iU2hhcGUiIGZpbGw9IiMwMDAwMDAiIGZpbGwtcnVsZT0ibm9uemVybyI+PC9wYXRoPg0KICAgICAgICAgICAgICAgICAgICA8L2c+DQogICAgICAgICAgICAgICAgPC9nPg0KICAgICAgICAgICAgPC9nPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+);
@ -47,14 +47,14 @@
left: 0px;
}
.editor-toolbar-mobile .toolbar-dropdown .toolbar-dropdown-list {
.editor-toolbar-mobile .toolbar-dropdown .toolbar-dropdown-list,.editor-toolbar-popup .toolbar-dropdown .toolbar-dropdown-list{
bottom: 32px;
top: auto;
max-height: calc(30vh);
overflow: auto;
}
.editor-toolbar-mobile .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list {
.editor-toolbar-mobile .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list,.editor-toolbar-popup .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list{
right: 0px;
}

View File

@ -32,6 +32,7 @@ export type GroupProps = {
>;
icon?: React.ReactNode;
content?: React.ReactNode | (() => React.ReactNode);
popup?: boolean;
};
const ToolbarGroup: React.FC<GroupProps> = ({
@ -39,6 +40,7 @@ const ToolbarGroup: React.FC<GroupProps> = ({
items,
icon,
content,
popup,
}) => {
const renderItems = () => {
return items.map((item, index) => {
@ -76,7 +78,8 @@ const ToolbarGroup: React.FC<GroupProps> = ({
content={
<div
className={classNames('editor-toolbar', {
'editor-toolbar-mobile': isMobile,
'editor-toolbar-mobile': isMobile && !popup,
'editor-toolbar-popup': popup,
})}
data-element="ui"
>

View File

@ -36,16 +36,29 @@
box-shadow: none;
}
.editor-toolbar.editor-toolbar-popup {
position: initial;
box-shadow: none;
top: 0;
left: 0;
border: 0 none;
}
.editor-toolbar-mobile .editor-toolbar-content {
text-align: left;
padding: 0 12px;
}
.editor-toolbar-mobile .editor-toolbar-group {
.editor-toolbar-mobile .editor-toolbar-group,.editor-toolbar-popup .editor-toolbar-group {
border: 0 none;
padding: 0;
}
.editor-toolbar-popup .editor-toolbar-content {
text-align: center;
padding: 0;
}
.editor-toolbar-popover .editor-toolbar {
position: relative;
box-shadow: none;

View File

@ -1,326 +1,9 @@
import { merge, omit } from 'lodash-es';
import React, {
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from 'react';
import classnames from 'classnames-es-ts';
import { EngineInterface, isMobile } from '@aomao/engine';
import ToolbarGroup from './group';
import {
getToolbarDefaultConfig,
fontFamilyDefaultData,
fontfamily,
} from './config/toolbar';
import { ButtonProps, DropdownProps, ColorProps, CollapseProps } from './types';
import { fontFamilyDefaultData, fontfamily } from './config/toolbar';
import ToolbarPlugin, { ToolbarComponent } from './plugin';
import { CollapseGroupProps } from './collapse/group';
import { CollapseItemProps } from './collapse/item';
import locales from './locales';
import Toolbar from './Toolbar';
import type { ToolbarProps, GroupItemProps, ToolbarItemProps } from './Toolbar';
import './index.css';
type ToolbarItemProps =
| ButtonProps
| DropdownProps
| ColorProps
| CollapseProps;
type GroupItemDataProps = {
// 分组图标-mobile
icon?: React.ReactNode;
// 分组内容-mobile
content?: React.ReactNode | (() => React.ReactNode);
// 子项
items: Array<ToolbarItemProps | string>;
};
type GroupItemProps =
| Array<
| ToolbarItemProps
| string
| (Omit<CollapseProps, 'groups'> & {
groups: Array<
Omit<CollapseGroupProps, 'items'> & {
items: Array<
Omit<CollapseItemProps, 'engine'> | 'string'
>;
}
>;
})
>
| GroupItemDataProps;
type GroupProps = Omit<GroupItemDataProps, 'items'> & {
items: Array<ToolbarItemProps>;
};
export type ToolbarProps = {
engine: EngineInterface;
items: Array<GroupItemProps>;
className?: string;
};
const Toolbar: React.FC<ToolbarProps> = ({ engine, className, items = [] }) => {
const [data, setData] = useState<Array<GroupProps>>([]);
//移动端浏览器视图信息
const toolbarRef = useRef<HTMLDivElement | null>(null);
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [mobileView, setMobileView] = useState({ top: 0 });
const caluTimeoutRef = useRef<NodeJS.Timeout | null>(null);
//计算移动浏览器的视图变化
const calcuMobileView = () => {
if (!engine.isFocus() || engine.readonly) return;
if (caluTimeoutRef.current) clearTimeout(caluTimeoutRef.current);
caluTimeoutRef.current = setTimeout(() => {
const rect = toolbarRef.current?.getBoundingClientRect();
const height = rect?.height || 0;
setMobileView({
top:
global.Math.max(
document.body.scrollTop,
document.documentElement.scrollTop,
) +
(window.visualViewport.height || 0) -
height,
});
}, 100);
};
/**
*
*/
const updateState = useCallback(() => {
if (isMobile) calcuMobileView();
const data: Array<GroupProps> = [];
const defaultConfig = getToolbarDefaultConfig(engine);
items.forEach((group) => {
const dataGroup: GroupProps = { items: [] };
if (!Array.isArray(group)) {
dataGroup.icon = group.icon;
dataGroup.content = group.content;
group = group.items;
}
group.forEach((item) => {
let customItem:
| ButtonProps
| DropdownProps
| ColorProps
| CollapseProps
| undefined = undefined;
if (typeof item === 'string') {
const defaultItem = defaultConfig.find((config) =>
item === 'collapse'
? config.type === item
: config.type !== 'collapse' &&
config.name === item,
);
if (defaultItem) customItem = defaultItem;
} else {
const defaultItem = defaultConfig.find((config) =>
item.type === 'collapse'
? config.type === item.type
: config.type !== 'collapse' &&
config.name === item.name,
);
// 解析collapse item 为字符串时
if (item.type === 'collapse') {
const customCollapse: CollapseProps = {
...merge(
omit({ ...defaultItem }, 'groups'),
omit({ ...item }, 'groups'),
),
groups: [],
};
item.groups.forEach((group) => {
const items: Array<
Omit<CollapseItemProps, 'engine'>
> = [];
group.items.forEach((cItem) => {
let targetItem = undefined;
(defaultItem as CollapseProps).groups.some(
(g) =>
g.items.some((i) => {
const isEqual =
i.name ===
(typeof cItem === 'string'
? cItem
: cItem.name);
if (isEqual) {
targetItem = {
...i,
...(typeof cItem ===
'string'
? {}
: cItem),
};
}
return isEqual;
}),
);
if (targetItem) items.push(targetItem);
else if (typeof cItem === 'object')
items.push(cItem);
});
if (items.length > 0) {
customCollapse.groups.push({
...omit(group, 'itmes'),
items,
});
}
});
customItem =
customCollapse.groups.length > 0
? customCollapse
: undefined;
} else if (item.type === 'dropdown') {
customItem = defaultItem
? merge(
defaultItem,
omit({ ...item }, 'type', 'items'),
)
: { ...item };
(customItem as DropdownProps).items = item.items;
} else {
customItem = defaultItem
? merge(defaultItem, omit({ ...item }, 'type'))
: { ...item };
}
}
if (customItem) {
if (customItem.type === 'button') {
if (customItem.onActive)
customItem.active = customItem.onActive();
else if (engine.command.queryEnabled(customItem.name))
customItem.active = engine.command.queryState(
customItem.name,
);
} else if (customItem.type === 'dropdown') {
if (customItem.onActive)
customItem.values = customItem.onActive(
customItem.items,
);
else
customItem.values = engine.command.queryState(
customItem.name,
);
}
if (customItem.type !== 'collapse')
customItem.disabled = customItem.onDisabled
? customItem.onDisabled()
: !engine.command.queryEnabled(customItem.name);
else {
customItem.groups.forEach((group) =>
group.items.forEach((item) => {
item.disabled = item.onDisabled
? item.onDisabled()
: !engine.command.queryEnabled(item.name);
}),
);
}
dataGroup.items.push(customItem);
}
});
if (dataGroup.items.length > 0) data.push(dataGroup);
});
setData(data);
}, [engine, items]);
useMemo(() => {
updateState();
}, [engine, items]);
const update = () => {
if (updateTimeoutRef.current) clearTimeout(updateTimeoutRef.current);
updateTimeoutRef.current = setTimeout(() => {
updateState();
}, 100);
};
useEffect(() => {
engine.language.add(locales);
engine.on('select', update);
engine.on('change', update);
engine.on('blur', update);
engine.on('focus', update);
let scrollTimer: NodeJS.Timeout;
const hideMobileToolbar = () => {
setMobileView({
top: -120,
});
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
calcuMobileView();
}, 200);
};
const handleReadonly = () => {
if (engine.readonly) {
hideMobileToolbar();
} else {
calcuMobileView();
}
};
if (isMobile) {
engine.on('readonly', handleReadonly);
engine.on('blur', hideMobileToolbar);
document.addEventListener('scroll', hideMobileToolbar);
visualViewport.addEventListener('resize', calcuMobileView);
visualViewport.addEventListener('scroll', calcuMobileView);
} else {
engine.on('readonly', update);
}
return () => {
engine.off('select', update);
engine.off('change', update);
engine.off('blur', update);
engine.off('focus', update);
if (isMobile) {
engine.off('readonly', handleReadonly);
engine.off('blur', hideMobileToolbar);
document.removeEventListener('scroll', hideMobileToolbar);
visualViewport.removeEventListener('resize', calcuMobileView);
visualViewport.removeEventListener('scroll', calcuMobileView);
} else {
engine.off('readonly', update);
}
};
}, [engine]);
const onMouseDown = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
const nodeName = (event.target as Node).nodeName;
if (nodeName !== 'INPUT' && nodeName !== 'TEXTAREA')
event.preventDefault();
};
return (
<div
ref={toolbarRef}
className={classnames('editor-toolbar', className, {
'editor-toolbar-mobile': isMobile,
})}
style={isMobile ? { top: `${mobileView.top}px` } : {}}
data-element="ui"
onMouseDown={onMouseDown}
onMouseOver={(event) => event.preventDefault()}
onMouseMove={(event) => event.preventDefault()}
onContextMenu={(event) => event.preventDefault()}
>
<div className="editor-toolbar-content">
{data.map((group, index) => (
<ToolbarGroup key={index} engine={engine} {...group} />
))}
</div>
</div>
);
};
export default Toolbar;
export { ToolbarPlugin, ToolbarComponent, fontFamilyDefaultData, fontfamily };
export type { ToolbarProps, GroupItemProps, ToolbarItemProps };

View File

@ -31,4 +31,14 @@
z-index: 999;
max-height: calc(80vh);
overflow: auto;
}
/** ------------------- popup ---------------------- **/
.data-toolbar-popup-wrapper {
position: absolute;
z-index: 9999;
padding: 4px;
background-color: #fff;
border-radius: 4px;
border: 1px solid #dee0e3;
box-shadow: 0 4px 8px 0 rgba(31, 35, 41, 0.1);
}

View File

@ -11,8 +11,11 @@ import {
import { CollapseGroupProps } from '../../collapse/group';
import { CollapseItemProps } from '../../collapse/item';
import { getToolbarDefaultConfig } from '../../config/toolbar';
import { CollapseProps } from '../../types';
import CollapseComponent, { CollapseComponentInterface } from './collapse';
import type { CollapseProps } from '../../types';
import CollapseComponent from './collapse';
import type { CollapseComponentInterface } from './collapse';
import ToolbarPopup from './popup';
import type { GroupItemProps } from './popup';
import './index.css';
type Data = Array<CollapseGroupProps>;
@ -20,7 +23,7 @@ export interface ToolbarValue extends CardValue {
data: Data;
}
class ToolbarComponent<T extends ToolbarValue> extends Card<T> {
class ToolbarComponent<T extends ToolbarValue = ToolbarValue> extends Card<T> {
private keyword?: NodeInterface;
private placeholder?: NodeInterface;
private component?: CollapseComponentInterface;
@ -259,3 +262,5 @@ class ToolbarComponent<T extends ToolbarValue> extends Card<T> {
}
export default ToolbarComponent;
export { ToolbarPopup };
export type { GroupItemProps };

View File

@ -0,0 +1,145 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { $, EngineInterface, isEngine, isMobile, Range } from '@aomao/engine';
import type { NodeInterface, EditorInterface } from '@aomao/engine';
import Toolbar from '../../Toolbar';
import type { GroupItemProps } from '../../Toolbar';
type PopupOptions = {
items?: GroupItemProps[];
};
export default class Popup {
#editor: EditorInterface;
#root: NodeInterface;
#point: Record<'left' | 'top', number> = { left: 0, top: -9999 };
#align: 'top' | 'bottom' = 'bottom';
#options: PopupOptions = {};
constructor(editor: EditorInterface, options: PopupOptions = {}) {
this.#options = options;
this.#editor = editor;
this.#root = $(`<div class="data-toolbar-popup-wrapper">Test</div>`);
document.body.append(this.#root[0]);
if (isEngine(editor)) {
this.#editor.on('select', this.onSelect);
} else {
document.addEventListener('selectionchange', this.onSelect);
}
if (!isMobile) window.addEventListener('scroll', this.onSelect);
window.addEventListener('resize', this.onSelect);
this.#editor.scrollNode?.on('scroll', this.onSelect);
}
onSelect = () => {
const range = Range.from(this.#editor)
?.cloneRange()
.shrinkToTextNode();
const selection = window.getSelection();
if (
!range ||
!selection ||
!selection.focusNode ||
range.collapsed ||
(!range.commonAncestorNode.inEditor() &&
!range.commonAncestorNode.isRoot())
) {
this.hide();
return;
}
const subRanges = range.getSubRanges();
if (
subRanges.length === 0 ||
(this.#editor.card.active && !this.#editor.card.active.isEditable)
) {
this.hide();
return;
}
const topRange = subRanges[0];
const bottomRange = subRanges[subRanges.length - 1];
const topRect = topRange.getBoundingClientRect();
const bottomRect = bottomRange.getBoundingClientRect();
let rootRect: DOMRect | undefined = undefined;
this.showContent(() => {
rootRect = this.#root.get<HTMLElement>()?.getBoundingClientRect();
console.log(rootRect);
if (!rootRect) {
this.hide();
return;
}
this.#align =
bottomRange.startNode.equal(selection.focusNode!) &&
!topRange.startNode.equal(selection.focusNode!)
? 'bottom'
: 'top';
const space = 12;
let targetRect = this.#align === 'bottom' ? bottomRect : topRect;
if (
this.#align === 'top' &&
targetRect.top - rootRect.height - space <
window.innerHeight -
(this.#editor.scrollNode?.height() || 0)
) {
this.#align = 'bottom';
} else if (
this.#align === 'bottom' &&
targetRect.bottom + rootRect.height + space > window.innerHeight
) {
this.#align = 'top';
}
targetRect = this.#align === 'bottom' ? bottomRect : topRect;
const top =
this.#align === 'top'
? targetRect.top - rootRect.height - space
: targetRect.bottom + space;
this.#point = {
left: targetRect.left + targetRect.width - rootRect.width / 2,
top,
};
this.#root.css({
left: `${this.#point.left}px`,
top: `${this.#point.top}px`,
});
});
};
showContent(callback?: () => void) {
const result = this.#editor.trigger('toolbar-render', this.#options);
ReactDOM.render(
typeof result === 'object' ? (
result
) : (
<Toolbar
popup={true}
engine={this.#editor as EngineInterface}
items={this.#options.items || []}
/>
),
this.#root.get<HTMLDivElement>()!,
() => {
if (callback) callback();
},
);
}
hide() {
this.#root.css({
left: '0px',
top: '-9999px',
});
}
destroy() {
this.#root.remove();
if (isEngine(this.#editor)) {
this.#editor.on('select', this.onSelect);
} else {
document.removeEventListener('selectionchange', this.onSelect);
}
if (!isMobile) window.removeEventListener('scroll', this.onSelect);
window.removeEventListener('resize', this.onSelect);
this.#editor.scrollNode?.off('scroll', this.onSelect);
}
}
export type { GroupItemProps };

View File

@ -1,15 +1,18 @@
import React from 'react';
import {
DATA_TRANSIENT_ELEMENT,
EditorInterface,
isEngine,
isSafari,
NodeInterface,
Plugin,
} from '@aomao/engine';
import type {
EditorInterface,
NodeInterface,
PluginOptions,
} from '@aomao/engine';
import { CollapseItemProps } from '../collapse/item';
import ToolbarComponent, { ToolbarValue } from './component';
import type { CollapseItemProps } from '../collapse/item';
import ToolbarComponent, { ToolbarPopup } from './component';
import type { ToolbarValue, GroupItemProps } from './component';
import locales from '../locales';
type Config = Array<{
@ -18,6 +21,9 @@ type Config = Array<{
}>;
export interface ToolbarOptions extends PluginOptions {
config: Config;
popup?: {
items: GroupItemProps[];
};
}
const defaultConfig = (editor: EditorInterface): Config => {
@ -36,7 +42,6 @@ const defaultConfig = (editor: EditorInterface): Config => {
'video-uploader',
'math',
'status',
//'mind'
],
},
];
@ -51,20 +56,13 @@ class ToolbarPlugin<T extends ToolbarOptions> extends Plugin<T> {
if (isEngine(this.editor)) {
this.editor.on('keydown:slash', (event) => this.onSlash(event));
this.editor.on('parse:value', (node) => this.paserValue(node));
//this.editor.on('select', this.onSelect)
}
this.editor.language.add(locales);
if (this.options.popup) {
new ToolbarPopup(this.editor);
}
}
// onSelect = () => {
// if (!isEngine(this.editor)) return;
// const { change, card } = this.editor;
// if(card.active) return
// const range = change.range.get().cloneRange().shrinkToTextNode();
// if(range.collapsed) return
// const { startNode, endNode } = range
// }
paserValue(node: NodeInterface) {
if (
node.isCard() &&
@ -117,6 +115,6 @@ class ToolbarPlugin<T extends ToolbarOptions> extends Plugin<T> {
throw new Error('Method not implemented.');
}
}
export { ToolbarComponent };
export { ToolbarComponent, ToolbarPopup };
export type { ToolbarValue };
export default ToolbarPlugin;

View File

@ -40,7 +40,9 @@ const MODE_ALIAS: { [key: string]: string } = {
'c++': 'cpp',
};
export default class<T extends CodeblockOptions> extends Plugin<T> {
export default class<
T extends CodeblockOptions = CodeblockOptions,
> extends Plugin<T> {
static get pluginName() {
return 'codeblock';
}
@ -375,3 +377,4 @@ export default class<T extends CodeblockOptions> extends Plugin<T> {
}
}
export { CodeBlockComponent };
export type { CodeBlockValue };

View File

@ -376,3 +376,4 @@ export default class<T extends CodeBlockOptions> extends Plugin<T> {
}
}
export { CodeBlockComponent };
export type { CodeBlockValue };

View File

@ -131,3 +131,4 @@ class Embed<T extends EmbedOptions> extends Plugin<T> {
export default Embed;
export { EmbedComponent };
export type { EmbedValue };

View File

@ -15,7 +15,9 @@ export interface LinkOptions extends PluginOptions {
hotkey?: string | Array<string>;
markdown?: string;
}
export default class<T extends LinkOptions> extends InlinePlugin<T> {
export default class<
T extends LinkOptions = LinkOptions,
> extends InlinePlugin<T> {
private toolbar?: Toolbar;
static get pluginName() {