用戶
 找回密碼
 立即注冊

QQ登錄

只需一步,快速開始

掃一掃,登錄網站

小程序社區 首頁 工具/框架 查看內容

基于 Angular 的小程序可視化編輯器 —— Panel-Magic

Rolan 2020-1-10 00:41

Panel-Magic是一個基于 AngularX+ 并面向設計師或運營人員的可視化搭建平臺,目前僅可用于快速生成微信小程序應用,具有與 Photoshop 相似的交互體驗??!好了,吹完之后接下來開始從技術角度剖析其中主要的實現原理 ...

Panel-Magic 是一個基于 AngularX+ 并面向設計師或運營人員的可視化搭建平臺,目前僅可用于快速生成微信小程序應用,具有與 Photoshop 相似的交互體驗??!

好了,吹完之后接下來開始從技術角度剖析其中主要的實現原理

在此之前說明該平臺的定位,目的不是給技術人員編輯完之后進行二次開發或代碼的定制化。關于這個定位問題我個人的想法是,code 問題不可能完全交托給可視化編輯、除非是類似傳統的簡單的企業介紹頁等還有可能完全代替,但還是比不上直接代碼生成的工具,所以 Panel-Magic 一開始的定位就是給設計師或運營人員使用,生成的產物不再是 code。

技術棧

  • 框架選型:Angular8
  • UI 組件庫: ng-zorro-antd (宇宙第一組件庫)
  • 本地存儲:IndexedDB
  • 響應式編程庫:Rxjs
  • 編寫語言:Typescript
  • CSS 預處理器:SCSS
  • 最終產物:JSON

工作流程

關鍵是中間的數據模型的建模過程以及可視化界面的創建,生成的新數據和源數據都是約定好固定格式的 JSON 描述文件,其包含固定的 key 字段和對應的 value 值類型,生成小程序的過程在生成完新數據之后

目前源數據約定的數據格式為

{
    "app_id": "",
    "cata_data": [
        {
            "group": "默認組",
            "pages": [
                {
                    "title": "首頁",
                    "name": "首頁",
                    "router": "page10001",
                    "isEdit": false,
                    "uniqueId": 1556693791081,
                },
            ],
            "isEdit": false,
            "uniqueId": 1556693791066,
        }
    ]
}
復制代碼

更為完整的約定格式在 MockModel.ts

目錄結構

src
├── app
│   ├── appdata                                 // AppData 根服務,數據模型 AppDataModel 的核心服務
│   ├── base-class                              // 基類
│   ├── core                                    // HttpClient 服務
│   ├── panel-extend                            // 可視化搭建交互部分
│   │   ├── model                               // 數據模型
│   │   ├── panel-assist-arbor                  // 右側可操作區域如對齊、圖層、前進后退等操作入口
│   │   ├── panel-catalogue                     // 頁面分組管理
│   │   ├── panel-event                         // 事件管理
│   │   ├── panel-layer                         // 圖層列表管理
│   │   ├── panel-scaleplate                    // 標尺管理
│   │   ├── panel-scope-enchantment             // 核心拖拽部分,包括輔助線、輪廓描述等
│   │   ├── panel-senior-vessel-edit            // 容器組合管理
│   │   ├── panel-shell                         // “手機殼”區域管理
│   │   ├── panel-soul                          // 左側組件庫管理
│   │   ├── panel-widget                        // 每個部分組件如按鈕、文字等
│   │   │   ├── all-widget-container
│   │   │   │   ├── auxiliaryline-widget
│   │   │   │   ├── button-widget
│   │   │   │   ├── linkrange-widget
│   │   │   │   ├── picture-widget
│   │   │   │   ├── rect-widget
│   │   │   │   └── text-widget
│   │   │   ├── all-widget-unit
│   │   │   │   ├── map-view
│   │   │   │   ├── navigation-bar-view
│   │   │   │   ├── rich-text-view
│   │   │   │   ├── slideshow-picture-view
│   │   │   │   └── tab-bar-view
│   │   │   ├── all-widget-vessel
│   │   │   │   └── senior-vessel-widget
│   │   │   └── model
│   │   ├── panel-widget-appearance             // “設置”管理
│   │   │   ├── model
│   │   │   ├── panel-widget-animation
│   │   │   ├── panel-widget-clip-path
│   │   │   ├── panel-widget-facade
│   │   │   ├── panel-widget-filter
│   │   │   ├── panel-widget-picture
│   │   │   ├── panel-widget-shadow
│   │   │   └── panel-widget-text
│   │   ├── panel-widget-appearance-site        // 每個部分組件的專屬“設置”
│   │   │   ├── panel-button-site
│   │   │   ├── panel-combination-site
│   │   │   ├── panel-line-site
│   │   │   ├── panel-linkrange-site
│   │   │   ├── panel-map-site
│   │   │   ├── panel-picture-site
│   │   │   ├── panel-rect-site
│   │   │   ├── panel-slideshow-picture-site
│   │   │   └── panel-text-site
│   │   └── panel-widget-details                // 彈出來的“設置”管理界面
│   ├── public                                  // 公共組件
│   │   ├── directive
│   │   ├── image-gallery
│   │   ├── my-color-picker
│   │   ├── ng-thumb-auto
│   │   ├── pipe
│   │   ├── theme
│   │   ├── top-navbar
│   │   └── util
│   ├── service                                 // 服務端 service
│   │   ├── hs-files
│   │   └── hs-xcx
│   └── share
├── assets                                      // 資源文件
復制代碼

