outline ok

This commit is contained in:
kangwei 2020-03-26 03:42:35 +08:00
parent 9a2e5e97de
commit 4edf3fce6b
26 changed files with 1193 additions and 672 deletions

View File

@ -20,4 +20,7 @@
width: 3px;
height: auto;
}
&.invalid {
background-color: red;
}
}

View File

@ -2,7 +2,7 @@ import { Component } from 'react';
import { computed, observer } from '../../../../../../globals';
import { SimulatorContext } from '../context';
import { SimulatorHost } from '../host';
import Location, {
import DropLocation, {
Rect,
isLocationChildrenDetail,
LocationChildrenDetail,
@ -23,16 +23,15 @@ interface InsertionData {
/**
* (INode)
*/
function processChildrenDetail(sim: ISimulator, target: NodeParent, detail: LocationChildrenDetail): InsertionData {
function processChildrenDetail(sim: ISimulator, container: NodeParent, detail: LocationChildrenDetail): InsertionData {
let edge = detail.edge || null;
if (edge) {
edge = sim.computeRect(target);
}
if (!edge) {
edge = sim.computeRect(container);
if (!edge) {
return {};
}
}
const ret: any = {
edge,
@ -42,18 +41,33 @@ function processChildrenDetail(sim: ISimulator, target: NodeParent, detail: Loca
if (detail.near) {
const { node, pos, rect, align } = detail.near;
ret.nearRect = rect || sim.computeRect(node);
ret.vertical = align ? align === 'V' : isVertical(ret.nearRect);
if (pos === 'replace') {
// FIXME: ret.nearRect mybe null
ret.coverRect = ret.nearRect;
ret.insertType = 'cover';
} else if (!ret.nearRect || (ret.nearRect.width === 0 && ret.nearRect.height === 0)) {
ret.nearRect = ret.edge;
ret.insertType = 'after';
ret.vertical = isVertical(ret.nearRect);
} else {
ret.insertType = pos;
ret.vertical = align ? align === 'V' : isVertical(ret.nearRect);
}
return ret;
}
// from outline-tree: has index, but no near
// TODO: think of shadowNode & ConditionFlow
const { index } = detail;
let nearNode = target.children.get(index);
if (index == null) {
ret.coverRect = ret.edge;
ret.insertType = 'cover';
return ret;
}
let nearNode = container.children.get(index);
if (!nearNode) {
// index = 0, eg. nochild,
nearNode = target.children.get(index > 0 ? index - 1 : 0);
nearNode = container.children.get(index > 0 ? index - 1 : 0);
if (!nearNode) {
ret.insertType = 'cover';
ret.coverRect = edge;
@ -63,7 +77,14 @@ function processChildrenDetail(sim: ISimulator, target: NodeParent, detail: Loca
}
if (nearNode) {
ret.nearRect = sim.computeRect(nearNode);
if (!ret.nearRect || (ret.nearRect.width === 0 && ret.nearRect.height === 0)) {
ret.nearRect = ret.edge;
ret.insertType = 'after';
}
ret.vertical = isVertical(ret.nearRect);
} else {
ret.insertType = 'cover';
ret.coverRect = edge;
}
return ret;
}
@ -71,7 +92,7 @@ function processChildrenDetail(sim: ISimulator, target: NodeParent, detail: Loca
/**
* detail "坐标"
*/
function processDetail({ target, detail, document }: Location): InsertionData {
function processDetail({ target, detail, document }: DropLocation): InsertionData {
const sim = document.simulator;
if (!sim) {
return {};
@ -115,6 +136,9 @@ export class InsertionView extends Component {
}
let className = 'lc-insertion';
if ((loc.detail as any)?.valid === false) {
className += ' invalid';
}
const style: any = {};
let x: number;
let y: number;

View File

@ -3,7 +3,7 @@ import { ISimulator, Component, NodeInstance } from '../../../designer/simulator
import Viewport from './viewport';
import { createSimulator } from './create-simulator';
import { SimulatorRenderer } from '../renderer/renderer';
import Node, { NodeParent, isNodeParent, isNode, contains } from '../../../designer/document/node/node';
import Node, { NodeParent, isNodeParent, isNode, contains, PositionNO } from '../../../designer/document/node/node';
import DocumentModel from '../../../designer/document/document-model';
import ResourceConsumer from './resource-consumer';
import { AssetLevel, Asset, AssetList, assetBundle, assetItem, AssetType } from '../utils/asset';
@ -536,7 +536,7 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
/**
* DOM simulator
*/
getNodeInstanceFromElement(target: Element | null): NodeInstance | null {
getNodeInstanceFromElement(target: Element | null): NodeInstance<ReactInstance> | null {
if (!target) {
return null;
}
@ -701,31 +701,24 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
locate(e: LocateEvent): any {
this.sensing = true;
this.scroller.scrolling(e);
const dropTarget = this.getDropTarget(e);
if (!dropTarget) {
const dropContainer = this.getDropContainer(e);
if (!dropContainer) {
return null;
}
if (isLocationData(dropTarget)) {
return this.designer.createLocation(dropTarget);
if (isLocationData(dropContainer)) {
return this.designer.createLocation(dropContainer);
}
const target = dropTarget;
const { container, instance: containerInstance } = dropContainer;
// FIXME: e.target is #document, etc., does not has e.targetInstance
const targetInstance = e.targetInstance as ReactInstance;
const parentInstance = this.getClosestNodeInstance(targetInstance, target.id);
const edge = this.computeComponentInstanceRect(
parentInstance?.instance as any,
parentInstance?.node?.componentMeta.rectSelector,
);
const edge = this.computeComponentInstanceRect(containerInstance, container.componentMeta.rectSelector);
if (!edge) {
return null;
}
const children = target.children;
const children = container.children;
const detail: LocationChildrenDetail = {
type: LocationDetailType.Children,
@ -734,8 +727,10 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
};
const locationData = {
target,
target: container,
detail,
source: 'simulator' + this.document.id,
event: e,
};
if (!children || children.size < 1 || !edge) {
@ -755,7 +750,7 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
const instances = this.getComponentInstances(node);
const inst = instances
? instances.length > 1
? instances.find(inst => this.getClosestNodeInstance(inst, target.id)?.instance === targetInstance)
? instances.find(inst => this.getClosestNodeInstance(inst, container.id)?.instance === containerInstance)
: instances[0]
: null;
const rect = inst ? this.computeComponentInstanceRect(inst, node.componentMeta.rectSelector) : null;
@ -830,61 +825,109 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
return this.designer.createLocation(locationData);
}
getDropTarget(e: LocateEvent): NodeParent | LocationData | null {
/**
*
*/
getDropContainer(e: LocateEvent): DropContainer | LocationData | null {
const { target, dragObject } = e;
const isAny = isDragAnyObject(dragObject);
let container: any;
const { modalNode, currentRoot } = this.document;
let container: Node;
let nodeInstance: NodeInstance<ReactInstance> | undefined;
if (target) {
const ref = this.getNodeInstanceFromElement(target);
if (ref?.node) {
e.targetInstance = ref.instance;
e.targetNode = ref.node;
nodeInstance = ref;
container = ref.node;
} else if (isAny) {
return null;
} else {
container = this.document.rootNode;
container = currentRoot;
}
} else if (isAny) {
return null;
} else {
container = this.document.rootNode;
container = currentRoot;
}
if (!isNodeParent(container) && !isRootNode(container)) {
container = container.parent;
if (!isNodeParent(container)) {
container = container.parent || currentRoot;
}
// check container if in modalNode layer, if not, use modalNode
if (modalNode && !modalNode.contains(container)) {
container = modalNode;
}
if (isAny) {
// TODO: use spec container to accept specialData
if (isAny) {
// will return locationData
return null;
}
// get common parent, avoid drop container contains by dragObject
// TODO: renderengine support pointerEvents: none for acceleration
const drillDownExcludes = new Set<Node>();
if (isDragNodeObject(dragObject)) {
const nodes = dragObject.nodes;
let i = nodes.length;
let p: any = container;
while (i-- > 0) {
if (contains(nodes[i], p)) {
p = nodes[i].parent;
}
}
if (p !== container) {
container = p || this.document.rootNode;
drillDownExcludes.add(container);
}
}
const ret: any = {
container,
};
if (nodeInstance) {
if (nodeInstance.node === container) {
ret.instance = nodeInstance.instance;
} else {
ret.instance = this.getClosestNodeInstance(nodeInstance.instance as any, container.id)?.instance;
}
} else {
ret.instance = this.getComponentInstances(container)?.[0];
}
let res: any;
let upward: any;
// TODO: improve AT_CHILD logic, mark has checked
// TODO: complete drill down logic
while (container) {
res = this.acceptNodes(container, e);
if (ret.container !== container) {
ret.container = container;
ret.instance = this.getClosestNodeInstance(ret.instance, container.id)?.instance;
}
res = this.handleAccept(ret, e);
if (isLocationData(res)) {
return res;
}
if (res === true) {
return container;
return ret;
}
if (!res) {
drillDownExcludes.add(container);
if (upward) {
container = upward;
upward = null;
} else {
} else if (container.parent) {
container = container.parent;
} else {
return null;
}
} else if (isNode(res)) {
/* else if (res === AT_CHILD) {
/* else if (res === DRILL_DOWN) {
if (!upward) {
upward = container.parent;
}
container = this.getNearByContainer(container, e);
container = this.getNearByContainer(container, drillExcludes, e);
if (!container) {
container = upward;
upward = null;
@ -897,38 +940,71 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
return null;
}
acceptNodes(container: NodeParent, e: LocateEvent) {
isAcceptable(container: NodeParent): boolean {
return false;
/*
const meta = container.componentMeta;
const instance: any = this.document.getView(container);
if (instance && '$accept' in instance) {
return true;
}
return meta.acceptable;
*/
}
/**
*
*/
handleAccept({ container, instance }: DropContainer, e: LocateEvent) {
const { dragObject } = e;
if (isRootNode(container)) {
return this.checkDropTarget(container, dragObject as any);
return this.document.checkDropTarget(container, dragObject as any);
}
const config = container.componentMeta;
const meta = container.componentMeta;
if (!config.isContainer) {
// FIXME: get containerInstance for accept logic use
const acceptable: boolean = this.isAcceptable(container);
if (!meta.isContainer && !acceptable) {
return false;
}
// check is contains, get common parent
if (isDragNodeObject(dragObject)) {
const nodes = dragObject.nodes;
let i = nodes.length;
let p: any = container;
while (i-- > 0) {
if (contains(nodes[i], p)) {
p = nodes[i].parent;
}
}
if (p !== container) {
return p || this.document.rootNode;
}
}
return this.checkNesting(container, dragObject as any);
}
// first use accept
if (acceptable) {
/*
getNearByContainer(container: NodeParent, e: LocateEvent) {
const view: any = this.document.getView(container);
if (view && '$accept' in view) {
if (view.$accept === false) {
return false;
}
if (view.$accept === AT_CHILD || view.$accept === '@CHILD') {
return AT_CHILD;
}
if (typeof view.$accept === 'function') {
const ret = view.$accept(container, e);
if (ret || ret === false) {
return ret;
}
}
}
if (proto.acceptable) {
const ret = proto.accept(container, e);
if (ret || ret === false) {
return ret;
}
}
*/
}
// check nesting
return this.document.checkNesting(container, dragObject as any);
}
/**
*
*/
getNearByContainer(container: NodeParent, e: LocateEvent) {
/*
const children = container.children;
if (!children || children.length < 1) {
return null;
@ -963,43 +1039,7 @@ export class SimulatorHost implements ISimulator<SimulatorProps> {
}
return nearBy;
}
*/
checkNesting(dropTarget: NodeParent, dragObject: DragNodeObject | DragNodeDataObject): boolean {
let items: Array<Node | NodeSchema>;
if (isDragNodeDataObject(dragObject)) {
items = Array.isArray(dragObject.data) ? dragObject.data : [dragObject.data];
} else {
items = dragObject.nodes;
}
return items.every(item => this.checkNestingDown(dropTarget, item));
}
checkDropTarget(dropTarget: NodeParent, dragObject: DragNodeObject | DragNodeDataObject): boolean {
let items: Array<Node | NodeSchema>;
if (isDragNodeDataObject(dragObject)) {
items = Array.isArray(dragObject.data) ? dragObject.data : [dragObject.data];
} else {
items = dragObject.nodes;
}
return items.every(item => this.checkNestingUp(dropTarget, item));
}
checkNestingUp(parent: NodeParent, target: NodeSchema | Node): boolean {
if (isNode(target) || isNodeSchema(target)) {
const config = isNode(target) ? target.componentMeta : this.document.getComponentMeta(target.componentName);
if (config) {
return config.checkNestingUp(target, parent);
}
}
return true;
}
checkNestingDown(parent: NodeParent, target: NodeSchema | Node): boolean {
const config = parent.componentMeta;
return config.checkNestingDown(parent, target) && this.checkNestingUp(parent, target);
}
// #endregion
}
@ -1065,3 +1105,8 @@ function getMatched(elements: Array<Element | Text>, selector: string): Element
}
return firstQueried;
}
export interface DropContainer {
container: NodeParent;
instance: ReactInstance;
}

View File

@ -4,7 +4,7 @@ import Project from './project';
import Dragon, { isDragNodeObject, isDragNodeDataObject, LocateEvent, DragObject } from './helper/dragon';
import ActiveTracker from './helper/active-tracker';
import Hovering from './helper/hovering';
import Location, { LocationData, isLocationChildrenDetail } from './helper/location';
import DropLocation, { LocationData, isLocationChildrenDetail } from './helper/location';
import DocumentModel from './document/document-model';
import Node, { insertChildren } from './document/node/node';
import { isRootNode } from './document/node/root-node';
@ -30,7 +30,7 @@ export interface DesignerProps {
onMount?: (designer: Designer) => void;
onDragstart?: (e: LocateEvent) => void;
onDrag?: (e: LocateEvent) => void;
onDragend?: (e: { dragObject: DragObject; copy: boolean }, loc?: Location) => void;
onDragend?: (e: { dragObject: DragObject; copy: boolean }, loc?: DropLocation) => void;
[key: string]: any;
}
@ -158,12 +158,12 @@ export default class Designer {
this.props?.eventPipe?.emit(`designer.${event}`, ...args);
}
private _dropLocation?: Location;
private _dropLocation?: DropLocation;
/**
* dragon
*/
createLocation(locationData: LocationData): Location {
const loc = new Location(locationData);
createLocation(locationData: LocationData): DropLocation {
const loc = new DropLocation(locationData);
if (this._dropLocation && this._dropLocation.document !== loc.document) {
this._dropLocation.document.internalSetDropLocation(null);
}
@ -290,7 +290,7 @@ export default class Designer {
return this.project.schema;
}
set schema(schema: ProjectSchema) {
setSchema(schema: ProjectSchema) {
// todo:
}

View File

@ -1,9 +1,9 @@
import Project from '../project';
import Node, { isNodeParent, insertChildren, insertChild, NodeParent } from './node/node';
import Node, { isNodeParent, insertChildren, insertChild, NodeParent, isNode } from './node/node';
import { Selection } from './selection';
import RootNode from './node/root-node';
import { ISimulator } from '../simulator';
import Location from '../helper/location';
import DropLocation from '../helper/location';
import { ComponentMeta } from '../component-meta';
import History from '../helper/history';
import Prop from './node/props/prop';
@ -16,7 +16,9 @@ import {
computed,
obx,
autorun,
isNodeSchema,
} from '../../../../globals';
import { isDragNodeDataObject, DragNodeObject, DragNodeDataObject } from '../helper/dragon';
export default class DocumentModel {
/**
@ -56,6 +58,15 @@ export default class DocumentModel {
this.rootNode.getExtraProp('fileName', true)?.setValue(fileName);
}
private _modalNode?: NodeParent;
get modalNode() {
return this._modalNode;
}
get currentRoot() {
return this.modalNode || this.rootNode;
}
constructor(readonly project: Project, schema: RootSchema) {
autorun(() => {
this.nodes.forEach(item => {
@ -201,11 +212,11 @@ export default class DocumentModel {
node.remove();
}
@obx.ref private _dropLocation: Location | null = null;
@obx.ref private _dropLocation: DropLocation | null = null;
/**
*
*/
internalSetDropLocation(loc: Location | null) {
internalSetDropLocation(loc: DropLocation | null) {
this._dropLocation = loc;
}
@ -378,6 +389,48 @@ export default class DocumentModel {
remove() {
// todo:
}
checkNesting(dropTarget: NodeParent, dragObject: DragNodeObject | DragNodeDataObject): boolean {
let items: Array<Node | NodeSchema>;
if (isDragNodeDataObject(dragObject)) {
items = Array.isArray(dragObject.data) ? dragObject.data : [dragObject.data];
} else {
items = dragObject.nodes;
}
return items.every(item => this.checkNestingDown(dropTarget, item));
}
checkDropTarget(dropTarget: NodeParent, dragObject: DragNodeObject | DragNodeDataObject): boolean {
let items: Array<Node | NodeSchema>;
if (isDragNodeDataObject(dragObject)) {
items = Array.isArray(dragObject.data) ? dragObject.data : [dragObject.data];
} else {
items = dragObject.nodes;
}
return items.every(item => this.checkNestingUp(dropTarget, item));
}
/**
* parentWhitelist
*/
checkNestingUp(parent: NodeParent, obj: NodeSchema | Node): boolean {
if (isNode(obj) || isNodeSchema(obj)) {
const config = isNode(obj) ? obj.componentMeta : this.getComponentMeta(obj.componentName);
if (config) {
return config.checkNestingUp(obj, parent);
}
}
return true;
}
/**
* childWhitelist
*/
checkNestingDown(parent: NodeParent, obj: NodeSchema | Node): boolean {
const config = parent.componentMeta;
return config.checkNestingDown(parent, obj) && this.checkNestingUp(parent, obj);
}
}
export function isDocumentModel(obj: any): obj is DocumentModel {

View File

@ -3,9 +3,11 @@ import { uniqueId } from '../../../../../utils/unique-id';
import Node from './node';
import { intl } from '../../../locale';
// modals assoc x-hide value, initial: check is Modal, yes will put it in modals, cross levels
// if-else-if assoc conditionGroup value, should be the same level, and siblings, need renderEngine support
export default class ExclusiveGroup {
readonly isExclusiveGroup = true;
readonly id = uniqueId('cond-grp');
readonly id = uniqueId('exclusive');
@obx.val readonly children: Node[] = [];
@obx private visibleIndex = 0;

View File

@ -39,11 +39,11 @@ export default class NodeChildren {
} else {
node = this.owner.document.createNode(item);
}
node.internalSetParent(this.owner);
children[i] = node;
}
this.children = children;
this.interalInitParent();
}
/**

View File

@ -28,12 +28,13 @@ import ExclusiveGroup, { isExclusiveGroup } from './exclusive-group';
* loop
* loopArgs
* condition
* ------- future support -----
* conditionGroup
* title
* ignored
* locked
* hidden
* ------- addition support -----
* conditionGroup use for condition, for exclusive
* title display on outline
* ignored ignore this node will not publish to render, but will store
* locked can not select/hover/ item on canvas but can control on outline
* hidden not visible on canvas
* slotArgs like loopArgs, for slot node
*/
export default class Node {
/**
@ -49,11 +50,12 @@ export default class Node {
/**
*
* :
* * #text
* * #expression
* * Page
* * Block/Fragment
* * Block
* * Component /
* * Fragment props
* * Leaf | props
* * Slot props children slotArgs
*/
readonly componentName: string;
/**
@ -240,6 +242,8 @@ export default class Node {
if (!isExclusiveGroup(grp)) {
if (this.prevSibling?.conditionGroup?.name === grp) {
grp = this.prevSibling.conditionGroup;
} else if (this.nextSibling?.conditionGroup?.name === grp) {
grp = this.nextSibling.conditionGroup;
} else {
grp = new ExclusiveGroup(grp);
}

View File

@ -1,5 +1,5 @@
import { EventEmitter } from 'events';
import Location from './location';
import DropLocation from './location';
import DocumentModel from '../document/document-model';
import { ISimulator, isSimulator, ComponentInstance } from '../simulator';
import Node from '../document/node/node';
@ -47,9 +47,6 @@ export interface LocateEvent {
* canvasX,canvasY,
*/
fixed?: true;
targetNode?: Node;
targetInstance?: ComponentInstance;
}
/**
@ -67,7 +64,7 @@ export interface ISensor {
/**
*
*/
locate(e: LocateEvent): Location | undefined;
locate(e: LocateEvent): DropLocation | undefined | null;
/**
*
*/
@ -204,10 +201,10 @@ export default class Dragon {
}
private emitter = new EventEmitter();
private emptyImage: HTMLImageElement = new Image();
// private emptyImage: HTMLImageElement = new Image();
constructor(readonly designer: Designer) {
this.emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
// this.emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
}
from(shell: Element, boost: (e: MouseEvent) => DragObject | null) {
@ -280,6 +277,7 @@ export default class Dragon {
let lastArrive: any;
const drag = (e: MouseEvent | DragEvent) => {
// FIXME: donot setcopy when: newbie & no location
checkcopy(e);
if (isInvalidPoint(e, lastArrive)) return;
@ -433,6 +431,7 @@ export default class Dragon {
const chooseSensor = (e: LocateEvent) => {
let sensor = e.sensor && e.sensor.isEnter(e) ? e.sensor : sensors.find(s => s.sensorAvailable && s.isEnter(e));
if (!sensor) {
// TODO: enter some area like componentspanel cancel
if (lastSensor) {
sensor = lastSensor;
} else if (e.sensor) {
@ -539,13 +538,6 @@ export default class Dragon {
});
}
/**
*
*/
private isCopyState(): boolean {
return cursor.isCopy();
}
/**
*
*/

View File

@ -1,9 +1,12 @@
import ComponentNode, { NodeParent } from '../document/node/node';
import DocumentModel from '../document/document-model';
import { LocateEvent } from './dragon';
export interface LocationData {
target: NodeParent; // shadowNode | ConditionFlow | ElementNode | RootNode
detail: LocationDetail;
source: string;
event: LocateEvent;
}
export enum LocationDetailType {
@ -13,14 +16,19 @@ export enum LocationDetailType {
export interface LocationChildrenDetail {
type: LocationDetailType.Children;
index: number;
index?: number | null;
/**
*
*/
valid?: boolean;
edge?: DOMRect;
near?: {
node: ComponentNode;
pos: 'before' | 'after';
pos: 'before' | 'after' | 'replace';
rect?: Rect;
align?: 'V' | 'H';
};
focus?: { type: 'slots' } | { type: 'node'; node: NodeParent };
}
export interface LocationPropDetail {
@ -118,15 +126,28 @@ export function getWindow(elem: Element | Document): Window {
return (isDocument(elem) ? elem : elem.ownerDocument!).defaultView!;
}
export default class Location {
export default class DropLocation {
readonly target: NodeParent;
readonly detail: LocationDetail;
readonly event: LocateEvent;
readonly source: string;
get document(): DocumentModel {
return this.target.document;
}
constructor({ target, detail }: LocationData) {
constructor({ target, detail, source, event }: LocationData) {
this.target = target;
this.detail = detail;
this.source = source;
this.event = event;
}
clone(event: LocateEvent): DropLocation {
return new DropLocation({
target: this.target,
detail: this.detail,
source: this.source,
event,
});
}
}

View File

@ -43,8 +43,8 @@ const SCROLL_ACCURCY = 30;
export interface IScrollable {
scrollTarget?: ScrollTarget | Element;
bounds: DOMRect;
scale: number;
bounds?: DOMRect | null;
scale?: number;
}
export default class Scroller {
@ -62,8 +62,7 @@ export default class Scroller {
return target;
}
constructor(private scrollable: IScrollable) {
}
constructor(private scrollable: IScrollable) {}
scrollTo(options: { left?: number; top?: number }) {
this.cancel();
@ -119,9 +118,9 @@ export default class Scroller {
scrolling(point: { globalX: number; globalY: number }) {
this.cancel();
const { bounds, scale } = this.scrollable;
const { bounds, scale = 1 } = this.scrollable;
const scrollTarget = this.scrollTarget;
if (!scrollTarget) {
if (!scrollTarget || !bounds) {
return;
}

View File

@ -165,7 +165,7 @@ export default {
name: 'children',
propType: 'node'
}
]
],
},
'Button.Group': {
componentName: 'Button.Group',

View File

@ -1,2 +1,3 @@
export * from './create-icon';
export * from './is-react';
export * from './unique-id';

View File

@ -0,0 +1,4 @@
let guid = Date.now();
export function uniqueId(prefix = '') {
return `${prefix}${(guid++).toString(36).toLowerCase()}`;
}

View File

@ -1,37 +1,52 @@
import { NodeParent } from '../../../designer/src/designer/document/node/node';
import DropLocation, { isLocationChildrenDetail } from '../../../designer/src/designer/helper/location';
import { LocateEvent } from '../../../designer/src/designer/helper/dragon';
/**
*
*/
export default class DwellTimer {
private timer: number | undefined;
private previous: any;
private previous?: NodeParent;
private event?: LocateEvent
constructor(readonly timeout: number = 400) {}
constructor(private decide: (node: NodeParent, event: LocateEvent) => void, private timeout: number = 800) {}
/**
* ID
* ()
*
*/
start(id: any, fn: () => void) {
if (this.previous !== id) {
this.end();
this.previous = id;
focus(node: NodeParent, event: LocateEvent) {
this.event = event;
if (this.previous === node) {
return;
}
this.reset();
this.previous = node;
const x = Date.now();
console.info('set', x);
this.timer = setTimeout(() => {
fn();
this.end();
}, this.timeout) as number;
console.info('done', x, Date.now() - x);
this.previous && this.decide(this.previous, this.event!);
this.reset();
}, this.timeout) as any;
}
tryFocus(loc?: DropLocation | null) {
if (!loc || !isLocationChildrenDetail(loc.detail)) {
this.reset();
return;
}
if (loc.detail.focus?.type === 'node') {
this.focus(loc.detail.focus.node, loc.event);
} else {
this.reset();
}
}
end() {
const timer = this.timer;
if (timer) {
clearTimeout(timer);
reset() {
console.info('reset');
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
if (this.previous) {
this.previous = undefined;
}
}
}

View File

@ -0,0 +1,53 @@
import DropLocation, { isLocationChildrenDetail } from '../../../designer/src/designer/helper/location';
import { NodeParent } from '../../../designer/src/designer/document/node/node';
const IndentSensitive = 15;
export class IndentTrack {
private indentStart: number | null = null;
reset() {
this.indentStart = null;
}
getIndentParent(lastLoc: DropLocation, loc: DropLocation): [NodeParent, number] | null {
if (
lastLoc.target !== loc.target ||
!isLocationChildrenDetail(lastLoc.detail) ||
!isLocationChildrenDetail(loc.detail) ||
lastLoc.source !== loc.source ||
lastLoc.detail.index !== loc.detail.index ||
loc.detail.index == null
) {
this.indentStart = null;
return null;
}
if (this.indentStart == null) {
this.indentStart = lastLoc.event.globalX;
}
const delta = loc.event.globalX - this.indentStart;
const indent = Math.floor(Math.abs(delta) / IndentSensitive);
if (indent < 1) {
return null;
}
this.indentStart = loc.event.globalX;
const direction = delta < 0 ? 'left' : 'right';
let parent = loc.target;
const index = loc.detail.index;
if (direction === 'left') {
if (!parent.parent || parent.isSlotRoot || index < parent.children.size) {
return null;
}
return [parent.parent, parent.index + 1];
} else {
if (index === 0) {
return null;
}
parent = parent.children.get(index - 1) as any;
if (parent && parent.isContainer()) {
return [parent, parent.children.size];
}
}
return null;
}
}

View File

@ -1,111 +0,0 @@
/**
* X
*/
import { INode, INodeParent, isRootNode } from '../../../../document/node';
import Location, {
isLocationChildrenDetail,
LocationChildrenDetail,
LocationData,
LocationDetailType,
} from '../../../../document/location';
import { LocateEvent } from '../../../../globals';
import { isContainer } from './is-container';
export default class XAxisTracker {
private location!: Location;
private start: number = 0;
/**
* @param unit
*/
constructor(readonly unit = 15) {}
track(loc: Location, e: LocateEvent): LocationData | null {
this.location = loc;
if (this.start === 0) {
this.start = e.globalX;
}
const parent = this.locate(e);
if (!parent) {
return null;
}
return {
target: parent as INodeParent,
detail: {
type: LocationDetailType.Children,
index: parent.children.length,
},
};
}
/**
*
*/
locate(e: LocateEvent): INode | null {
if (!isLocationChildrenDetail(this.location.detail)) {
return null;
}
const delta = e.globalX - this.start;
let direction = null;
if (delta < 0) {
direction = 'left';
} else {
direction = 'right';
}
const n = Math.floor(Math.abs(delta) / this.unit);
// console.log('x', e.globalX, 'y', e.globalY, 'delta', delta, 'n', n, 'start', this.start);
if (n < 1) {
return null;
}
// 一旦移动一个单位,就将"原点"清零
this.reset();
const node = this.location.target;
const index = (this.location.detail as LocationChildrenDetail).index;
let parent = null;
if (direction === 'left') {
// 如果光标是往左运动
// 该节点如果不是最后一个节点,那么就没有继续查找下去的必要
// console.log('>>> [left]', index, node.children.length, node);
if (isRootNode(node)) {
return null;
}
// index 为 0 表示第一个位置
// 第一个位置或者不是最后以为位置,都不需要处理
if (index < node.children.length - 1) {
return null;
}
parent = node.parent as INode;
} else {
// 插入线一般是在元素下面,所以这边需要多减去 1即 -2
if (index === 0) {
return null;
}
const i2 = Math.max(index - 1, 0);
parent = node.children[i2];
// console.log('>>> [right]', index, i2, parent, node.id);
}
// parent 节点判断
if (!parent || !isContainer(parent)) {
return null;
}
return parent;
}
reset() {
this.start = 0;
}
}

View File

@ -1,25 +1,63 @@
import { computed, obx } from '../../globals';
import { computed, obx, uniqueId } from '../../globals';
import Designer from '../../designer/src/designer/designer';
import { ISensor, LocateEvent } from '../../designer/src/designer/helper/dragon';
import {
ISensor,
LocateEvent,
isDragNodeObject,
isDragAnyObject,
DragObject,
} from '../../designer/src/designer/helper/dragon';
import Scroller, { ScrollTarget, IScrollable } from '../../designer/src/designer/helper/scroller';
import { Tree } from './tree';
import Location from '../../designer/src/designer/helper/location';
import DropLocation, {
isLocationChildrenDetail,
LocationChildrenDetail,
LocationDetailType,
} from '../../designer/src/designer/helper/location';
import TreeNode from './tree-node';
import Node, { NodeParent, contains } from '../../designer/src/designer/document/node/node';
import { IndentTrack } from './helper/indent-track';
import DwellTimer from './helper/dwell-timer';
export interface IScrollBoard {
scrollToNode(treeNode: TreeNode, detail?: any): void;
}
class TreeMaster {
constructor(readonly designer: Designer) {
designer.dragon.onDragstart((e) => {
designer.dragon.onDragstart(e => {
const tree = this.currentTree;
if (tree) {
tree.document.selection.getTopNodes().forEach(node => {
tree.getTreeNode(node).setExpanded(false);
});
};
});
designer.activeTracker.onChange((target) => {
const tree = this.currentTree;
if (tree && target.node.document === tree.document) {
tree.getTreeNode(target.node).expandParents();
}
});
designer.activeTracker.onChange(({ node, detail }) => {
const tree = this.currentTree;
if (!tree || node.document !== tree.document) {
return;
}
const treeNode = tree.getTreeNode(node);
if (detail && isLocationChildrenDetail(detail)) {
treeNode.expand(true);
} else {
treeNode.expandParents();
}
this.boards.forEach(board => {
board.scrollToNode(treeNode, detail);
});
});
}
private boards = new Set<IScrollBoard>();
addBoard(board: IScrollBoard) {
this.boards.add(board);
}
removeBoard(board: IScrollBoard) {
this.boards.delete(board);
}
private treeMap = new Map<string, Tree>();
@ -49,12 +87,13 @@ function getTreeMaster(designer: Designer): TreeMaster {
return master;
}
export class OutlineMain implements ISensor {
export class OutlineMain implements ISensor, IScrollBoard, IScrollable {
private _designer?: Designer;
@obx.ref private _master?: TreeMaster;
get master() {
return this._master;
}
readonly id = uniqueId('tree');
constructor(readonly editor: any) {
if (editor.designer) {
@ -66,30 +105,467 @@ export class OutlineMain implements ISensor {
}
}
/**
* @see ISensor
*/
fixEvent(e: LocateEvent): LocateEvent {
throw new Error("Method not implemented.");
if (e.fixed) {
return e;
}
locate(e: LocateEvent): Location | undefined {
throw new Error("Method not implemented.");
const notMyEvent = e.originalEvent.view?.document !== document;
if (!e.target || notMyEvent) {
e.target = document.elementFromPoint(e.canvasX!, e.canvasY!);
}
// documentModel : 目标文档
e.documentModel = this._designer?.currentDocument;
// 事件已订正
e.fixed = true;
return e;
}
private indentTrack = new IndentTrack();
private dwell = new DwellTimer((target, event) => {
const document = target.document;
const designer = document.designer;
let index: any;
let focus: any;
let valid = true;
if (target.isSlotContainer()) {
index = null;
focus = { type: 'slots' };
} else {
index = 0;
valid = document.checkNesting(target, event.dragObject as any);
}
designer.createLocation({
target,
source: this.id,
event,
detail: {
type: LocationDetailType.Children,
index,
focus,
valid,
},
});
});
/**
* @see ISensor
*/
locate(e: LocateEvent): DropLocation | undefined | null {
this.sensing = true;
this.scroller?.scrolling(e);
const tree = this._master?.currentTree;
if (!tree || !this._shell) {
return null;
}
const document = tree.document;
const designer = document.designer;
const { globalY, dragObject } = e;
const pos = getPosFromEvent(e, this._shell);
const irect = this.getInsertionRect();
const originLoc = document.dropLocation;
if (originLoc && ((pos && pos === 'unchanged') || (irect && globalY >= irect.top && globalY <= irect.bottom))) {
const loc = originLoc.clone(e);
const indented = this.indentTrack.getIndentParent(originLoc, loc);
if (indented) {
const [parent, index] = indented;
if (checkRecursion(parent, dragObject)) {
if (tree.getTreeNode(parent).expanded) {
this.dwell.reset();
return designer.createLocation({
target: parent,
source: this.id,
event: e,
detail: {
type: LocationDetailType.Children,
index,
valid: document.checkNesting(parent, e.dragObject as any),
},
});
}
(originLoc.detail as LocationChildrenDetail).focus = {
type: 'node',
node: parent,
};
// focus try expand go on
this.dwell.focus(parent, e);
} else {
this.dwell.reset();
}
} else {
// FIXME: recreate new location
if ((originLoc.detail as LocationChildrenDetail).near) {
(originLoc.detail as LocationChildrenDetail).near = undefined;
this.dwell.reset();
}
}
return;
}
this.indentTrack.reset();
if (pos && pos !== 'unchanged') {
let treeNode = tree.getTreeNodeById(pos.nodeId);
if (treeNode) {
let focusSlots = pos.focusSlots;
let { node } = treeNode;
if (isDragNodeObject(dragObject)) {
const nodes = dragObject.nodes;
let i = nodes.length;
let p: any = node;
while (i-- > 0) {
if (contains(nodes[i], p)) {
p = nodes[i].parent;
}
}
if (p !== node) {
node = p || document.rootNode;
treeNode = tree.getTreeNode(node);
focusSlots = false;
}
}
if (focusSlots) {
this.dwell.reset();
return designer.createLocation({
target: node as NodeParent,
source: this.id,
event: e,
detail: {
type: LocationDetailType.Children,
index: null,
valid: false,
focus: { type: 'slots' },
},
});
}
if (!treeNode.isRoot()) {
const loc = this.getNear(treeNode, e);
this.dwell.tryFocus(loc);
return loc;
}
}
}
const loc = this.drillLocate(tree.root, e);
this.dwell.tryFocus(loc);
return loc;
}
private getNear(treeNode: TreeNode, e: LocateEvent, index?: number, rect?: DOMRect) {
const document = treeNode.tree.document;
const designer = document.designer;
const { globalY, dragObject } = e;
// TODO: check dragObject is anyData
const { node, expanded } = treeNode;
if (!rect) {
rect = this.getTreeNodeRect(treeNode);
if (!rect) {
return null;
}
}
if (index == null) {
index = node.index;
}
if (node.isSlotRoot) {
// 是个插槽根节点
if (!treeNode.isContainer() && !treeNode.isSlotContainer()) {
return designer.createLocation({
target: node.parent!,
source: this.id,
event: e,
detail: {
type: LocationDetailType.Children,
index: null,
near: { node, pos: 'replace' },
valid: true, // TODO: future validation the slot limit
},
});
}
const loc1 = this.drillLocate(treeNode, e);
if (loc1) {
return loc1;
}
return designer.createLocation({
target: node.parent!,
source: this.id,
event: e,
detail: {
type: LocationDetailType.Children,
index: null,
valid: false,
focus: { type: 'slots' },
},
});
}
let focusNode: Node | undefined;
// focus
if (!expanded && (treeNode.isContainer() || treeNode.isSlotContainer())) {
focusNode = node;
}
// before
const titleRect = this.getTreeTitleRect(treeNode) || rect;
if (globalY < titleRect.top + titleRect.height / 2) {
return designer.createLocation({
target: node.parent!,
source: this.id,
event: e,
detail: {
type: LocationDetailType.Children,
index,
valid: document.checkNesting(node.parent!, dragObject as any),
near: { node, pos: 'before' },
focus: checkRecursion(focusNode, dragObject) ? { type: 'node', node: focusNode } : undefined,
},
});
}
if (globalY > titleRect.bottom) {
focusNode = undefined;
}
if (expanded) {
// drill
const loc = this.drillLocate(treeNode, e);
if (loc) {
return loc;
}
}
// after
return designer.createLocation({
target: node.parent!,
source: this.id,
event: e,
detail: {
type: LocationDetailType.Children,
index: index + 1,
valid: document.checkNesting(node.parent!, dragObject as any),
near: { node, pos: 'after' },
focus: checkRecursion(focusNode, dragObject) ? { type: 'node', node: focusNode } : undefined,
},
});
}
private drillLocate(treeNode: TreeNode, e: LocateEvent): DropLocation | null {
const document = treeNode.tree.document;
const designer = document.designer;
const { dragObject, globalY } = e;
if (!checkRecursion(treeNode.node, dragObject)) {
return null;
}
if (isDragAnyObject(dragObject)) {
// TODO: future
return null;
}
const container = treeNode.node as NodeParent;
const detail: LocationChildrenDetail = {
type: LocationDetailType.Children,
};
const locationData: any = {
target: container,
detail,
source: this.id,
event: e,
};
const isSlotContainer = treeNode.isSlotContainer();
const isContainer = treeNode.isContainer();
if (container.isSlotRoot && !treeNode.expanded) {
// 未展开,直接定位到内部第一个节点
if (isSlotContainer) {
detail.index = null;
detail.focus = { type: 'slots' };
detail.valid = false;
} else {
detail.index = 0;
detail.valid = document.checkNesting(container, dragObject);
}
}
let items: TreeNode[] | null = null;
let slotsRect: DOMRect | undefined;
let focusSlots: boolean = false;
// isSlotContainer
if (isSlotContainer) {
slotsRect = this.getTreeSlotsRect(treeNode);
if (slotsRect) {
if (globalY <= slotsRect.bottom) {
focusSlots = true;
items = treeNode.slots;
} else if (!isContainer) {
// 不在 slots 范围,又不是 container 的情况,高亮 slots 区
detail.index = null;
detail.focus = { type: 'slots' };
detail.valid = false;
return designer.createLocation(locationData);
}
}
}
if (!items && isContainer) {
items = treeNode.children;
}
if (!items) {
return null;
}
const l = items.length;
let index = 0;
let before = l < 1;
let current: TreeNode | undefined;
let currentIndex = index;
for (; index < l; index++) {
current = items[index];
currentIndex = index;
const rect = this.getTreeNodeRect(current);
if (!rect) {
continue;
}
// rect
if (globalY < rect.top) {
before = true;
break;
}
if (globalY > rect.bottom) {
continue;
}
const loc = this.getNear(current, e, index, rect);
if (loc) {
return loc;
}
}
if (focusSlots) {
detail.focus = { type: 'slots' };
detail.valid = false;
detail.index = null;
} else {
if (current) {
detail.index = before ? currentIndex : currentIndex + 1;
detail.near = { node: current.node, pos: before ? 'before' : 'after' };
} else {
detail.index = l;
}
detail.valid = document.checkNesting(container, dragObject);
}
return designer.createLocation(locationData);
}
/**
* @see ISensor
*/
isEnter(e: LocateEvent): boolean {
throw new Error("Method not implemented.");
if (!this._shell) {
return false;
}
const rect = this._shell.getBoundingClientRect();
return e.globalY >= rect.top && e.globalY <= rect.bottom && e.globalX >= rect.left && e.globalX <= rect.right;
}
deactiveSensor(): void {
throw new Error("Method not implemented.");
private tryScrollAgain: number | null = null;
/**
* @see IScrollBoard
*/
scrollToNode(treeNode: TreeNode, detail?: any, tryTimes: number = 0) {
this.tryScrollAgain = null;
if (this.sensing || !this.bounds || !this.scroller || !this.scrollTarget) {
// is a active sensor
return;
}
const opt: any = {};
let scroll = false;
let rect: ClientRect | undefined;
if (detail && isLocationChildrenDetail(detail)) {
rect = this.getInsertionRect();
} else {
rect = this.getTreeNodeRect(treeNode);
}
if (!rect) {
if (!this.tryScrollAgain && tryTimes < 3) {
this.tryScrollAgain = requestAnimationFrame(() => this.scrollToNode(treeNode, detail, tryTimes + 1));
}
return;
}
const scrollTarget = this.scrollTarget;
const st = scrollTarget.top;
const scrollHeight = scrollTarget.scrollHeight;
const { height, top, bottom } = this.bounds;
if (rect.top < top || rect.bottom > bottom) {
opt.top = Math.min(rect.top + rect.height / 2 + st - top - height / 2, scrollHeight - height);
scroll = true;
}
if (scroll) {
this.scroller.scrollTo(opt);
}
}
private sensing = false;
/**
* @see ISensor
*/
deactiveSensor() {
this.sensing = false;
this.scroller?.cancel();
this.dwell.reset();
this.indentTrack.reset();
}
/**
* @see IScrollable
*/
get bounds(): DOMRect | null {
if (!this._shell) {
return null;
}
return this._shell.getBoundingClientRect();
}
private _scrollTarget?: ScrollTarget;
/**
* @see IScrollable
*/
get scrollTarget() {
return this._scrollTarget;
}
private scroller?: Scroller;
private setupDesigner(designer: Designer) {
this._designer = designer;
this._master = getTreeMaster(designer);
// designer.dragon.addSensor(this);
this._master.addBoard(this);
designer.dragon.addSensor(this);
this.scroller = designer.createScroller(this);
}
purge() {
this._designer?.dragon.removeSensor(this);
this._master?.removeBoard(this);
// todo purge treeMaster if needed
}
@ -108,9 +584,80 @@ export class OutlineMain implements ISensor {
}
this._shell = shell;
if (shell) {
// this._sensorAvailable = true;
this._scrollTarget = new ScrollTarget(shell);
this._sensorAvailable = true;
} else {
this._scrollTarget = undefined;
this._sensorAvailable = false;
}
}
private getInsertionRect(): DOMRect | undefined {
if (!this._shell) {
return undefined;
}
return this._shell.querySelector('.insertion')?.getBoundingClientRect();
}
private getTreeNodeRect(treeNode: TreeNode): DOMRect | undefined {
if (!this._shell) {
return undefined;
}
return this._shell.querySelector(`.tree-node[data-id="${treeNode.id}"]`)?.getBoundingClientRect();
}
private getTreeTitleRect(treeNode: TreeNode): DOMRect | undefined {
if (!this._shell) {
return undefined;
}
return this._shell.querySelector(`.tree-node-title[data-id="${treeNode.id}"]`)?.getBoundingClientRect();
}
private getTreeSlotsRect(treeNode: TreeNode): DOMRect | undefined {
if (!this._shell) {
return undefined;
}
return this._shell.querySelector(`.tree-node-slots[data-id="${treeNode.id}"]`)?.getBoundingClientRect();
}
}
function checkRecursion(parent: Node | undefined | null, dragObject: DragObject): parent is NodeParent {
if (!parent) {
return false;
}
if (isDragNodeObject(dragObject)) {
const nodes = dragObject.nodes;
if (nodes.some(node => node.contains(parent))) {
return false;
}
}
return true;
}
function getPosFromEvent(
{ target }: LocateEvent,
stop: Element,
):
| null
| 'unchanged'
| {
nodeId: string;
focusSlots: boolean;
} {
if (!target || !stop.contains(target)) {
return null;
}
if (target.matches('.insertion')) {
return 'unchanged';
}
target = target.closest('[data-id]');
if (!target || !stop.contains(target)) {
return null;
}
const nodeId = (target as HTMLDivElement).dataset.id!;
return {
focusSlots: target.matches('.tree-node-slots'),
nodeId,
};
}

View File

@ -1,220 +0,0 @@
import { ISenseAble, LocateEvent, isNodesDragTarget, activeTracker, getCurrentDocument } from '../../../globals';
import Location, { isLocationChildrenDetail, LocationDetailType } from '../../../document/location';
import tree from './tree';
import Scroller, { ScrollTarget } from '../../../document/scroller';
import { isShadowNode } from '../../../document/node/shadow-node';
import TreeNode from './tree-node';
import { INodeParent } from '../../../document/node';
import DwellTimer from './helper/dwell-timer';
import XAxisTracker from './helper/x-axis-tracker';
export const OutlineBoardID = 'outline-board';
export default class OutlineBoard implements ISenseAble {
id = OutlineBoardID;
get bounds() {
const rootElement = this.element;
const clientBound = rootElement.getBoundingClientRect();
return {
height: clientBound.height,
width: clientBound.width,
top: clientBound.top,
left: clientBound.left,
right: clientBound.right,
bottom: clientBound.bottom,
scale: 1,
scrollHeight: rootElement.scrollHeight,
scrollWidth: rootElement.scrollWidth,
};
}
sensitive: boolean = true;
private sensing: boolean = false;
private scrollTarget = new ScrollTarget(this.element);
private scroller = new Scroller(this, this.scrollTarget);
constructor(readonly element: HTMLDivElement) {
activeTracker.onChange(({ node, detail }) => {
const treeNode = isShadowNode(node) ? tree.getTreeNode(node.origin) : tree.getTreeNode(node);
if (treeNode.hidden) {
return;
}
if (detail && detail.type === LocationDetailType.Children) {
treeNode.expand(true);
} else {
treeNode.expandParents();
}
this.scrollToNode(treeNode, detail);
});
}
private tryScrollAgain: number | null = null;
scrollToNode(treeNode: TreeNode, detail?: any, tryTimes: number = 0) {
this.tryScrollAgain = null;
if (this.sensing) {
// is a active sensor
return;
}
const opt: any = {};
let scroll = false;
let rect: ClientRect | null;
if (detail && isLocationChildrenDetail(detail)) {
rect = tree.getInsertionRect();
} else {
rect = treeNode.computeRect();
}
if (!rect) {
if (!this.tryScrollAgain && tryTimes < 3) {
this.tryScrollAgain = requestAnimationFrame(() => this.scrollToNode(treeNode, detail, tryTimes + 1));
}
return;
}
const scrollTarget = this.scrollTarget;
const st = scrollTarget.top;
const { height, top, bottom, scrollHeight } = this.bounds;
if (rect.top < top || rect.bottom > bottom) {
opt.top = Math.min(rect.top + rect.height / 2 + st - top - height / 2, scrollHeight - height);
scroll = true;
}
if (scroll && this.scroller) {
this.scroller.scrollTo(opt);
}
}
isEnter(e: LocateEvent): boolean {
return this.inRange(e);
}
inRange(e: LocateEvent): boolean {
const rect = this.bounds;
return e.globalY >= rect.top && e.globalY <= rect.bottom && e.globalX >= rect.left && e.globalX <= rect.right;
}
deactive(): void {
this.sensing = false;
console.log('>>> deactive');
}
fixEvent(e: LocateEvent): LocateEvent {
return e;
}
private dwellTimer: DwellTimer = new DwellTimer(450);
private xAxisTracker = new XAxisTracker();
locate(e: LocateEvent): Location | undefined {
this.sensing = true;
this.scroller.scrolling(e);
const dragTarget = e.dragTarget;
// FIXME: not support multiples/nodedatas/any data,
const dragment = isNodesDragTarget(dragTarget) ? dragTarget.nodes[0] : null;
if (!dragment) {
return;
}
const doc = getCurrentDocument()!;
const preDraggedNode = doc.dropLocation && doc.dropLocation.target;
// 左右移动追踪,一旦左右移动满足位置条件,直接返回即可。
if (doc.dropLocation) {
const loc2 = this.xAxisTracker.track(doc.dropLocation, e);
if (loc2) {
this.dwellTimer.end();
return doc.createLocation(loc2);
}
} else {
this.dwellTimer.end();
return doc.createLocation({
target: dragment.parent!,
detail: {
type: LocationDetailType.Children,
index: dragment.index,
},
});
}
// 这语句的后半段是解决"丢帧"问题
// e 有一种情况,是从 root > .flow 开始冒泡,而不是实际节点。这种情况往往发生在:光标在插入框内移动
// 此时取上一次插入位置的 node 即可
const treeNode = tree.getTreeNodeByEvent(e as any) || (preDraggedNode && tree.getTreeNode(preDraggedNode));
// TODO: 没有判断是否可以放入 isDropContainer决定 target 的值是父节点还是本节点
if (!treeNode || dragment === treeNode.node || treeNode.ignored) {
this.dwellTimer.end();
console.warn('not found tree-node or other reasons', treeNode, e);
return undefined;
}
// console.log('I am at', treeNode.id, e);
const rect = treeNode.computeRect();
if (!rect) {
this.dwellTimer.end();
console.warn('can not get the rect, node', treeNode.id);
return undefined;
}
const node = treeNode.node;
const parentNode = node.parent;
if (!parentNode) {
this.dwellTimer.end();
return undefined;
}
let index = Math.max(parentNode.children.indexOf(node), 0);
const center = rect.top + rect.height / 2;
// 常规处理
// 如果可以展开,但是没有展开,需要设置延时器,检查停留时间然后展开
// 最后返回合适的位置信息
// FIXME: 容器判断存在问题,比如 img 是可以被放入的
if (treeNode.isContainer() && !treeNode.expanded) {
if (e.globalY > center) {
this.dwellTimer.start(treeNode.id, () => {
doc.createLocation({
target: node as INodeParent,
detail: {
type: LocationDetailType.Children,
index: 0,
},
});
});
}
} else {
this.dwellTimer.end();
}
// 如果节点是展开状态,并且光标是在其下方,不做任何处理,直接返回即可
// 如果不做这个处理,那么会出现"抖动"情况:在当前元素中心线下方时,会作为该元素的第一个子节点插入,而又会碰到已经存在对第一个字节点"争相"处理
if (treeNode.expanded) {
if (e.globalY > center) {
return undefined;
}
}
// 如果光标移动到节点中心线下方,则将元素插入到该节点下方
// 反之插入该节点上方
if (e.globalY > center) {
// down
index = index + 1;
}
index = Math.min(index, parentNode.children.length);
return doc.createLocation({
target: parentNode,
detail: {
type: LocationDetailType.Children,
index,
},
});
}
}

View File

@ -1,7 +1,7 @@
import { computed, obx, TitleContent, isI18nData, localeFormat } from '../../globals';
import Node from '../../designer/src/designer/document/node/node';
import DocumentModel from '../../designer/src/designer/document/document-model';
import { isLocationChildrenDetail } from '../../designer/src/designer/helper/location';
import { isLocationChildrenDetail, LocationChildrenDetail } from '../../designer/src/designer/helper/location';
import Designer from '../../designer/src/designer/designer';
import { Tree } from './tree';
@ -14,15 +14,15 @@ export default class TreeNode {
*
*/
@computed get expandable(): boolean {
return this.hasChildren() || this.isSlotContainer() || this.dropIndex != null;
return this.hasChildren() || this.isSlotContainer() || this.dropDetail?.index != null;
}
/**
* "线"
*/
@computed get dropIndex(): number | null {
@computed get dropDetail(): LocationChildrenDetail | undefined | null {
const loc = this.node.document.dropLocation;
return loc && this.isResponseDropping() && isLocationChildrenDetail(loc.detail) ? loc.detail.index : null;
return loc && this.isResponseDropping() && isLocationChildrenDetail(loc.detail) ? loc.detail : null;
}
@computed get depth(): number {
@ -44,6 +44,14 @@ export default class TreeNode {
return loc.target === this.node;
}
@computed isFocusingNode(): boolean {
const loc = this.node.document.dropLocation;
if (!loc) {
return false;
}
return isLocationChildrenDetail(loc.detail) && loc.detail.focus?.type === 'node' && loc.detail.focus.node === this.node;
}
/**
*
*
@ -198,65 +206,6 @@ export default class TreeNode {
}
*/
/**
*
*/
expand(tryExpandParents: boolean = false) {
// 这边不能直接使用 expanded需要额外判断是否可以展开
// 如果只使用 expanded会漏掉不可以展开的情况即在不可以展开的情况下会触发展开
if (this.expandable && !this._expanded) {
this.setExpanded(true);
}
if (tryExpandParents) {
this.expandParents();
}
}
/**
*
*
*/
private dwellTimer: number | undefined;
clearDwellTimer() {
clearTimeout(this.dwellTimer);
this.dwellTimer = undefined;
}
willExpand() {
if (this.dwellTimer) {
return;
}
this.clearDwellTimer();
if (this.expanded) {
return;
}
this.dwellTimer = setTimeout(() => {
this.clearDwellTimer();
this.expand(true);
}, 400) as any;
}
expandParents() {
let p = this.node.parent;
while (p) {
this.tree.getTreeNode(p).setExpanded(true);
p = p.parent;
}
}
private titleRef: HTMLDivElement | null = null;
mount(ref: HTMLDivElement | null) {
this.titleRef = ref;
}
computeRect() {
let target = this.titleRef;
if (!target) {
const nodeId = this.id;
target = window.document.querySelector(`div[data-id="${nodeId}"]`);
}
return target && target.getBoundingClientRect();
}
select(isMulti: boolean) {
const node = this.node;
@ -274,6 +223,28 @@ export default class TreeNode {
}
}
/**
*
*/
expand(tryExpandParents: boolean = false) {
// 这边不能直接使用 expanded需要额外判断是否可以展开
// 如果只使用 expanded会漏掉不可以展开的情况即在不可以展开的情况下会触发展开
if (this.expandable && !this._expanded) {
this.setExpanded(true);
}
if (tryExpandParents) {
this.expandParents();
}
}
expandParents() {
let p = this.node.parent;
while (p) {
this.tree.getTreeNode(p).setExpanded(true);
p = p.parent;
}
}
readonly designer: Designer;
readonly document: DocumentModel;
@obx.ref private _node: Node;

View File

@ -25,4 +25,8 @@ export class Tree {
this.treeNodesMap.set(node.id, treeNode);
return treeNode;
}
getTreeNodeById(id: string) {
return this.treeNodesMap.get(id);
}
}

View File

@ -14,8 +14,9 @@
}
.lc-outline-tree {
@treeNodeHeight: 30px;
overflow: hidden;
margin-bottom: 20px;
margin-bottom: @treeNodeHeight;
user-select: none;
.tree-node-branches::before {
@ -28,6 +29,7 @@
left: 6px;
content: ' ';
z-index: 2;
pointer-events: none;
}
&:hover {
@ -37,10 +39,15 @@
}
.insertion {
pointer-events: none !important;
pointer-events: all !important;
border: 1px dashed var(--color-brand-light);
height: 18px;
height: @treeNodeHeight;
box-sizing: border-box;
transform: translateZ(0);
&.invalid {
border-color: red;
background-color: rgba(240, 154, 154, 0.719);
}
}
.condition-group-container {
@ -75,7 +82,7 @@
.tree-node-slots {
border-bottom: 1px solid rgb(144, 94, 190);
position: relative;
&:before {
&::before {
position: absolute;
display: block;
width: 0;
@ -99,6 +106,16 @@
display: block;
}
}
&.insertion-at-slots {
padding-bottom: @treeNodeHeight;
border-bottom-color: rgb(182, 55, 55);
>.tree-node-slots-title {
background-color: rgb(182, 55, 55);
}
&::before {
border-left-color: rgb(182, 55, 55);
}
}
}
.tree-node {
@ -146,7 +163,8 @@
border-bottom: 1px solid var(--color-line-normal, rgba(31, 56, 88, 0.1));
display: flex;
align-items: center;
height: 30px;
height: @treeNodeHeight;
box-sizing: border-box;
position: relative;
transform: translateZ(0);
padding-right: 5px;
@ -196,6 +214,12 @@
opacity: 0.5;
}
}
html.lc-cursor-dragging & {
// FIXME: only hide hover shows
.tree-node-hide-btn, .tree-node-lock-btn {
display: none;
}
}
&.editing {
& > .tree-node-hide-btn, & >.tree-node-lock-btn {
display: none;
@ -285,6 +309,22 @@
& > .tree-node-branches::before {
border-left: 1px solid var(--color-brand);
}
& > .tree-node-title {
.tree-node-expand-btn {
color: var(--color-brand);
}
.tree-node-icon {
color: var(--color-brand);
}
.tree-node-title-label > .lc-title {
color: var(--color-brand);
}
}
}
&.highlight {
& > .tree-node-title {
background: var(--color-block-background-shallow);
}
}
.tree-node-branches {

View File

@ -1,5 +1,6 @@
import { observer, Title } from '../../../globals';
import { Component } from 'react';
import classNames from 'classnames';
import TreeNode from '../tree-node';
import TreeNodeView from './tree-node';
import ExclusiveGroup from '../../../designer/src/designer/document/node/exclusive-group';
@ -55,7 +56,16 @@ class TreeNodeChildren extends Component<{
groupContents = [];
}
};
const { dropIndex } = treeNode;
const dropDetail = treeNode.dropDetail;
const dropIndex = dropDetail?.index;
const insertion = (
<div
key="insertion"
className={classNames('insertion', {
invalid: dropDetail?.valid === false,
})}
/>
);
treeNode.children?.forEach((child, index) => {
const { conditionGroup } = child.node;
if (conditionGroup !== currentGrp) {
@ -66,22 +76,23 @@ class TreeNodeChildren extends Component<{
currentGrp = conditionGroup;
if (index === dropIndex) {
if (groupContents.length > 0) {
groupContents.push(<div key="insertion" className="insertion" />);
groupContents.push(insertion);
} else {
children.push(<div key="insertion" className="insertion" />);
children.push(insertion);
}
}
groupContents.push(<TreeNodeView key={child.id} treeNode={child} />);
} else {
if (index === dropIndex) {
children.push(<div key="insertion" className="insertion" />);
children.push(insertion);
}
children.push(<TreeNodeView key={child.id} treeNode={child} />);
}
});
endGroup();
if (dropIndex != null && dropIndex === treeNode.children?.length) {
children.push(<div key="insertion" className="insertion" />);
const length = treeNode.children?.length || 0;
if (dropIndex != null && dropIndex >= length) {
children.push(insertion);
}
return <div className="tree-node-children">{children}</div>;
@ -101,9 +112,14 @@ class TreeNodeSlots extends Component<{
return null;
}
return (
<div className="tree-node-slots">
<div
className={classNames('tree-node-slots', {
'insertion-at-slots': treeNode.dropDetail?.focus?.type === 'slots',
})}
data-id={treeNode.id}
>
<div className="tree-node-slots-title">
<Title title={{ type: 'i18n', intl: intl('Slots')}} />
<Title title={{ type: 'i18n', intl: intl('Slots') }} />
</div>
{treeNode.slots.map(tnode => (
<TreeNodeView key={tnode.id} treeNode={tnode} />

View File

@ -27,10 +27,10 @@ export default class TreeNodeView extends Component<{ treeNode: TreeNode }> {
// 是否锁定的
locked: treeNode.locked,
// 是否投放响应
dropping: treeNode.dropIndex != null,
dropping: treeNode.dropDetail?.index != null,
'is-root': treeNode.isRoot(),
'condition-flow': treeNode.node.conditionGroup != null,
// highlight: treeNode.isResponseDropping() && treeNode.dropIndex == null,
highlight: treeNode.isFocusingNode(),
});
return (

View File

@ -61,7 +61,8 @@ export default class TreeTitle extends Component<{
const { treeNode } = this.props;
const { editing } = this.state;
const isCNode = !treeNode.isRoot();
const isNodeParent = treeNode.node.isNodeParent;
const { node } = treeNode;
const isNodeParent = node.isNodeParent;
let style: any;
if (isCNode) {
const depth = treeNode.depth;
@ -77,9 +78,9 @@ export default class TreeTitle extends Component<{
className={classNames('tree-node-title', {
editing,
})}
ref={ref => treeNode.mount(ref)}
style={style}
onClick={treeNode.node.conditionGroup ? () => treeNode.node.setConditionalVisible() : undefined}
data-id={treeNode.id}
onClick={node.conditionGroup ? () => node.setConditionalVisible() : undefined}
>
{isCNode && <ExpandBtn treeNode={treeNode} />}
<div className="tree-node-icon">{createIcon(treeNode.icon)}</div>
@ -94,19 +95,19 @@ export default class TreeTitle extends Component<{
) : (
<Fragment>
<Title title={treeNode.title} />
{treeNode.node.slotFor && (<a className="tree-node-tag slot">
{node.slotFor && (<a className="tree-node-tag slot">
{/* todo: click redirect to prop */}
<IconSlot />
<EmbedTip>{intl('Slot for {prop}', { prop: treeNode.node.slotFor.key })}</EmbedTip>
<EmbedTip>{intl('Slot for {prop}', { prop: node.slotFor.key })}</EmbedTip>
</a>)}
{treeNode.node.hasLoop() && (
{node.hasLoop() && (
<a className="tree-node-tag loop">
{/* todo: click todo something */}
<IconLoop />
<EmbedTip>{intl('Loop')}</EmbedTip>
</a>
)}
{treeNode.node.hasCondition() && !treeNode.node.conditionGroup && (
{node.hasCondition() && !node.conditionGroup && (
<a className="tree-node-tag cond">
{/* todo: click todo something */}
<IconCond />

View File

@ -1,83 +1,140 @@
import { Component } from 'react';
import { Component, MouseEvent as ReactMouseEvent } from 'react';
import { observer } from '../../../globals';
import { Tree } from '../tree';
import TreeNodeView from './tree-node';
import { isRootNode } from '../../../designer/src/designer/document/node/root-node';
import Node from '../../../designer/src/designer/document/node/node';
import { DragObjectType, isShaken } from '../../../designer/src/designer/helper/dragon';
function getTreeNodeIdByEvent(e: ReactMouseEvent, stop: Element): null | string {
let target: Element | null = e.target as Element;
if (!target || !stop.contains(target)) {
return null;
}
target = target.closest('[data-id]');
if (!target || !stop.contains(target)) {
return null;
}
return (target as HTMLDivElement).dataset.id || null;
}
@observer
export default class TreeView extends Component<{ tree: Tree }> {
/*
hover(e: any) {
const treeNode = tree.getTreeNodeByEvent(e);
private shell: HTMLDivElement | null = null;
private hover(e: ReactMouseEvent) {
const { tree } = this.props;
const doc = tree.document;
const hovering = doc.designer.hovering;
if (!hovering.enable) {
return;
}
const node = this.getTreeNodeFromEvent(e)?.node;
hovering.hover(node || null);
}
private onClick = (e: ReactMouseEvent) => {
if (this.ignoreUpSelected) {
return;
}
if (this.boostEvent && isShaken(this.boostEvent, e.nativeEvent)) {
return;
}
const treeNode = this.getTreeNodeFromEvent(e);
if (!treeNode) {
return;
}
const { node } = treeNode;
const designer = treeNode.designer;
const doc = node.document;
const selection = doc.selection;
const id = node.id;
const isMulti = e.metaKey || e.ctrlKey;
designer.activeTracker.track(node);
if (isMulti && !isRootNode(node) && selection.has(id)) {
selection.remove(id);
} else {
selection.select(id);
}
};
private onMouseOver = (e: ReactMouseEvent) => {
this.hover(e);
};
private getTreeNodeFromEvent(e: ReactMouseEvent) {
if (!this.shell) {
return;
}
const id = getTreeNodeIdByEvent(e, this.shell);
if (!id) {
return;
}
const { tree } = this.props;
return tree.getTreeNodeById(id);
}
private ignoreUpSelected = false;
private boostEvent?: MouseEvent;
private onMouseDown = (e: ReactMouseEvent) => {
const treeNode = this.getTreeNodeFromEvent(e);
if (!treeNode) {
return;
}
edging.watch(treeNode.node);
}
onClick(e: any) {
if (this.dragEvent && (this.dragEvent as any).shaken) {
return;
}
const { node } = treeNode;
const designer = treeNode.designer;
const doc = node.document;
const selection = doc.selection;
const isMulti = e.metaKey || e.ctrlKey;
const isLeftButton = e.button === 0;
const treeNode = tree.getTreeNodeByEvent(e);
if (!treeNode) {
return;
if (isLeftButton && !isRootNode(node)) {
let nodes: Node[] = [node];
this.ignoreUpSelected = false;
if (isMulti) {
// multi select mode, directily add
if (!selection.has(node.id)) {
designer.activeTracker.track(node);
selection.add(node.id);
this.ignoreUpSelected = true;
}
treeNode.select(isMulti);
// 通知主画板滚动到对应位置
activeTracker.track(treeNode.node);
selection.remove(doc.rootNode.id);
// 获得顶层 nodes
nodes = selection.getTopNodes();
}
onMouseOver(e: any) {
if (dragon.dragging) {
return;
this.boostEvent = e.nativeEvent;
designer.dragon.boost(
{
type: DragObjectType.Node,
nodes,
},
this.boostEvent,
);
}
this.hover(e);
}
onMouseUp(e: any) {
if (dragon.dragging) {
return;
}
this.hover(e);
}
onMouseLeave() {
edging.watch(null);
}
componentDidMount(): void {
if (this.ref.current) {
dragon.from(this.ref.current, (e: MouseEvent) => {
this.dragEvent = e;
const treeNode = tree.getTreeNodeByEvent(e);
if (treeNode) {
return {
type: DragTargetType.Nodes,
nodes: [treeNode.node],
};
}
return null;
});
}
}
*/
private onMouseLeave = () => {
const { tree } = this.props;
const doc = tree.document;
doc.designer.hovering.leave(doc);
};
render() {
const { tree } = this.props;
const root = tree.root;
return (
<div className="lc-outline-tree">
<div
className="lc-outline-tree"
ref={shell => (this.shell = shell)}
onMouseDown={this.onMouseDown}
onMouseOver={this.onMouseOver}
onClick={this.onClick}
onMouseLeave={this.onMouseLeave}
>
<TreeNodeView key={root.id} treeNode={root} />
</div>
);