Skip to content

Commit c63ac0e

Browse files
authored
Merge pull request #5075 from apostrophecms/PRO-8303-temporal-state
PRO-8303 temporal state
2 parents bcff8bf + 4a213be commit c63ac0e

5 files changed

Lines changed: 197 additions & 19 deletions

File tree

modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export default {
171171
if (!this.hasLayoutMeta) {
172172
this.onCreateProvision();
173173
}
174+
this.updateWidgetStore(this.parentOptions?.widgetId, 'layout:switch', this.layoutMode);
174175
},
175176
beforeUnmount() {
176177
apos.bus.$off('widget-breadcrumb-operation', this.executeWidgetOperation);

modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
ref="root"
44
class="apos-layout"
55
>
6-
<section
6+
<TransitionGroup
77
ref="grid"
8+
name="apos-grid"
9+
tag="section"
810
class="apos-layout__grid"
911
:class="gridClasses"
1012
data-apos-test="aposLayoutContainer"
@@ -55,7 +57,7 @@
5557
</template>
5658
</div>
5759
</div>
58-
</section>
60+
</TransitionGroup>
5961
<AposGridManager
6062
v-if="isManageMode"
6163
:grid-state="gridState"
@@ -66,6 +68,8 @@
6668
@resize-end="$emit('resize-end', $event); isResizing = false"
6769
@move-start="isMoving = true"
6870
@move-end="$emit('move-end', $event); isMoving = false"
71+
@preview-move="onPreviewMove"
72+
@preview-clear="onPreviewClear"
6973
@add-fit-item="$emit('add-fit-item', $event)"
7074
@patch-item="$emit('patch-item', $event)"
7175
/>
@@ -117,7 +121,12 @@ export default {
117121
data() {
118122
return {
119123
isResizing: false,
120-
isMoving: false
124+
isMoving: false,
125+
// Live preview from manager: patches to apply to items for render-only
126+
preview: {
127+
patches: null,
128+
key: null
129+
}
121130
};
122131
},
123132
computed: {
@@ -138,12 +147,16 @@ export default {
138147
return slots;
139148
},
140149
renderItems() {
150+
const base = this.gridState.current.items;
151+
// Apply live preview patches if present (render-only)
152+
if (Array.isArray(this.preview.patches) && this.preview.patches.length) {
153+
return this.applyPreviewPatches(base, this.preview.patches);
154+
}
141155
if (!this.syntheticItems.length) {
142-
return this.gridState.current.items;
156+
return base;
143157
}
144-
// Merge and sort by order to ensure CSS order consistency
145158
const merged = [
146-
...this.gridState.current.items,
159+
...base,
147160
...this.syntheticItems
148161
];
149162
return merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
@@ -162,6 +175,61 @@ export default {
162175
'is-moving': this.isMoving
163176
};
164177
}
178+
},
179+
methods: {
180+
onPreviewMove({ patches, key }) {
181+
if (!Array.isArray(patches) || !patches.length) {
182+
this.onPreviewClear();
183+
return;
184+
}
185+
if (this.preview.key === key) {
186+
return;
187+
}
188+
this.preview = {
189+
patches,
190+
key
191+
};
192+
},
193+
onPreviewClear() {
194+
if (this.preview.patches || this.preview.key) {
195+
this.preview = {
196+
patches: null,
197+
key: null
198+
};
199+
}
200+
},
201+
applyPreviewPatches(items, patches) {
202+
// Map items by _id for quick lookup
203+
const map = new Map(items.map((it, idx) => [ it._id, {
204+
it,
205+
idx
206+
} ]));
207+
const out = items.map(it => ({ ...it }));
208+
for (const p of patches) {
209+
const ref = map.get(p._id);
210+
if (!ref) {
211+
continue;
212+
}
213+
const target = out[ref.idx];
214+
// Only apply layout-related fields present in the patch
215+
if ('colstart' in p) {
216+
target.colstart = p.colstart;
217+
}
218+
if ('rowstart' in p) {
219+
target.rowstart = p.rowstart;
220+
}
221+
if ('colspan' in p) {
222+
target.colspan = p.colspan;
223+
}
224+
if ('rowspan' in p) {
225+
target.rowspan = p.rowspan;
226+
}
227+
if ('order' in p) {
228+
target.order = p.order;
229+
}
230+
}
231+
return out;
232+
}
165233
}
166234
};
167235
</script>
@@ -207,9 +275,19 @@ export default {
207275
208276
/* stylelint-disable-next-line media-feature-name-allowed-list */
209277
@media (prefers-reduced-motion: no-preference) {
210-
.apos-layout__grid {
211-
transition: all 300ms ease;
278+
/* TransitionGroup move/enter/leave animations for grid items */
279+
.apos-grid-move {
280+
transition: transform 200ms ease;
212281
}
213-
}
214282
283+
.apos-grid-enter-active,
284+
.apos-grid-leave-active {
285+
transition: opacity 200ms ease;
286+
}
287+
288+
.apos-grid-enter-from,
289+
.apos-grid-leave-to {
290+
opacity: 0.01;
291+
}
292+
}
215293
</style>

modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
}"
2929
>
3030
<div
31+
v-if="!hasMotion"
3132
data-shim
3233
:data-id="item._id"
3334
class="apos-layout__item-shim"
@@ -174,6 +175,9 @@ export default {
174175
'resize-end',
175176
'move-start',
176177
'move-end',
178+
// New: live preview events for layout rendering
179+
'preview-move',
180+
'preview-clear',
177181
'add-fit-item',
178182
'remove-item',
179183
'patch-item'
@@ -234,8 +238,8 @@ export default {
234238
iconSize: 11
235239
},
236240
onMoveDebounced: throttle(this.onMove, 10),
237-
onResizeDebounced: throttle(this.onResize, 10)
238-
// gridContentStyles: new Map()
241+
onResizeDebounced: throttle(this.onResize, 10),
242+
lastPreviewKey: null
239243
};
240244
},
241245
computed: {
@@ -306,9 +310,13 @@ export default {
306310
document.addEventListener('mouseup', this.onMouseUp);
307311
document.addEventListener('touchend', this.onMouseUp);
308312
313+
const rootEl = this.$parent.$refs.root?.$el || this.$parent.$refs.root;
314+
const gridRef = this.$parent.$refs.grid;
315+
const gridEl = gridRef && gridRef.$el ? gridRef.$el : gridRef;
316+
309317
this.manager.init(
310-
this.$parent.$refs.root,
311-
this.$parent.$refs.grid,
318+
rootEl,
319+
gridEl,
312320
(type, obj) => {
313321
if (type === 'resize') {
314322
this.sceneResizeIndex += 1;
@@ -325,7 +333,9 @@ export default {
325333
// without the resize observer being properly cleaned up.
326334
this.manager.onSceneResizeDebounced(entries);
327335
});
328-
this.resizeObserver.observe(this.$parent.$refs.grid);
336+
if (gridEl) {
337+
this.resizeObserver.observe(gridEl);
338+
}
329339
330340
await this.$nextTick();
331341
this.cloneCalculateIndex += 1;
@@ -408,6 +418,33 @@ export default {
408418
this.ghostDataWrite.colstart = colstart ?? this.ghostDataWrite.colstart;
409419
this.ghostDataWrite.colspan = colspan ?? this.ghostDataWrite.colspan;
410420
this.ghostDataWrite.direction = direction ?? this.ghostDataWrite.direction;
421+
422+
// Live preview of resize result when direction is known
423+
if (this.ghostDataWrite.direction && this.ghostDataWrite.id) {
424+
const newKey = `${this.ghostDataWrite.colstart}:${this.ghostDataWrite.colspan}:${this.ghostDataWrite.direction}`;
425+
if (this.lastPreviewKey !== newKey) {
426+
const patches = this.manager.performItemResize({
427+
data: this.ghostDataWrite,
428+
state: this.gridState,
429+
item: this.gridState.lookup.get(this.ghostDataWrite.id)
430+
});
431+
if (Array.isArray(patches) && patches.length) {
432+
this.$emit('preview-move', { // reuse same preview channel
433+
patches,
434+
key: newKey
435+
});
436+
this.lastPreviewKey = newKey;
437+
} else if (this.lastPreviewKey) {
438+
// No effective change, clear existing preview
439+
this.$emit('preview-clear');
440+
this.lastPreviewKey = null;
441+
}
442+
}
443+
} else if (this.lastPreviewKey) {
444+
// Lost direction (e.g., mouse returned to start), clear preview
445+
this.$emit('preview-clear');
446+
this.lastPreviewKey = null;
447+
}
411448
}
412449
},
413450
onStartMove(item, event) {
@@ -476,6 +513,36 @@ export default {
476513
if (colstart && rowstart) {
477514
this.ghostDataWrite.colstart = colstart;
478515
this.ghostDataWrite.rowstart = rowstart;
516+
// Emit preview of would-be state when snap target changes
517+
const newKey = `${colstart}:${rowstart}`;
518+
if (
519+
typeof this.ghostData.snapLeft === 'number' &&
520+
typeof this.ghostData.snapTop === 'number'
521+
) {
522+
if (this.lastPreviewKey !== newKey) {
523+
const patches = this.manager.performItemMove({
524+
data: this.ghostDataWrite,
525+
state: this.gridState,
526+
item: this.gridState.lookup.get(this.ghostDataWrite.id),
527+
precomp: this.movePrecomp
528+
});
529+
if (Array.isArray(patches) && patches.length) {
530+
this.$emit('preview-move', {
531+
patches,
532+
key: newKey
533+
});
534+
this.lastPreviewKey = newKey;
535+
} else if (this.lastPreviewKey) {
536+
// No effective change, clear existing preview
537+
this.$emit('preview-clear');
538+
this.lastPreviewKey = null;
539+
}
540+
}
541+
} else if (this.lastPreviewKey) {
542+
// Lost snapping, clear preview
543+
this.$emit('preview-clear');
544+
this.lastPreviewKey = null;
545+
}
479546
}
480547
},
481548
onMouseMove(event) {
@@ -510,6 +577,11 @@ export default {
510577
}
511578
},
512579
resetGhostData() {
580+
// Always clear any preview when ghost state resets
581+
if (this.lastPreviewKey) {
582+
this.$emit('preview-clear');
583+
this.lastPreviewKey = null;
584+
}
513585
Object.keys(this.ghostData).forEach(key => {
514586
this.ghostData[key] = typeof this.ghostData[key] === 'boolean'
515587
? false

modules/@apostrophecms/layout-widget/ui/apos/lib/grid-manager.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,21 @@ export class GridManager {
405405
const maxStartX = Math.max(1, columns - colspan + 1);
406406
const maxStartY = Math.max(1, rows - rowspan + 1);
407407

408-
// Initial nearest indices from current pixel position
409-
let c = Math.round(left / stepX) + 1;
410-
let r = Math.round(top / stepY) + 1;
408+
// Initial nearest indices with custom snap threshold.
409+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
410+
const tMoveOpt = (
411+
state?.options?.snapThresholdMove ?? state?.options?.snapThreshold ?? 0.6
412+
);
413+
const tMove = Number(tMoveOpt);
414+
const tMoveClamped = clamp(
415+
Number.isFinite(tMove) ? tMove : 0.6,
416+
0.05,
417+
0.95
418+
);
419+
const shiftX = (1 - tMoveClamped) * stepX;
420+
const shiftY = (1 - tMoveClamped) * stepY;
421+
let c = Math.floor((left + shiftX) / stepX) + 1;
422+
let r = Math.floor((top + shiftY) / stepY) + 1;
411423
c = Math.max(1, Math.min(c, maxStartX));
412424
r = Math.max(1, Math.min(r, maxStartY));
413425

@@ -470,7 +482,20 @@ export class GridManager {
470482
const columnWidth = containerRect.width / state.columns;
471483
const direction = deltaX > 0 ? 'east' : 'west';
472484
const directionCorrection = data.side === direction ? 1 : -1;
473-
const deltaColspan = Math.round(Math.abs(deltaX) / columnWidth) * directionCorrection;
485+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
486+
const tResizeOpt = (
487+
state?.options?.snapThresholdResize ?? state?.options?.snapThreshold ?? 0.5
488+
);
489+
const tResize = Number(tResizeOpt);
490+
const SNAP_THRESHOLD = clamp(
491+
Number.isFinite(tResize) ? tResize : 0.5,
492+
0.05,
493+
0.95
494+
);
495+
const deltaSteps = Math.floor(
496+
(Math.abs(deltaX) + (1 - SNAP_THRESHOLD) * columnWidth) / columnWidth
497+
);
498+
const deltaColspan = deltaSteps * directionCorrection;
474499
const desired = Math.max(
475500
state.options.minSpan,
476501
Math.min(item.colspan + deltaColspan, state.columns)

modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ export function itemsToState({
8181
const resolvedOptions = {
8282
...options,
8383
columns: meta.columns || options.columns,
84-
gap: [ 'layout', 'focus' ].includes(layoutMode) ? gap || '2px' : options.gap
84+
gap: [ 'layout', 'focus' ].includes(layoutMode) ? gap || '2px' : options.gap,
85+
snapThresholdMove: 0.7,
86+
snapThreshold: 0.5
8587
};
8688

8789
const positionsIndex = createPositionIndex(current.items, current.rows);

0 commit comments

Comments
 (0)