布局排版

為了實現更好的自由布局排版,絕對定位是我的首選選擇,也更能匹配像素級別的定制編輯

除了定位數據以外,每個組件其實都具有通用的樣式數據,如邊框設置、陰影設置、文本設置、定位設置等通用元素,甚至也具有通用的事件設置,然后對于編輯來說,組件同時也具有如選中時的輪廓樣式數據等,所以我們定義一個基本組件數據模型,讓所有組件都繼承這個模型,那就是 PanelWidgetModel.ts

拿 button 按鈕組件舉例來說,它位于 src/app/panel-extend/panel-widget/all-widget-container/button-widget ;

├── button-widget.component.html
├── button-widget.component.ts
└── button-widget.data.ts
復制代碼

其中 button-widget.data.ts 文件是用于在左側拖拽組件到中間編輯區域時候的默認樣式和事件數據,它是直接實例化了 PanelWidgetModel 并導出

其中 component 部分為:

import { Component, OnInit, Input } from "@angular/core";
import { PanelWidgetModel } from "../../model";

@Component({
    selector: "app-button-widget",
    templateUrl: "./button-widget.component.html",
    styles: [""],
})
export class ButtonWidgetComponent implements OnInit {
    private _widget: PanelWidgetModel;

    @Input()
    public get widget(): PanelWidgetModel {
        return this._widget;
    }
    public set widget(v: PanelWidgetModel) {
        this._widget = v;
    }
    constructor() {}

    ngOnInit() {}
}

復制代碼

然后在渲染的時候雙向綁定里面的文本數據

<p *ngIf="!widget.isHiddenText" class="text-overflow-hidden">{{ widget.autoWidget.content }}</p>
復制代碼

對于簡單的組件 PanelWidgetModel 提供的基本數據模型足矣;

稍微復雜的組件如 map 地圖組件則可以在 component 文件里自行拓展 PanelWidgetModel類;

有了 PanelWidgetModel 之后,我們來看看渲染組件的核心代碼部分 :point_down:;

<div class="zoom-area" [ngStyle]="{ 'background-color': panelInfo.bgColor }">
    <ng-container *ngFor="let widget of widgetList$ | async">
        <div class="widget-shell" [ngStyle]="widget.profileModel.styleContent">
            <app-panel-widget [widget]="widget" [isSimpleFunc]="false"></app-panel-widget>
        </div>
    </ng-container>
</div>
復制代碼

在模版中異步循環渲染 widgetList$ 里的組件并傳遞數據給 app-panel-widget 組件;

其中 widgetList$ 定義為;

public get widgetList$(): BehaviorSubject<Array<PanelWidgetModel>> {
    return this.panelExtendService.widgetList$;
}

// 在 panelExtendService 服務里
public widgetList$: BehaviorSubject<Array<PanelWidgetModel>> = new BehaviorSubject<Array<PanelWidgetModel>>([]);
復制代碼

就是上述提到的 PanelWidgetModel 類列表;

而 app-panel-widget 組件位于 src/app/panel-extend/panel-widget/panel-widget.component.ts

它負責接收 widgetList$ 里的每一個不同組件并根據 type 類型負責渲染對應的組件;

