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

介紹

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

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

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

技術棧

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

工做流程

1.png

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

目前源數據約定的數據格式爲shell

{
    "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 並導出bash

其中 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 以後,咱們來看看渲染組件的核心代碼部分 👇;

<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: 由 public 裏的 DraggableDirective 指令提供,用於返回鼠標事件的 movementY 和 movementX
  • nrMouseMoveOut: 由 public 裏的 MousemoveoutDirective 指令提供,用於返回鼠標的移入和移出事件監聽
  • emitMouseType: 由 public 裏的 MousemoveoutDirective 指令提供,返回鼠標是移入仍是移出事件
  • 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 的時候多選多個組件並將生成的輪廓描邊包含出多個組件,如

2.gif

其中生成輪廓的邏輯核心部分在 panelScopeEnchantmentService 裏的 handleFromWidgetListToProfileOuterSphere 方法,👇

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.tshandleLocationInsetWidget 方法裏;

/** * 根據主輪廓的位置計算輪廓內被選組件的位置 */
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.tshandleOuterSphereRotateOffsetCoord;

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 類裏的用於描述組件自己的輪廓數據類

這樣一來全部被選中的組件都有了相對於主輪廓來講的位置比例,在進行拉伸計算的時候,將組件本身的寬高和主輪廓的寬高比例保持一致,便可

4.gif

對齊輔助線生成規則

先看看對齊輔助線效果;

3.gif

用過 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 裏,前進就是在表裏面查找最新保存的狀態並渲染,後退就是查找上一次狀態並渲染

剪貼蒙版

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

咱們先看看它的效果

5.gif

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

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

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

小結

總體的搭建從架構方面來講並不複雜,生成的小程序代碼包也沒那麼的神祕,其中花費時間較多的天然就是在處理各類極致交互體驗的技術細節上,在實現功能以前建好數據模型是一個良好的習慣,Panel-Magic 還有不少比較複雜的功能點,感興趣的能夠去 Star 一下😉

相關文章
相關標籤/搜索