mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 16:07:13 +00:00
feat(gui): reverse morph — dashboard tile icons zoom into their opening window
Opening an app in dashboard mode now runs the minimize morph backwards: the window grows out of its tile's icon box (transform + opacity only, radius relaxing from the 22% squircle to the resting value) while a clone of the icon enlarges IN PLACE on its slot — capped at 2.5x, fully dissolved by ~250% growth — as the window fades in underneath on the same 450ms cubic-bezier(0.32, 0.72, 0, 1) path with the crossfade handoff mirrored (window in at 80-240ms, ghost out at 120-320ms). Both open paths get the morph, with the same visibility conditions as minimize (Apps section active, tile on the pager page in view, not phone, no reduced motion), falling back to the existing fades otherwise: - fresh launch: TabApps passes window_options.morph_from_dashboard_tile, and UIWindow morphs instead of the 70ms opening fade. Since .window is display:none from the stylesheet until later in the pipeline, the hook shows it before measuring (nothing has painted yet, so neither the early show nor the hide-back on a declined morph can flash). - un-minimize: showWindow's minimized-in-place branch morphs instead of fadeIn(150), keeping the z-index bump and 80ms focus timing. A data-window_morphing flag guards against opposing morphs fighting over the window's inline styles: minimize clicked mid-open-zoom (or a tile click mid-minimize-zoom) falls back to the pre-morph fade behavior, and each animation still restores every inline property byte-identical when it ends.
This commit is contained in:
@@ -386,7 +386,14 @@ const TabApps = {
|
||||
// spawning a duplicate instance.
|
||||
if ( self._launchingApps.has(appName) ) return;
|
||||
self._launchingApps.add(appName);
|
||||
launch_app({ name: appName, maximized: true })
|
||||
// morph_from_dashboard_tile: the new window opens by
|
||||
// morphing this tile's icon into it (see UIWindow) instead
|
||||
// of the plain opening fade.
|
||||
launch_app({
|
||||
name: appName,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
})
|
||||
.finally(() => self._launchingApps.delete(appName));
|
||||
}
|
||||
});
|
||||
|
||||
+206
-7
@@ -654,7 +654,32 @@ async function UIWindow (options) {
|
||||
$(el_window).focusWindow();
|
||||
}
|
||||
|
||||
if ( window.animate_window_opening ) {
|
||||
// A launch from a dashboard Apps-tab tile (TabApps passes
|
||||
// morph_from_dashboard_tile) morphs the tile's icon into the opening
|
||||
// window — the reverse of hideWindow's minimize morph. The window is
|
||||
// already appended, displayed, and at its final (maximized) geometry
|
||||
// here, and nothing has painted since it was appended, so the morph's
|
||||
// collapsed start state applies without a flash of the full window.
|
||||
// Falls through to the standard opening fade when the tile isn't
|
||||
// visible (other tab, other pager page, phone, reduced motion).
|
||||
let morphed_from_tile = false;
|
||||
if ( options.morph_from_dashboard_tile && window.animate_window_opening
|
||||
&& options.is_visible && ! options.fadeIn && ! isMobile.phone
|
||||
&& ! (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) ) {
|
||||
const tile = dashboard_tile_in_view(options.app);
|
||||
if ( tile ) {
|
||||
// .window is display:none from the stylesheet until the show()
|
||||
// further down; the morph needs the window laid out to measure
|
||||
// it, so show it now — nothing has painted yet, so neither the
|
||||
// early show nor the hide-back on failure ever flashes.
|
||||
const was_hidden = ! $(el_window).is(':visible');
|
||||
if ( was_hidden ) $(el_window).show();
|
||||
morphed_from_tile = morph_window_from_tile(el_window, tile);
|
||||
if ( ! morphed_from_tile && was_hidden ) $(el_window).hide();
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! morphed_from_tile && window.animate_window_opening ) {
|
||||
// animate window opening
|
||||
$(el_window).css({
|
||||
'opacity': '0',
|
||||
@@ -3823,17 +3848,40 @@ $.fn.showWindow = async function (options) {
|
||||
const el_window = this;
|
||||
|
||||
// A window minimized with no taskbar to animate toward (dashboard
|
||||
// mode) was simply hidden in place by hideWindow — its geometry
|
||||
// was never disturbed, so just un-hide it. (The explicit flag is
|
||||
// used because data-orig-* can't distinguish the two minimize
|
||||
// paths: drag/maximize handlers set it too.)
|
||||
// mode) was hidden in place by hideWindow — its geometry was
|
||||
// never disturbed. If the app's tile is visible on the Apps tab's
|
||||
// current page, grow the window back out of it (the reverse of
|
||||
// hideWindow's zoom-to-tile morph); otherwise just fade it back
|
||||
// in as before. (The explicit flag is used because data-orig-*
|
||||
// can't distinguish the two minimize paths: drag/maximize
|
||||
// handlers set it too.)
|
||||
if ( $(el_window).attr('data-minimized_in_place') === '1' ) {
|
||||
$(el_window).attr({
|
||||
'data-is_minimized': false,
|
||||
'data-minimized_in_place': '0',
|
||||
});
|
||||
$(el_window).fadeIn(150);
|
||||
$(el_window).css('z-index', ++window.last_window_zindex);
|
||||
const reduce_motion = window.matchMedia
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
// Skip the morph while another morph still owns the window's
|
||||
// inline styles (reopen mid-minimize-zoom): clearing the
|
||||
// flags above already makes that animation's cleanup leave
|
||||
// the window visible and restored, same as before.
|
||||
const tile = (reduce_motion || isMobile.phone
|
||||
|| $(el_window).attr('data-window_morphing') === '1')
|
||||
? null
|
||||
: dashboard_tile_in_view($(el_window).attr('data-app'));
|
||||
let morphed = false;
|
||||
if ( tile ) {
|
||||
// The morph needs the window displayed and laid out to
|
||||
// measure it; no paint happens between this show() and
|
||||
// the morph's start state, so nothing flashes.
|
||||
const was_hidden = ! $(el_window).is(':visible');
|
||||
if ( was_hidden ) $(el_window).show();
|
||||
morphed = morph_window_from_tile(el_window, tile);
|
||||
if ( ! morphed && was_hidden ) $(el_window).hide();
|
||||
}
|
||||
if ( ! morphed ) $(el_window).fadeIn(150);
|
||||
setTimeout(() => {
|
||||
$(this).focusWindow();
|
||||
}, 80);
|
||||
@@ -4001,6 +4049,150 @@ function dashboard_tile_in_view (app_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse of hideWindow's zoom-to-tile morph: the tile's icon flies out
|
||||
* and inflates into the window while the window fades in — the same two
|
||||
* congruent layers on the same 450ms decelerating path, run backwards, so
|
||||
* opening reads as the icon becoming the window. See hideWindow's morph for
|
||||
* the mechanics (congruent layers, contain-fit ghost, compositor-only
|
||||
* animation); this mirrors it constant for constant.
|
||||
*
|
||||
* The window must be in the DOM, displayed, and at its final geometry —
|
||||
* geometry is never touched, and every inline property this sets is restored
|
||||
* byte-identical when the animation ends. Returns true if the morph started;
|
||||
* false if either box was unmeasurable (caller falls back to its fade).
|
||||
*/
|
||||
function morph_window_from_tile (el_window, tile) {
|
||||
const icon = tile.querySelector('.myapps-tile-icon') || tile;
|
||||
const win_rect = el_window.getBoundingClientRect();
|
||||
const icon_rect = icon.getBoundingClientRect();
|
||||
if ( win_rect.width <= 0 || win_rect.height <= 0
|
||||
|| icon_rect.width <= 0 || icon_rect.height <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $win = $(el_window);
|
||||
// Everything touched is restored from these exact inline values once
|
||||
// the animation ends.
|
||||
const orig_style = {
|
||||
transform: el_window.style.transform,
|
||||
transformOrigin: el_window.style.transformOrigin,
|
||||
opacity: el_window.style.opacity,
|
||||
borderRadius: el_window.style.borderRadius,
|
||||
overflow: el_window.style.overflow,
|
||||
willChange: el_window.style.willChange,
|
||||
pointerEvents: el_window.style.pointerEvents,
|
||||
transition: el_window.style.transition,
|
||||
};
|
||||
const icon_orig_visibility = icon.style.visibility;
|
||||
// Unlike the minimize morph (whose end states are inline values it
|
||||
// sets itself), this direction ends on the window's RESTING look, which
|
||||
// lives in the stylesheet — transitions need explicit end values, so
|
||||
// read the computed radius up front (the inline value goes back at the
|
||||
// end either way).
|
||||
const end_radius = getComputedStyle(el_window).borderRadius;
|
||||
const end_transform = orig_style.transform || 'none';
|
||||
|
||||
// Same mapping as the minimize morph, driven backwards: the window
|
||||
// starts collapsed onto the icon's box...
|
||||
const scale_x = icon_rect.width / win_rect.width;
|
||||
const scale_y = icon_rect.height / win_rect.height;
|
||||
const dx = icon_rect.left - win_rect.left;
|
||||
const dy = icon_rect.top - win_rect.top;
|
||||
|
||||
// ...and the ghost simply enlarges IN PLACE on the real icon's slot,
|
||||
// dissolving as the window grows out from underneath it. Unlike the
|
||||
// minimize morph's contain-fit flight (where the huge ghost only
|
||||
// becomes visible once it has shrunk near icon size), this ghost is
|
||||
// visible from frame one — flown toward the window it visibly drifts
|
||||
// away from its slot, and grown to full contain-fit (easily 10x+) it
|
||||
// reads as a giant blurry sticker. So it stays put and its growth is
|
||||
// capped: it fades out completely by ~97% of the decelerating path,
|
||||
// right around 250% of its size, and the window alone carries the
|
||||
// motion and the rest of the growth.
|
||||
const ghost = icon.cloneNode(true);
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.left = `${icon_rect.left}px`;
|
||||
ghost.style.top = `${icon_rect.top}px`;
|
||||
ghost.style.width = `${icon_rect.width}px`;
|
||||
ghost.style.height = `${icon_rect.height}px`;
|
||||
ghost.style.margin = '0';
|
||||
// +2, not +1: showWindow's delayed focusWindow() bumps the window's
|
||||
// z-index once more mid-flight, and the ghost must stay above it.
|
||||
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 2;
|
||||
ghost.style.pointerEvents = 'none';
|
||||
// 'center' so the scale below enlarges the ghost in place around its
|
||||
// own slot (the minimize morph's ghost uses 'top left' because it
|
||||
// translates as it flies; this one never moves).
|
||||
ghost.style.transformOrigin = 'center';
|
||||
ghost.style.willChange = 'transform, opacity';
|
||||
ghost.style.opacity = '1';
|
||||
ghost.style.transform = 'none';
|
||||
const ghost_scale = Math.min(
|
||||
2.5,
|
||||
win_rect.width / icon_rect.width,
|
||||
win_rect.height / icon_rect.height,
|
||||
);
|
||||
document.body.appendChild(ghost);
|
||||
icon.style.visibility = 'hidden';
|
||||
|
||||
$win.attr('data-window_morphing', '1');
|
||||
el_window.style.transition = 'none';
|
||||
el_window.style.transformOrigin = 'top left';
|
||||
el_window.style.overflow = 'hidden'; // let the radius clip the content
|
||||
el_window.style.willChange = 'transform, opacity';
|
||||
el_window.style.pointerEvents = 'none';
|
||||
el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`;
|
||||
// Percentage radius stays proportional under the scale — ~22% is the
|
||||
// icon-squircle look.
|
||||
el_window.style.borderRadius = '22%';
|
||||
el_window.style.opacity = '0';
|
||||
// Flush the starting state (both layers) so the transitions animate
|
||||
// from it rather than snapping.
|
||||
void el_window.offsetWidth;
|
||||
|
||||
// The minimize morph's handoff, mirrored: the incoming layer (here the
|
||||
// window) fades in at 80→240ms, the outgoing ghost lingers to 120→320ms,
|
||||
// and both ride the same decelerating path — so there is never a hole
|
||||
// and the swap is invisible: the icon "becomes" the window.
|
||||
el_window.style.transition = [
|
||||
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'opacity 0.16s ease-in 0.08s',
|
||||
].join(', ');
|
||||
el_window.style.transform = end_transform;
|
||||
el_window.style.borderRadius = end_radius;
|
||||
el_window.style.opacity = '1';
|
||||
ghost.style.transition = [
|
||||
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'opacity 0.2s ease-out 0.12s',
|
||||
].join(', ');
|
||||
ghost.style.transform = `scale(${ghost_scale})`;
|
||||
ghost.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
// End of the zoom: the ghost has dissolved over the window's
|
||||
// center — drop it, un-hide the real icon, and put every touched
|
||||
// inline property back (the end states equal the resting computed
|
||||
// values, so nothing visibly changes).
|
||||
ghost.remove();
|
||||
icon.style.visibility = icon_orig_visibility;
|
||||
$win.attr('data-window_morphing', '0');
|
||||
el_window.style.transition = 'none';
|
||||
el_window.style.transform = orig_style.transform;
|
||||
el_window.style.transformOrigin = orig_style.transformOrigin;
|
||||
el_window.style.opacity = orig_style.opacity;
|
||||
el_window.style.borderRadius = orig_style.borderRadius;
|
||||
el_window.style.overflow = orig_style.overflow;
|
||||
el_window.style.willChange = orig_style.willChange;
|
||||
el_window.style.pointerEvents = orig_style.pointerEvents;
|
||||
setTimeout(() => {
|
||||
el_window.style.transition = orig_style.transition;
|
||||
}, 50);
|
||||
}, 480);
|
||||
return true;
|
||||
}
|
||||
|
||||
// hides a window
|
||||
$.fn.hideWindow = async function (options) {
|
||||
$(this).each(async function () {
|
||||
@@ -4019,7 +4211,12 @@ $.fn.hideWindow = async function (options) {
|
||||
const $win = $(this);
|
||||
const reduce_motion = window.matchMedia
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const tile = (reduce_motion || isMobile.phone)
|
||||
// (A morph already in flight — either direction — owns the
|
||||
// window's inline transform/opacity and will restore them at
|
||||
// its end, so don't start another on top; fall back to the
|
||||
// plain fade below, exactly as before the morphs existed.)
|
||||
const tile = (reduce_motion || isMobile.phone
|
||||
|| $win.attr('data-window_morphing') === '1')
|
||||
? null
|
||||
: dashboard_tile_in_view($win.attr('data-app'));
|
||||
|
||||
@@ -4100,6 +4297,7 @@ $.fn.hideWindow = async function (options) {
|
||||
$win.attr({
|
||||
'data-is_minimized': true,
|
||||
'data-minimized_in_place': '1',
|
||||
'data-window_morphing': '1',
|
||||
});
|
||||
el_window.style.transformOrigin = 'top left';
|
||||
el_window.style.overflow = 'hidden'; // let the radius clip the content
|
||||
@@ -4141,6 +4339,7 @@ $.fn.hideWindow = async function (options) {
|
||||
// restore but leave it visible.
|
||||
ghost.remove();
|
||||
icon.style.visibility = icon_orig_visibility;
|
||||
$win.attr('data-window_morphing', '0');
|
||||
const minimized = $win.attr('data-is_minimized');
|
||||
if ( minimized === '1' || minimized === 'true' ) $win.hide();
|
||||
el_window.style.transition = 'none';
|
||||
|
||||
Reference in New Issue
Block a user