<div
    class="widget-main"
    [nrIsStopPropagation]="true"
    nrDraggable
    [nrIdBody]="'#free-panel-main'"
    (launchMouseIncrement)="acceptDraggableIncrement($event)"
    nrMouseMoveOut
    (dblclick)="acceptDoubleClick()"
    (mousedown)="acceptWidgetChecked($event)"
    (emitMouseType)="acceptMouseMoveOut($event)"
    (contextmenu)="acceptWidgetRightClick($event)"
>
    <ng-container *ngIf="widget.autoWidget">
        <div class="widget-content {{ widget.type }}" *ngIf="widget.type != 'combination'" [ngStyle]="widgetStyle">
            <ng-container [ngSwitch]="widget.type">
                <!-- more ... -->
                <!-- 按鈕 -->
                <ng-container *ngSwitchCase="'button'">
                    <app-button-widget [widget]="widget"></app-button-widget>
                </ng-container>
                <!-- more ... -->
            </ng-container>
        </div>
    </ng-container>
</div>
復制代碼
nrDraggable
launchMouseIncrement
nrMouseMoveOut
emitMouseType
contextmenu

所以,當你在面板中選中某個組件的時候,不單單只是一個簡單的 click 事件組成,是由鼠標的移入、鼠標按下、鼠標彈起等分解步驟來完成;

我們先看看 mousedown 事件, 它執行的方法為 acceptWidgetChecked ;

public acceptWidgetChecked(event: MouseEvent): void {
    if (!this.isSimpleFunc) {
        event.stopPropagation();
        event.preventDefault();
        if (
            !this.panelScopeEnchantmentService.scopeEnchantmentModel.outerSphereInsetWidgetList$.value.some(
                w => w.uniqueId == this.widget.uniqueId
            )
        ) {
            event.shiftKey == true
                ? this.panelScopeEnchantmentService.toggleOuterSphereInsetWidget(this.widget)
                : this.panelScopeEnchantmentService.onlyOuterSphereInsetWidget(this.widget);
        } else {
            if (event.shiftKey == true) this.panelScopeEnchantmentService.toggleOuterSphereInsetWidget(this.widget);
        }
        this.openMouseMoveLaunch();
    }
}
復制代碼

這里先補充一下, panelScopeEnchantmentService 服務負責管理拖拽時的輔助線計算、輪廓描邊生成以及右鍵事件等核心編輯服務,該服務的 ScopeEnchantmentModel 就是用于生成組件輪廓數據和拖拽點的數據模型類;

所謂'輪廓描述',就是計算多個或單個組件的最長、最高的描邊

回到 acceptWidgetChecked , 這里當鼠標按下的時候并不是直接生成該組件的輪廓描述,而是多了 shiftKey 鍵盤事件的判斷,用于按住 shiftKey 的時候多選多個組件并將生成的輪廓描邊包含出多個組件,如

其中生成輪廓的邏輯核心部分在 panelScopeEnchantmentService 里的 handleFromWidgetListToProfileOuterSphere 方法,:point_down:

public handleFromWidgetListToProfileOuterSphere(arg: { isLaunch?: boolean } = { isLaunch: true }): void {
    const oriArr = this.scopeEnchantmentModel.outerSphereInsetWidgetList$.value.map(e => {
        e.profileModel.isCheck = true;
        // 根據當前位置重新設置mousecoord
        e.profileModel.setMouseCoord([e.profileModel.left, e.profileModel.top]);
        return e.profileModel;
    });
    if (oriArr.length > 0) {
        // 計算出最小的left,最小的top,最大的width和height
        const calcResult = this.calcProfileOuterSphereInfo();
        // 如果insetWidget數量大于一個則不允許開啟旋轉,且旋轉角度重置
        if (oriArr.length == 1) {
            calcResult.isRotate = true;
            calcResult.rotate = oriArr[0].rotate;
        } else {
            calcResult.isRotate = false;
        }
        // 賦值
        this.scopeEnchantmentModel.launchProfileOuterSphere(calcResult, arg.isLaunch);
        // 同時生成八個方位坐標點,如果被選組件大于一個則不生成
        this.scopeEnchantmentModel.handleCreateErightCornerPin();
    }
}
復制代碼

其中 calcProfileOuterSphereInfo 是計算大小和位置的核心

