@libs-ui/services-grid-layout
v0.2.357-31
Published
Mặt phẳng kéo–thả. Thư viện lo **toàn bộ** việc vẽ lưới, kéo thả, đổi kích thước, lồng container, tay cầm kéo, bộ nút trên khối, viền chế độ sửa, và dọn dẹp khi component chết.
Downloads
1,460
Readme
@libs-ui/services-grid-layout
Mặt phẳng kéo–thả. Thư viện lo toàn bộ việc vẽ lưới, kéo thả, đổi kích thước, lồng container, tay cầm kéo, bộ nút trên khối, viền chế độ sửa, và dọn dẹp khi component chết.
Nơi dùng chỉ đưa cây dữ liệu và cách phân giải component nội dung. Component nội dung của bạn chỉ vẽ phần nội dung — không phải tự lo kích thước, không phải tự vẽ viền chế độ sửa.
Cài
Lib dùng angular-gridster2 qua alias npm để chạy song song nhiều bản (giống cách repo đang chạy
quill 1.3.7 và quill2x 2.0.3):
// package.json của nơi dùng
"gridster18": "npm:[email protected]"Nơi dùng đang có angular-gridster2 bản khác thì không bị đụng — hai bản nằm ở hai thư mục
node_modules riêng.
Chạy Jest thì phải map cả hai tên, vì package tự khai name: angular-gridster2:
// jest.config.ts của nơi dùng
moduleNameMapper: {
'^gridster18$': '<rootDir>/../../node_modules/gridster18/fesm2022/angular-gridster2.mjs',
'^angular-gridster2$': '<rootDir>/../../node_modules/gridster18/fesm2022/angular-gridster2.mjs',
}Dùng
@Component({
imports: [LibsUiGridLayoutCanvasComponent],
// 🔴 Khai trong providers của CHÍNH component — service theo từng mặt phẳng, không phải root.
providers: [LibsUiGridLayoutService],
template: `<libs_ui-services-grid_layout-canvas
(outChange)="handlerChange($event)"
(outRemoveNode)="handlerRemoveNode($event)"
(outAddBlockInside)="handlerAddInside($event)"
(outToggleType)="handlerToggleType($event)" />`,
})
export class MyPageComponent {
private readonly gridLayout = inject(LibsUiGridLayoutService);
constructor() {
this.gridLayout.init({
data: layoutFromServer(), // cây khối, thường lấy từ backend
getComponentOutlet: resolveNodeComponent,
mode: E_gridLayout_mode_string.grid,
isEditMode: true,
});
}
// KHÔNG cần ngOnDestroy — thư viện tự dọn qua DestroyRef.
}Không truyền [node] — init() đã giữ cây. Input node/depth của canvas chỉ dành cho lưới
lồng bên trong thư viện.
Phân giải component theo từng khối
Mỗi khối tự quyết định component của nó, đọc từ node.data (dữ liệu hình dạng server). Trả
Observable nên import() động dùng được ngay — mỗi loại khối là một chunk riêng:
export const resolveNodeComponent = (node: T_gridLayout_node): Observable<Type<unknown>> => {
switch ((node.data as T_serverElement | undefined)?.type) {
case E_serverElement_type_string.chart:
return from(import('./chart-card.component').then((c) => c.ChartCardComponent));
case E_serverElement_type_string.table:
return from(import('./table-card.component').then((c) => c.TableCardComponent));
default:
return from(import('./sample-card.component').then((c) => c.SampleCardComponent));
}
};Khai ở config.getComponentOutlet cho cả mặt phẳng, hoặc ở node.getComponentOutlet cho riêng một
khối (khối tự khai thì thắng).
Component nội dung
Khai đủ bốn input — thư viện setInput đúng bốn tên này, thiếu một cái là lỗi lúc chạy mà build
không bắt được:
@Component({ changeDetection: ChangeDetectionStrategy.OnPush, /* … */ })
export class MyCardComponent {
readonly data = input.required<T_myData>(); // = node.data — thường chỉ cần cái này
readonly node = input.required<T_gridLayout_node>();
readonly isEditMode = input<boolean>(false);
readonly depth = input<number>(0);
}Type sẵn có: T_gridLayout_nodeInputs<TData>.
Nút sẵn có bắn event kèm callback
Xoá · gộp · tách · thêm-khối-bên-trong là nút của thư viện. Bấm vào, thư viện chưa làm gì — nó
bắn event mang theo confirm(). Nghiệp vụ cần hỏi xác nhận thì mở modal rồi mới gọi; không cần thì
gọi luôn; không cho phép thì đừng gọi:
protected handlerRemoveNode(event: T_gridLayout_actionEvent): void {
if (!event.node.children?.length) {
event.confirm(); // khối đơn — xoá luôn
return;
}
this.pendingRemove.set(event); // khối ghép — hỏi trước, confirm() gọi sau
}
protected handlerAddInside(event: T_gridLayout_addEvent): void {
// confirm nhận khối mới, trả về khối đã tạo kèm id thư viện sinh
const added = event.confirm({ cols: 3, rows: 2, data: { type: 'metric' } });
}| Output | Khi nào bắn | confirm() |
|---|---|---|
| outChange | người dùng kéo / đổi kích thước — chỉ thao tác thật của người dùng | — |
| outRemoveNode | bấm nút xoá | () => boolean |
| outToggleType | bấm gộp (khối đơn → container) hoặc tách | () => boolean |
| outAddBlockInside | bấm + trên container | (item) => node \| undefined |
| outDropNode | vừa thả một thẻ kéo từ ngoài vào | — (khối đã chèn rồi) |
| outSelectNode | bấm chọn một khối | — |
| outConfigNode | bấm nút bánh răng trên khối | () => true |
| outZoomChange | tỷ lệ thu phóng vừa đổi | — |
outChange không bắn khi chính bạn gọi addBlock/removeBlock/compact — bạn đã biết mình vừa
làm gì, khỏi phải chống dội.
Hai chế độ
| Chế độ | Khối bám lưới | Chồng lên nhau | Dùng khi |
|---|---|---|---|
| grid | có | chỉ khi allowOverlap bật và hai khối khác layerIndex | dashboard, bố cục dạng bảng |
| free | không, toạ độ px | tự do | bố cục kiểu canvas — engine chưa dựng, hiện vẫn chạy trên lưới |
API
LibsUiGridLayoutService — một thực thể cho mỗi mặt phẳng.
| Thành viên | Việc |
|---|---|
| init(config) | dựng mặt phẳng; gọi lại sẽ dọn sạch rồi dựng lại. Tự san phẳng container lồng quá trần |
| setEditMode(value) | bật/tắt chế độ sửa |
| setAllowOverlap(value) | cho phép xếp chồng; tự gán layerIndex. KHÔNG dựng lại component nào |
| refresh(event?) | báo cây vừa đổi từ bên ngoài — hiếm khi cần |
| addBlock(item, parentId?) | thêm khối; bỏ parentId là thêm ở gốc. Trả khối vừa tạo (kèm id) |
| removeBlock(id) | bỏ khối; xoá container là xoá cả ruột |
| toContainer(id) · toSingle(id) | gộp thành container / tách về khối đơn — không mất dữ liệu |
| compact() | dồn khối lên, bỏ khoảng trống. Đệ quy vào container |
| findBlock(id) · findParentBlock(id) | tra cây |
| exportLayout() | cây hiện tại đã bỏ hàm, cloneDeep — gửi thẳng lên backend được |
| insertBlockWithCollision(item, col, row, parentId?) | chèn vào ĐÚNG ô và đẩy khối va chạm xuống. addBlock thì tự tìm hàng trống kế tiếp |
| duplicateBlock(id, transformData?) | nhân bản; transformData để bạn tự đổi dữ liệu của mình (thư viện không đoán trường nào là tiêu đề) |
| resizeBlock(id, { cols, rows }) | đổi kích thước bằng code; rộng quá mép phải thì tự dịch sang trái |
| setDragPayload(p) · clearDragPayload() | báo cho canvas biết đang kéo thẻ gì từ palette ngoài vào |
| setZoom(v) · zoomIn() · zoomOut() · resetZoom() · fitToScreen(w) | thu phóng |
| Root · IsEditMode · Version · Zoom | signal chỉ đọc |
| totalColumns | số cột thật của lưới cấp trang (mặc định 12) |
Mọi hàm sửa cây tự vẽ lại — không phải gọi refresh() theo sau.
Thư viện tự dọn qua DestroyRef — destroy() là private, CẤM gọi trong ngOnDestroy.
KHÔNG tự đặt id cho khối: addBlock sinh bằng uuid() và trả về khối vừa tạo. Tự đặt là mời
trùng id, mà trùng thì findBlock trả nhầm khối và cây hỏng im lặng.
LibsUiGridLayoutTreeService cố ý không xuất: mọi thao tác trên cây đã có trên service với chữ ký
gọn hơn (không phải truyền root — thư viện đang giữ nó). Cấu hình gridster cũng không xuất, xem lý
do ở mục dưới.
Lưu và mở lại bố cục
UtilsCache.Set(KEY, this.gridLayout.exportLayout(), UtilsCache.CACHE_EXPIRE_NONE);
// mở lại
this.gridLayout.init({ data: UtilsCache.Get(KEY) ?? defaultLayout(), getComponentOutlet });exportLayout() đã lược getComponentOutlet (hàm không tuần tự hoá được) nên JSON.stringify được
ngay. Mở lại chỉ cần đưa getComponentOutlet vào init — cây không mang hàm.
Kéo thẻ từ palette bên ngoài vào
Nơi dùng chỉ làm hai việc ở thẻ palette; canvas lo phần còn lại.
protected handlerPaletteDragStart(event: DragEvent, item: T_paletteItem): void {
this.gridLayout.setDragPayload({
type: item.type,
title: item.label, // chữ trên khung xem trước
cols: item.cols, // thư viện đo khung xem trước theo hai số này
rows: item.rows,
// Khối THẬT sẽ tạo. `isInside` = con trỏ đang trong một khối ghép ⇒ thu cho vừa ruột
// container (lưới con luôn 12 cột).
resolveNode: (isInside: boolean) => ({
cols: isInside ? 12 : item.cols,
rows: isInside ? 4 : item.rows,
data: { ...buildBlockData(item), label: '' }, // nhãn TRỐNG — đánh số ở `outDropNode`
}),
});
event.dataTransfer?.setData('text/plain', item.label); // thiếu dòng này Firefox không kéo được
}
protected handlerPaletteDragEnd(): void {
this.gridLayout.clearDragPayload(); // người dùng thả RA NGOÀI canvas thì thư viện không bắn gì
}<div draggable="true"
(dragstart)="handlerPaletteDragStart($event, item)"
(dragend)="handlerPaletteDragEnd()">…</div>
<libs_ui-services-grid_layout-canvas (outDropNode)="handlerDropNode($event)" />🔴 CẤM tự viết (dragover) / (drop) trên canvas. Canvas đã làm: vẽ khung xem trước bám ô lưới,
bật ô li của container khi con trỏ đi vào trong rồi tắt đúng lúc, chèn khối qua
insertBlockWithCollision và đẩy khối va chạm xuống.
🔴 resolveNode phải THUẦN. Thư viện gọi nó ở mỗi nhịp dragover chỉ để ĐO khung xem trước.
Cấm tăng bộ đếm, sinh id, gọi API bên trong — đánh số khối thì đánh ở outDropNode.
🔴 outDropNode không có confirm() — lúc nó bắn thì khối đã chèn rồi. Chặn trước thì chặn ở
dragstart; gỡ sau thì removeBlock(event.node.id). event.parentId khác undefined ⇒ người dùng
thả vào ruột một khối ghép.
LibsUiGridLayoutDragController là lớp tuỳ chọn cho trường hợp bạn tự dựng bề mặt thả riêng
ngoài canvas. Dùng canvas sẵn có thì không cần đụng tới.
Thu phóng và Stage
this.gridLayout.init({
getComponentOutlet,
data: layoutFromServer(),
enableZoom: true,
stageWidth: 1400, // 12 cột chia trên con số NÀY, không phải bề rộng cửa sổ
stageMinHeight: 900,
zoomMin: 0.4,
zoomMax: 1.5,
storageKey: 'bao_cao_ban_hang_zoom', // 🔴 RIÊNG từng màn
autoFitOnLoad: true,
});Bật enableZoom là canvas dựng một stage khổ cố định rồi phóng to/thu nhỏ cả stage — bố cục
nhìn giống nhau trên mọi khổ màn. Không bật thì 12 cột chia theo bề rộng cửa sổ, mỗi máy một kích
thước khối.
🔴 storageKey phải riêng từng màn — dùng chung thì hai màn ghi đè tỷ lệ của nhau.
Canvas có sẵn cụm nút thu phóng ở góc; muốn đặt nút chỗ khác thì [showZoomDock]="false" rồi gọi
zoomIn() · zoomOut() · resetZoom() · setZoom(v) · fitToScreen(width).
Input của canvas
| Input | Mặc định | Việc |
|---|---|---|
| [(selectedNodeId)] | undefined | khối đang chọn — hai chiều, thư viện ghi id vào signal của bạn |
| [(zoom)] | undefined | tỷ lệ thu phóng — hai chiều |
| [showGridLines] | false | vẽ ô li của lưới (lúc KÉO thư viện tự bật rồi tự tắt) |
| [showZoomDock] | true | cụm nút thu phóng ở góc canvas |
| [enableExternalDrop] | true | cho thả thẻ kéo từ ngoài vào |
| [hiddenToolbar] | false | ẩn hẳn bộ nút mặc định trên khối |
| [templateToolbar] | undefined | tự vẽ thanh công cụ của khối |
🔴 node và depth là input NỘI BỘ — thư viện tự truyền khi vẽ lưới con. Mặt phẳng gốc đọc cây
từ service (đã đưa vào qua init()), truyền thêm là khai hai lần cùng một thứ.
<libs_ui-services-grid_layout-canvas [templateToolbar]="toolbar" />
<ng-template #toolbar let-node
let-isContainer="isContainer" let-canAddInside="canAddInside"
let-remove="remove" let-addInside="addInside" let-toggleType="toggleType">
@if (canAddInside) { <button (click)="addInside()">+</button> }
<button (click)="toggleType()">{{ isContainer ? 'Tách' : 'Gộp' }}</button>
<button (click)="remove()">Xoá</button>
</ng-template>🔴 templateToolbar đổi giao diện, không đổi luật: ba hàm trong context chỉ phát đúng sự
kiện (outRemoveNode · outAddBlockInside · outToggleType), y như bấm nút mặc định. Vẫn phải bắt
sự kiện rồi gọi event.confirm().
Vì sao cấu hình gridster lại như vậy
defines/gridster-config.define.ts giữ toàn bộ cấu hình đã vá. Mỗi dòng có dấu 🔴 tương ứng một lỗi
đã đo được trên sản phẩm thật — đổi nó là lỗi sống lại:
| Cấu hình | Bỏ đi thì gặp lỗi gì |
|---|---|
| relative trên khối cuộn | offsetParent sai → gridster tính ô lệch ~49px, ô chờ đặt không theo con trỏ |
| compactType: None | chỗ vừa nhường bị hút lấp lại → kéo mép bị hoàn nguyên, khối kéo treo lơ lửng |
| pushItems: true | khối lệch kích thước không swap được, cũng không nhường đường → bế tắc |
| delayStart: 160 | bấm chọn một khối là nó nhích theo dù chuột đứng yên |
| dragHandleClass riêng cho lưới con | kéo khối con nhấc luôn container |
| maxItemRows/Cols/Area nới | khối bị chặn chiều cao (trần diện tích 2500 khó thấy nhất) |
| tay cầm resize ở 0, không âm | overflow: hidden cắt mất nửa tay cầm → kéo mép không ăn |
| setGridSize: false ở lưới lồng | vòng lặp đo–ghi, lưới con co dần về 64×64px |
| disableScroll* ở lưới lồng | khối con bị đẩy ra ngoài container rồi kẹt |
| layerIndex gán vào cây ĐANG VẼ | gán vào config.data thì z-index đứng ở 1 — cây đang vẽ là bản clone |
Kiểm
npx nx lint services-grid-layout
npx nx test services-grid-layout
npx nx build services-grid-layout