diff --git a/packages/designer/src/builtin-simulator/index.ts b/packages/designer/src/builtin-simulator/index.ts index 8cee38433..6bcee7eb7 100644 --- a/packages/designer/src/builtin-simulator/index.ts +++ b/packages/designer/src/builtin-simulator/index.ts @@ -1,3 +1,4 @@ export * from './host'; export * from './host-view'; export * from './renderer'; +export * from './live-editing/live-editing'; diff --git a/packages/designer/src/builtin-simulator/live-editing/live-editing.ts b/packages/designer/src/builtin-simulator/live-editing/live-editing.ts index f1ea22577..828790e57 100644 --- a/packages/designer/src/builtin-simulator/live-editing/live-editing.ts +++ b/packages/designer/src/builtin-simulator/live-editing/live-editing.ts @@ -16,39 +16,59 @@ function defaultSaveContent(content: string, prop: Prop) { prop.setValue(content); } +export interface EditingTarget { + node: Node; + rootElement: HTMLElement; + event: MouseEvent; +} + +const saveHandlers: SaveHandler[] = []; +function addLiveEditingSaveHandler(handler: SaveHandler) { + saveHandlers.push(handler); +} + +const specificRules: SpecificRule[] = []; +function addLiveEditingSpecificRule(rule: SpecificRule) { + specificRules.push(rule); +} + export class LiveEditing { + static addLiveEditingSpecificRule = addLiveEditingSpecificRule; + static addLiveEditingSaveHandler = addLiveEditingSaveHandler; + @obx.ref private _editing: Prop | null = null; - apply(target: { node: Node; rootElement: HTMLElement; event: MouseEvent }) { + apply(target: EditingTarget) { const { node, event, rootElement } = target; const targetElement = event.target as HTMLElement; - const liveTextEditing = node.componentMeta.getMetadata().experimental?.liveTextEditing || []; + const liveTextEditing = node.componentMeta.liveTextEditing; let setterPropElement = getSetterPropElement(targetElement, rootElement); let propTarget = setterPropElement?.dataset.setterProp; - let matched: LiveTextEditingConfig | undefined; - if (propTarget) { - // 已埋点命中 data-setter-prop="proptarget", 从 liveTextEditing 读取配置(mode|onSaveContent) - matched = liveTextEditing.find(config => config.propTarget == propTarget); - } else { - // 执行 embedTextEditing selector 规则,获得第一个节点 是否 contains e.target,若匹配,读取配置 - matched = liveTextEditing.find(config => { - if (!config.selector) { - return false; - } - setterPropElement = config.selector === ':root' ? rootElement : rootElement.querySelector(config.selector); - if (!setterPropElement) { - return false; - } - if (!setterPropElement.contains(targetElement)) { - // try selectorAll - setterPropElement = Array.from(rootElement.querySelectorAll(config.selector)).find(item => item.contains(targetElement)) as HTMLElement; - if (!setterPropElement) { + let matched: (LiveTextEditingConfig & { propElement?: HTMLElement; }) | undefined | null; + if (liveTextEditing) { + if (propTarget) { + // 已埋点命中 data-setter-prop="proptarget", 从 liveTextEditing 读取配置(mode|onSaveContent) + matched = liveTextEditing.find(config => config.propTarget == propTarget); + } else { + // 执行 embedTextEditing selector 规则,获得第一个节点 是否 contains e.target,若匹配,读取配置 + matched = liveTextEditing.find(config => { + if (!config.selector) { return false; } - } - return true; + setterPropElement = queryPropElement(rootElement, targetElement, config.selector); + return setterPropElement ? true : false; + }); + propTarget = matched?.propTarget; + } + } else { + specificRules.some((rule) => { + matched = rule(target); + return matched ? true : false; }); - propTarget = matched?.propTarget; + if (matched) { + propTarget = matched.propTarget; + setterPropElement = matched.propElement || queryPropElement(rootElement, targetElement, matched.selector); + } } if (!propTarget) { @@ -75,7 +95,7 @@ export class LiveEditing { // 4. 监听 blur 事件 // 5. 设置编辑锁定:disable hover | disable select | disable canvas drag - const onSaveContent = matched?.onSaveContent || this.saveHandlers.find(item => item.condition(prop))?.onSaveContent || defaultSaveContent; + const onSaveContent = matched?.onSaveContent || saveHandlers.find(item => item.condition(prop))?.onSaveContent || defaultSaveContent; setterPropElement.setAttribute('contenteditable', matched?.mode && matched.mode !== 'plaintext' ? 'true' : 'plaintext-only'); setterPropElement.classList.add('engine-live-editing'); @@ -99,6 +119,8 @@ export class LiveEditing { this._editing = prop; } + // TODO: process enter | esc events & joint the FocusTracker + // TODO: upward testing for b/i/a html elements // 非文本编辑 @@ -127,13 +149,12 @@ export class LiveEditing { } this._editing = null; } - - private saveHandlers: SaveHandler[] = []; - setSaveHandler(handler: SaveHandler) { - this.saveHandlers.push(handler); - } } +export type SpecificRule = (target: EditingTarget) => (LiveTextEditingConfig & { + propElement?: HTMLElement; +}) | null; + export interface SaveHandler { condition: (prop: Prop) => boolean; onSaveContent: (content: string, prop: Prop) => void; @@ -155,3 +176,22 @@ function selectRange(doc: Document, range: Range) { selection.addRange(range); } } + + +function queryPropElement(rootElement: HTMLElement, targetElement: HTMLElement, selector?: string) { + if (!selector) { + return null; + } + let propElement = selector === ':root' ? rootElement : rootElement.querySelector(selector); + if (!propElement) { + return null; + } + if (!propElement.contains(targetElement)) { + // try selectorAll + propElement = Array.from(rootElement.querySelectorAll(selector)).find(item => item.contains(targetElement)) as HTMLElement; + if (!propElement) { + return null; + } + } + return propElement as HTMLElement; +} diff --git a/packages/designer/src/component-meta.ts b/packages/designer/src/component-meta.ts index d389586fa..411a23ae3 100644 --- a/packages/designer/src/component-meta.ts +++ b/packages/designer/src/component-meta.ts @@ -9,6 +9,8 @@ import { NestingFilter, isTitleConfig, I18nData, + LiveTextEditingConfig, + FieldConfig, } from '@ali/lowcode-types'; import { computed } from '@ali/lowcode-editor-core'; import { Node, ParentalNode } from './document'; @@ -91,6 +93,11 @@ export class ComponentMeta { return config?.combined || config?.props || []; } + private _liveTextEditing?: LiveTextEditingConfig[]; + get liveTextEditing() { + return this._liveTextEditing; + } + private parentWhitelist?: NestingFilter | null; private childWhitelist?: NestingFilter | null; @@ -150,6 +157,26 @@ export class ComponentMeta { : title; } + const liveTextEditing = this._transformedMetadata.experimental?.liveTextEditing || []; + + function collectLiveTextEditing(items: FieldConfig[]) { + items.forEach(config => { + if (config.items) { + collectLiveTextEditing(config.items); + } else { + const liveConfig = config.liveTextEditing || config.extraProps?.liveTextEditing; + if (liveConfig) { + liveTextEditing.push({ + propTarget: String(config.name), + ...liveConfig, + }); + } + } + }); + } + collectLiveTextEditing(this.configure); + this._liveTextEditing = liveTextEditing.length > 0 ? liveTextEditing : undefined; + const { configure = {} } = this._transformedMetadata; this._acceptable = false; diff --git a/packages/editor-skeleton/src/components/field/fields.tsx b/packages/editor-skeleton/src/components/field/fields.tsx index 30b28d7cf..6f949dc7c 100644 --- a/packages/editor-skeleton/src/components/field/fields.tsx +++ b/packages/editor-skeleton/src/components/field/fields.tsx @@ -1,4 +1,5 @@ import { Component } from 'react'; +import { isObject } from 'lodash'; import classNames from 'classnames'; import { Icon } from '@alifd/next'; import { Title, Tip } from '@ali/lowcode-editor-core'; @@ -7,6 +8,7 @@ import { PopupPipe, PopupContext } from '../popup'; import { intl, intlNode } from '../../locale'; import './index.less'; import { IconClear } from 'editor-skeleton/src/icons/clear'; +import InlineTip from './inlinetip'; export interface FieldProps { className?: string; @@ -14,6 +16,8 @@ export interface FieldProps { defaultDisplay?: 'accordion' | 'inline' | 'block'; collapsed?: boolean; valueState?: number; + name?: string; + tip?: any; onExpandChange?: (expandState: boolean) => void; onClear?: () => void; } @@ -76,11 +80,36 @@ export class Field extends Component { } } + getTipContent(propName: string, tip?: any): any { + let tipContent = ( +
+
属性:{propName}
+
+ ); + + if (isObject(tip)) { + tipContent = ( +
+
属性:{propName}
+
说明:{tip.content}
+
+ ); + } else if (tip) { + tipContent = ( +
+
属性:{propName}
+
说明:{tip}
+
+ ); + } + return tipContent; + } + render() { - const { className, children, title, valueState, onClear } = this.props; + const { className, children, title, valueState, onClear, name: propName, tip } = this.props; const { display, collapsed } = this.state; const isAccordion = display === 'accordion'; - + const tipContent = this.getTipContent(propName, tip); return (
{
{createValueState(valueState, onClear)} + <InlineTip position="top">{tipContent}</InlineTip> </div> {isAccordion && <Icon className="lc-field-icon" type="arrow-up" size="xs" />} </div> @@ -115,7 +145,7 @@ export class Field extends Component<FieldProps> { */ function createValueState(valueState?: number, onClear?: () => void) { let tip: any = null; - let className: string = 'lc-valuestate'; + let className = 'lc-valuestate'; let icon: any = null; if (valueState) { if (valueState < 0) { @@ -139,10 +169,12 @@ function createValueState(valueState?: number, onClear?: () => void) { // unset 占位空间 } - return <i className={className} onClick={onClear}> - {icon} - {tip && <Tip>{tip}</Tip>} - </i>; + return ( + <i className={className} onClick={onClear}> + {icon} + {tip && <Tip>{tip}</Tip>} + </i> + ); } export interface PopupFieldProps extends FieldProps { diff --git a/packages/editor-skeleton/src/components/field/inlinetip.tsx b/packages/editor-skeleton/src/components/field/inlinetip.tsx new file mode 100644 index 000000000..39dd55543 --- /dev/null +++ b/packages/editor-skeleton/src/components/field/inlinetip.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +export interface InlineTipProps { + position: string; + theme?: 'green' | 'black'; + children: React.ReactNode; +} + +export default class InlineTip extends React.Component<InlineTipProps> { + static displayName = 'InlineTip'; + + static defaultProps = { + position: 'auto', + theme: 'black', + }; + + render(): React.ReactNode { + const { position, theme, children } = this.props; + return ( + <div style={{ display: 'none' }} data-role="tip" data-position={position} data-theme={theme}> + {children} + </div> + ); + } +} diff --git a/packages/editor-skeleton/src/components/settings/settings-pane.tsx b/packages/editor-skeleton/src/components/settings/settings-pane.tsx index bffa799fa..cb6ed7ec9 100644 --- a/packages/editor-skeleton/src/components/settings/settings-pane.tsx +++ b/packages/editor-skeleton/src/components/settings/settings-pane.tsx @@ -66,6 +66,7 @@ class SettingFieldView extends Component<{ field: SettingField }> { valueState: field.isRequired ? 10 : field.valueState, onExpandChange: (expandState) => field.setExpanded(expandState), onClear: () => field.clearValue(), + ...extraProps, }, createSetterContent(setterType, { ...shallowIntl(setterProps), @@ -91,7 +92,7 @@ class SettingFieldView extends Component<{ field: SettingField }> { value, }); field.setValue(value); - } + }, }), extraProps.forceInline ? 'plain' : extraProps.display, ); diff --git a/packages/react-simulator-renderer/src/renderer.less b/packages/react-simulator-renderer/src/renderer.less index e2abf1153..c4f1d352b 100644 --- a/packages/react-simulator-renderer/src/renderer.less +++ b/packages/react-simulator-renderer/src/renderer.less @@ -88,7 +88,7 @@ body.engine-document { .engine-live-editing { cursor: text; outline: none; - box-shadow: 0 0 0px 4px rgba(23, 141, 247, 0.2); + box-shadow: 0 0 0px 2px rgb(102, 188, 92); user-select: text; } diff --git a/packages/types/src/field-config.ts b/packages/types/src/field-config.ts index 7e0ff6b38..8dc176fe8 100644 --- a/packages/types/src/field-config.ts +++ b/packages/types/src/field-config.ts @@ -45,6 +45,13 @@ export interface FieldExtraProps { * compatiable vision display */ display?: 'accordion' | 'inline' | 'block' | 'plain' | 'popup' | 'entry'; + liveTextEditing?: { + selector: string; + // 编辑模式 纯文本|段落编辑|文章编辑(默认纯文本,无跟随工具条) + mode?: 'plaintext' | 'paragraph' | 'article'; + // 从 contentEditable 获取内容并设置到属性 + onSaveContent?: (content: string, prop: any) => any; + } } export interface FieldConfig extends FieldExtraProps { diff --git a/packages/vision-preset/src/bundle/prototype.ts b/packages/vision-preset/src/bundle/prototype.ts index f7b291873..4ea036ffc 100644 --- a/packages/vision-preset/src/bundle/prototype.ts +++ b/packages/vision-preset/src/bundle/prototype.ts @@ -22,7 +22,7 @@ const GlobalPropsConfigure: Array<{ position: string; initials?: InitialItem[]; const Overrides: { [componentName: string]: { initials?: InitialItem[]; - config: any; + override: any; }; } = {}; @@ -44,14 +44,23 @@ function removeGlobalPropsConfigure(name: string) { } } } -function overridePropsConfigure(componentName: string, config: OldPropConfig | OldPropConfig[]) { +function overridePropsConfigure(componentName: string, config: { [name: string]: OldPropConfig } | OldPropConfig[]) { const initials: InitialItem[] = []; const addInitial = (item: InitialItem) => { initials.push(item); }; + let override: any; + if (Array.isArray(config)) { + override = upgradeConfigure(config, addInitial); + } else { + override = {}; + Object.keys(config).forEach(key => { + override[key] = upgradePropConfig(config[key], addInitial); + }); + } Overrides[componentName] = { initials, - config: Array.isArray(config) ? upgradeConfigure(config, addInitial) : upgradePropConfig(config, addInitial), + override, }; } registerMetadataTransducer( @@ -82,18 +91,18 @@ registerMetadataTransducer( } }); - const override = Overrides[componentName]; + const override = Overrides[componentName]?.override; if (override) { - if (Array.isArray(override.config)) { - metadata.configure.combined = override.config; + if (Array.isArray(override)) { + metadata.configure.combined = override; } else { let l = top.length; let item; while (l-- > 0) { item = top[l]; if (item.name in override) { - if (override.config[item.name]) { - top.splice(l, 1, override.config[item.name]); + if (override[item.name]) { + top.splice(l, 1, override[item.name]); } else { top.splice(l, 1); } @@ -102,7 +111,6 @@ registerMetadataTransducer( } } - // TODO FIXME! append override & globalConfigure initials and then unique return metadata; }, 100, @@ -249,7 +257,7 @@ class Prototype { } getRectSelector() { - return this.meta.rectSelector; + return this.meta.rootSelector; } isContainer() { diff --git a/packages/vision-preset/src/bundle/upgrade-metadata.ts b/packages/vision-preset/src/bundle/upgrade-metadata.ts index c734ff68d..cf10a6cd5 100644 --- a/packages/vision-preset/src/bundle/upgrade-metadata.ts +++ b/packages/vision-preset/src/bundle/upgrade-metadata.ts @@ -92,6 +92,7 @@ export interface OldPropConfig { slotTitle?: string; initialChildren?: any; // schema allowTextInput: boolean; + liveTextEditing?: any; } // from vision 5.4 @@ -205,6 +206,7 @@ export function upgradePropConfig(config: OldPropConfig, addInitial: AddIntial) setter, useVariableChange, supportVariable, + liveTextEditing, } = config; const extraProps: any = {}; @@ -451,6 +453,10 @@ export function upgradePropConfig(config: OldPropConfig, addInitial: AddIntial) } newConfig.setter = primarySetter; + if (liveTextEditing) { + extraProps.liveTextEditing = liveTextEditing; + } + return newConfig; } diff --git a/packages/vision-preset/src/editor.ts b/packages/vision-preset/src/editor.ts index 0811665fd..805537782 100644 --- a/packages/vision-preset/src/editor.ts +++ b/packages/vision-preset/src/editor.ts @@ -1,7 +1,7 @@ import { isJSBlock, isJSExpression, isJSSlot } from '@ali/lowcode-types'; import { isPlainObject } from '@ali/lowcode-utils'; import { globalContext, Editor } from '@ali/lowcode-editor-core'; -import { Designer, TransformStage, addBuiltinComponentAction } from '@ali/lowcode-designer'; +import { Designer, LiveEditing, TransformStage, addBuiltinComponentAction } from '@ali/lowcode-designer'; import Outline, { OutlineBackupPane, getTreeMaster } from '@ali/lowcode-plugin-outline-pane'; import { toCss } from '@ali/vu-css-style'; @@ -10,6 +10,7 @@ import { Skeleton, SettingsPrimaryPane } from '@ali/lowcode-editor-skeleton'; import { i18nReducer } from './i18n-reducer'; import { InstanceNodeSelector } from './components'; +import { liveEditingRule } from './vc-live-editing'; export const editor = new Editor(); globalContext.register(editor, Editor); @@ -176,20 +177,7 @@ skeleton.add({ content: OutlineBackupPane, }); -// skeleton.add({ -// name: 'sourceEditor', -// type: 'PanelDock', -// props: { -// align: 'top', -// icon: 'code', -// description: '组件库', -// }, -// panelProps: { -// width: 500 -// // area: 'leftFixedArea' -// }, -// content: SourceEditor, -// }); +LiveEditing.addLiveEditingSpecificRule(liveEditingRule); // 实例节点选择器,线框高亮 addBuiltinComponentAction({ diff --git a/packages/vision-preset/src/vc-live-editing.ts b/packages/vision-preset/src/vc-live-editing.ts new file mode 100644 index 000000000..9f1ec6410 --- /dev/null +++ b/packages/vision-preset/src/vc-live-editing.ts @@ -0,0 +1,72 @@ +import { EditingTarget, Node as DocNode } from '@ali/lowcode-designer'; +import Env from './env'; +import { isJSExpression } from '@ali/lowcode-types'; +const I18nUtil = require('@ali/ve-i18n-util'); + +interface I18nObject { + type?: string; + use?: string; + key?: string; + [lang: string]: string | undefined; +} + +function getI18nText(obj: I18nObject) { + let locale = Env.getLocale(); + if (obj.key) { + return I18nUtil.get(obj.key, locale); + } + if (locale !== 'zh_CN' && locale !== 'zh_TW' && !obj[locale]) { + locale = 'en_US'; + } + return obj[obj.use || locale] || obj.zh_CN; +} + +function getText(node: DocNode, prop: string) { + const p = node.getProp(prop, false); + if (!p || p.isUnset()) { + return null; + } + const v = p.getValue(); + if (v == null) { + return null; + } + if (p.type === 'literal') { + return v; + } + if ((v as any).type === 'i18n') { + return getI18nText(v as any); + } + if (isJSExpression(v)) { + return v.mock; + } +} + +export function liveEditingRule(target: EditingTarget) { + // for vision components specific + const { node, rootElement, event } = target; + + const targetElement = event.target as HTMLElement; + + if (!Array.from(targetElement.childNodes).every(item => item.nodeType === Node.TEXT_NODE)) { + return null; + } + + const innerText = targetElement.innerText; + const propTarget = ['title', 'label', 'text', 'content'].find(prop => { + // TODO: enhance compare text logic + return getText(node, prop) === innerText; + }); + + if (propTarget) { + return { + propElement: targetElement, + propTarget, + }; + } + return null; +} + +// TODO: +export function liveEditingSaveHander() { + +}