public calcProfileOuterSphereInfo(): OuterSphereHasAuxlModel {
    const insetWidget = this.scopeEnchantmentModel.outerSphereInsetWidgetList$.value;
    let outerSphere = new OuterSphereHasAuxlModel().setData({
        left: Infinity,
        top: Infinity,
        width: -Infinity,
        height: -Infinity,
        rotate: 0,
    });
    let maxWidth = null;
    let maxHeight = null;
    let minWidthEmpty = Infinity;
    let minHeightEmpty = Infinity;
    insetWidget.forEach(e => {
        let offsetCoord = { left: 0, top: 0 };
        if (e.profileModel.rotate != 0 && insetWidget.length > 1) {
            offsetCoord = this.handleOuterSphereRotateOffsetCoord(e.profileModel);
        }

        outerSphere.left = Math.min(outerSphere.left, e.profileModel.left + offsetCoord.left);
        outerSphere.top = Math.min(outerSphere.top, e.profileModel.top + offsetCoord.top);

        maxWidth = Math.max(maxWidth, e.profileModel.left + e.profileModel.width + offsetCoord.left * -1);
        maxHeight = Math.max(maxHeight, e.profileModel.top + e.profileModel.height + offsetCoord.top * -1);

        if (e.profileModel.left + e.profileModel.width < 0) {
            minWidthEmpty = Math.min(minWidthEmpty, Math.abs(e.profileModel.left) - e.profileModel.width);
        } else {
            minWidthEmpty = 0;
        }

        if (e.profileModel.top + e.profileModel.height < 0) {
            minHeightEmpty = Math.min(minHeightEmpty, Math.abs(e.profileModel.top) - e.profileModel.height);
        } else {
            minHeightEmpty = 0;
        }
    });

    outerSphere.width = Math.abs(maxWidth - outerSphere.left) - minWidthEmpty;
    outerSphere.height = Math.abs(maxHeight - outerSphere.top) - minHeightEmpty;
    outerSphere.setMouseCoord([outerSphere.left, outerSphere.top]);

    return outerSphere;
}
復制代碼

更為完整的邏輯在 panelScopeEnchantmentService 服務里;

接下來就是拖拽事件,拖拽的組件并不單單是某個組件,而是輪廓包含在內的所有被選中組件,核心代碼在 src/app/panel-extend/panel-scope-enchantment/model/scope-enchantment.model.ts 的 handleLocationInsetWidget 方法里;

/**
 * 根據主輪廓的位置計算輪廓內被選組件的位置
 */
public handleLocationInsetWidget(
    increment: DraggablePort,
    allWidget: Array<PanelWidgetModel> = this.outerSphereInsetWidgetList$.value
): void {
    if (Array.isArray(allWidget)) {
        const pro = this.valueProfileOuterSphere;
        // 所有輪廓內的組件計算位置
        allWidget.forEach(w => {
            w.profileModel.mouseCoord[0] += increment.left;
            w.profileModel.mouseCoord[1] += increment.top;
            let obj = { left: w.profileModel.mouseCoord[0], top: w.profileModel.mouseCoord[1] };
            if (!(pro.lLine || pro.rLine || pro.vcLine)) {
                obj.left = w.profileModel.mouseCoord[0];
                pro.left = pro.mouseCoord[0];
            } else {
                obj.left += pro.left - pro.mouseCoord[0];
            }
            if (!(pro.tLine || pro.bLine || pro.hcLine)) {
                obj.top = w.profileModel.mouseCoord[1];
                pro.top = pro.mouseCoord[1];
            } else {
                obj.top += pro.top - pro.mouseCoord[1];
            }
            w.profileModel.setData(obj);
            /**
             * 如果被選的所有組件當中有組合組件combination,則需要重新計算其子集的所有widget輪廓數值
             */
            if (w.type == "combination") {
                this.handleLocationInsetWidget(increment, w.autoWidget.content);
            }
        });
    }
}
復制代碼

注:由于拖拽的過程當中,改變的是每個組件自身的位置信息數據,而輪廓描述是由 calcProfileOuterSphereInfo 計算生成的,所有在拖拽的過程當中還需要實時計算主輪廓數據;

小結:

PanelWidgetModel
ScopeEnchantmentModel

神奇的 "旋轉" 所帶來的問題

默認情況下所以依賴于 PanelWidgetModel 類的組件都可以進行旋轉,但就是因為這個旋轉角度,所影響的問題包括了拖拽邊框拉伸、多選組件一起拉伸、對齊輔助線計算不準確等一系列問題,所以在旋轉之后需要計算與不旋轉時候的差值增量,具體計算方式可以看我另一篇水文 12.拖拽拉伸加上旋轉角度的數學原理

核心函數位于 src/app/panel-extend/panel-scope-enchantment/panel-scope-enchantment.service.ts 的 handleOuterSphereRotateOffsetCoord ;

public handleOuterSphereRotateOffsetCoord(
    arg: ProfileModel,
    type: "lt" | "rt" | "lb" | "rb" = "lt"
): { left: number; top: number } | undefined {
    const fourCoord = this.conversionRotateToOffsetLeftTop({
        width: arg.width,
        height: arg.height,
        rotate: arg.rotate,
    });
    if (fourCoord) {
        let min = Infinity;
        let max = -Infinity;
        for (let e in fourCoord) {
            min = Math.min(min, fourCoord[e][0]);
            max = Math.max(max, fourCoord[e][1]);
        }
        const typeObj = {
            lt: [min, max],
            rt: [-min, max],
            lb: [min, -max],
            rb: [-min, -max],
        };
        if (typeObj[type]) {
            return {
                left: Math.round(arg.width / 2 + typeObj[type][0]),
                top: Math.round(arg.height / 2 - typeObj[type][1]),
            };
        }
    }
    return;
}

/// more...

public conversionRotateToOffsetLeftTop(arg: {
    width: number;
    height: number;
    rotate: number;
}): {
    lt: number[];
    rt: number[];
    lb: number[];
    rb: number[];
} {
    // 轉化角度使其成0~360的范圍
    arg.rotate = this.conversionRotateOneCircle(arg.rotate);
    let result = {
        lt: [(arg.width / 2) * -1, arg.height / 2],
        rt: [arg.width / 2, arg.height / 2],
        lb: [(arg.width / 2) * -1, (arg.height / 2) * -1],
        rb: [arg.width / 2, (arg.height / 2) * -1],
    };
    let convRotate = this.conversionRotateToMathDegree(arg.rotate);
    let calcX = (x, y) => <any>(x * Math.cos(convRotate) + y * Math.sin(convRotate)) * 1;
    let calcY = (x, y) => <any>(y * Math.cos(convRotate) - x * Math.sin(convRotate)) * 1;
    result.lt = [calcX(result.lt[0], result.lt[1]), calcY(result.lt[0], result.lt[1])];
    result.rt = [calcX(result.rt[0], result.rt[1]), calcY(result.rt[0], result.rt[1])];
    result.lb = [result.rt[0] * -1, result.rt[1] * -1];
    result.rb = [result.lt[0] * -1, result.lt[1] * -1];
    return result;
}
復制代碼

具體的邊框拉伸計算方式核心都在 DraggableTensileCursorService 服務

選中多個組件同時進行邊框拉伸計算方式

如果只選中一個組件對其進行邊框拉伸是很好計算的,即使有個旋轉角度也很好的計算,倘若選中的是多個組件一起呢?

我的解決方案就是;

拖拽邊框拉伸改變的其實不是組件本身的邊框,而是主輪廓 ScopeEnchantmentModel 的邊框,只是 順便 計算一下這個輪廓內部所有被選中的組件相對于輪廓來說的 位置比例 而已

核心代碼位于 src/app/panel-extend/panel-scope-enchantment/model/profile.model.ts ;

/**
 * 根據傳入的主輪廓數據計算該組件在主輪廓里的位置比例
 */
public recordInsetProOuterSphereFourProportion(pro: ProfileModel, widget: ProfileModel = this): void {
    this.insetProOuterSphereFourProportion = {
        left: (widget.left - pro.left) / pro.width,
        top: (widget.top - pro.top) / pro.height,
        right: (widget.left - pro.left + widget.width) / pro.width,
        bottom: Math.abs(widget.top - pro.top + widget.height) / pro.height,
    };
}
復制代碼

PS: ProfileModel 類是 PanelWidgetModel 類里的用于描述組件本身的輪廓數據類

這樣一來所有被選中的組件都有了相對于主輪廓來說的位置比例,在進行拉伸計算的時候,將組件自己的寬高和主輪廓的寬高比例保持一致,即可

對齊輔助線生成規則

先看看對齊輔助線效果;

用過 PS 的蛇雞絲應該對這個功能不會陌生,我個人也很喜歡這么牛逼的輔助線對齊;

我們先看看對齊輔助線渲染的模版文件,它位于 src/app/panel-extend/panel-scope-enchantment/panel-scope-enchantment.component.html ;

<!-- 輔助線 -->
<div class="auxiliary-container">
    <ng-container *ngIf="scopeEnchantment.profileOuterSphere$ | async">
        <div
            class="v v-left"
            *ngIf="(scopeEnchantment.profileOuterSphere$ | async).lLine"
            [ngStyle]="{
                left:
                    (scopeEnchantment.profileOuterSphere$ | async).left +
                    (scopeEnchantment.profileOuterSphere$ | async).offsetAmount.left +
                    'px'
            }"
        ></div>
        <div
            class="v v-center"
            *ngIf="(scopeEnchantment.profileOuterSphere$ | async).vcLine"
            [ngStyle]="{ left: (scopeEnchantment.profileOuterSphere$ | async).vCenterStyle + 'px' }"
        ></div>
        <div
            class="v v-right"
            *ngIf="(scopeEnchantment.profileOuterSphere$ | async).rLine"
            [ngStyle]="{
                left:
                    (scopeEnchantment.profileOuterSphere$ | async).rightStyle -
                    (scopeEnchantment.profileOuterSphere$ | async).offsetAmount.left +
                    'px'
            }"
        ></div>
        <div
            class="h h-top"
            *ngIf="(scopeEnchantment.profileOuterSphere$ | async).tLine"
            [ngStyle]="{
                top:
                    (scopeEnchantment.profileOuterSphere$ | async).top +
                    (scopeEnchantment.profileOuterSphere$ | async).offsetAmount.top +
                    'px'
            }"
        ></div>
        <div
            class="h h-center"
            *ngIf="(scopeEnchantment.profileOuterSphere$ | async).hcLine"
            [ngStyle]="{ top: (scopeEnchantment.profileOuterSphere$ | async).hCenterStyle + 'px' }"
        ></div>
        <div
            class="h h-bottom"
            *ngIf="(scopeEnchantment.profileOuterSphere$ | async).bLine"
            [ngStyle]="{
                top:
                    (scopeEnchantment.profileOuterSphere$ | async).bottomStyle -
                    (scopeEnchantment.profileOuterSphere$ | async).offsetAmount.top +
                    'px'
            }"
        ></div>
    </ng-container>
</div>
復制代碼

輔助線數據依賴于 ScopeEnchantmentModel 里的 profileOuterSphere$ , 其實就是描述主輪廓的可觀察類, 定義如下;

public profileOuterSphere$: BehaviorSubject<OuterSphereHasAuxlModel> = new BehaviorSubject(null);
復制代碼

其中 OuterSphereHasAuxlModel 就是包含了對齊輔助線的所有位置數據

大致思路就是

在點擊主輪廓正準備拖拽的時刻,計算好不在主輪廓內的其他外部組件的所有位置數據信息并記錄在某個變量里,完了之后在拖拽的過程當中,計算主輪廓的位置信息與這個變量內的數據差值是否達到了臨界點,從而決定是否顯示對齊輔助線和改變位置;

在 src/app/panel-extend/panel-scope-enchantment/panel-scope-enchantment.component.ts這個組件下開啟對主輪廓的訂閱

// 生成完主輪廓之后計算其余組件的橫線和豎線情況并保存起來
this.profileOuterSphereRX$ = this.scopeEnchantment.profileOuterSphere$.pipe().subscribe(value => {
    const insetW = this.panelScopeEnchantmentService.scopeEnchantmentModel.outerSphereInsetWidgetList$.value;
    if (value) {
        this.createAllLineSave();
        // 主輪廓創建完成就開啟角度值監聽
        this.openRotateSubject(value);
        // 根據角度計算主輪廓的offset坐標增量
        const cValue = cloneDeep(value);
        const offsetCoord = this.panelScopeEnchantmentService.handleOuterSphereRotateOffsetCoord(cValue);
        value.setOffsetAmount(offsetCoord);
        // 開始記錄所有被選組件的位置比例
        insetW.forEach(w => {
            w.profileModel.recordInsetProOuterSphereFourProportion(value);
        });
    }
    this.panelScopeEnchantmentService.panelScopeTextEditorModel$.next(null);
    this.clipPathService.emptyClipPath();
});
復制代碼

然后拖拽過程中限流的計算位置信息

/**
 * 計算輔助線的顯示與否情況
 * 分為6種情況
 * 輔助線只會顯示在主輪廓的4條邊以及2條中線
 * 遍歷時先尋找離四條邊最近的4個數值
 * 參數target表示除了用于計算最外主輪廓以外還能計算其他的輔助線情況,(例如左側的組件庫里的待創建的組件)
 */
public handleAuxlineCalculate(
    target: OuterSphereHasAuxlModel = this.scopeEnchantmentModel.valueProfileOuterSphere
): void {
    const outerSphere = target;
    const offsetAmount = outerSphere.offsetAmount;
    const aux = this.auxliLineModel$.value;
    const mouseCoord = outerSphere.mouseCoord;

    // 差量達到多少范圍內開始對齊
    const diffNum: number = 4;

    outerSphere.resetAuxl();

    if (mouseCoord) {
        for (let i: number = 0, l: number = aux.vLineList.length; i < l; i++) {
            if (Math.abs(aux.vLineList[i] - mouseCoord[0] + offsetAmount.left * -1) <= diffNum) {
                outerSphere.left = aux.vLineList[i] + offsetAmount.left * -1;
                outerSphere.lLine = true;
            }
            if (Math.abs(aux.vLineList[i] - (mouseCoord[0] + outerSphere.width) + offsetAmount.left) <= diffNum) {
                outerSphere.left = aux.vLineList[i] - outerSphere.width + offsetAmount.left;
                outerSphere.rLine = true;
            }
            if (outerSphere.lLine == true && outerSphere.rLine == true) break;
        }
        for (let i: number = 0, l: number = aux.hLineList.length; i < l; i++) {
            if (Math.abs(aux.hLineList[i] - mouseCoord[1] + offsetAmount.top * -1) <= diffNum) {
                outerSphere.top = aux.hLineList[i] + offsetAmount.top * -1;
                outerSphere.tLine = true;
            }
            if (Math.abs(aux.hLineList[i] - (mouseCoord[1] + outerSphere.height) + offsetAmount.top) <= diffNum) {
                outerSphere.top = aux.hLineList[i] - outerSphere.height + offsetAmount.top;
                outerSphere.bLine = true;
            }
            if (outerSphere.tLine == true && outerSphere.bLine == true) break;
        }
        for (let i: number = 0, l: number = aux.hcLineList.length; i < l; i++) {
            if (Math.abs(aux.hcLineList[i] - (mouseCoord[1] + outerSphere.height / 2)) <= diffNum) {
                outerSphere.top = aux.hcLineList[i] - outerSphere.height / 2;
                outerSphere.hcLine = true;
                break;
            }
        }
        for (let i: number = 0, l: number = aux.vcLineList.length; i < l; i++) {
            if (Math.abs(aux.vcLineList[i] - (mouseCoord[0] + outerSphere.width / 2)) <= diffNum) {
                outerSphere.left = aux.vcLineList[i] - outerSphere.width / 2;
                outerSphere.vcLine = true;
                break;
            }
        }
    }
}
復制代碼

前進和后退

關于前進與后退可以看我另一篇水文 富交互Web應用中的撤銷和前進 ;

實現原理比較簡單粗暴,就是把每一次你認為需要記錄下來的操作存一份數據到瀏覽器的 IndexedDB 里,前進就是在表里面查找最新保存的狀態并渲染,后退就是查找上一次狀態并渲染

剪貼蒙版

我特別喜歡剪貼蒙版部分,在寫它的過程當中感覺就像是做了好幾道初中數學大題!

我們先看看它的效果

它的核心其實就是依賴于一個 CSS 的屬性 clip-path

而展示出來的幾個固定剪貼蒙版本質上就是在計算組件的 clip-path 對應的不同屬性值

核心文件在 clip-path-mask.model.ts

小結

整體的搭建從架構方面來說并不復雜,生成的小程序代碼包也沒那么的神秘,其中花費時間較多的自然就是在處理各種極致交互體驗的技術細節上,在實現功能之前建好數據模型是一個良好的習慣,Panel-Magic 還有很多比較復雜的功能點,感興趣的可以去 Star 一下:wink:

鮮花
鮮花
雞蛋
雞蛋
分享至 : QQ空間
收藏
原作者: Ricbet 來自: 掘金
梦幻单人赚钱方法