mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93d2263f14 | |||
| dca0868753 | |||
| 074117fe33 | |||
| a1025f4520 | |||
| 7b8bab75a8 | |||
| c0492a70cb | |||
| 05169acbfe | |||
| 086b77e464 | |||
| de613e2502 |
@@ -26,6 +26,100 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Inter 4.1
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
## Framework7 documentation placeholder media
|
||||
|
||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||
|
||||
@@ -1778,6 +1778,11 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||
>
|
||||
<div
|
||||
id="phone-home-drag-portal"
|
||||
class="phone-home-drag-portal"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||
<section
|
||||
class="phone-device"
|
||||
|
||||
@@ -92,6 +92,10 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||
expect(source).toContain('phone-resolution-canvas--primary')
|
||||
expect(source).toMatch(
|
||||
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||
)
|
||||
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||
expect(source).toContain("'--phone-rendered-height'")
|
||||
expect(source).toContain("'--phone-rendered-width'")
|
||||
expect(mainCss).toMatch(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -211,9 +211,11 @@
|
||||
}
|
||||
}
|
||||
:root {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
font-optical-sizing: auto;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -234,7 +236,9 @@ body,
|
||||
user-select: none;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
@@ -295,6 +299,18 @@ button {
|
||||
height: var(--phone-rendered-height, 844px);
|
||||
}
|
||||
|
||||
.phone-home-drag-portal {
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.phone-home-drag-portal * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.phone-resolution-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1286,8 +1302,7 @@ button {
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #f5f5f7;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.darkchat-page button,
|
||||
.darkchat-page input,
|
||||
@@ -2622,8 +2637,30 @@ button {
|
||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.springboard--home-dragging .springboard-page--apps {
|
||||
overflow: visible;
|
||||
.home-drag-layer {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-drag-layer * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.home-drag-position {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.home-drag-ghost {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.app-grid {
|
||||
display: grid;
|
||||
@@ -2658,6 +2695,9 @@ button {
|
||||
transition: transform var(--springboard-page-duration)
|
||||
var(--springboard-page-easing);
|
||||
}
|
||||
.app-icon-item--drag-source {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-icon-remove {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
@@ -2783,7 +2823,6 @@ button {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-icon--calculator {
|
||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||
@@ -7542,18 +7581,17 @@ button {
|
||||
min-height: var(--springboard-grid-row);
|
||||
}
|
||||
.app-icon-button {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.app-icon-label {
|
||||
min-height: 15px;
|
||||
font-size: 11.5px;
|
||||
min-height: var(--sky-home-label-height);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: 15px;
|
||||
line-height: var(--sky-home-label-height);
|
||||
text-align: center;
|
||||
}
|
||||
.springboard-edit-add {
|
||||
|
||||
@@ -30,11 +30,13 @@ const props = withDefaults(
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -63,14 +65,14 @@ let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--compact': compact,
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
}"
|
||||
|
||||
@@ -22,11 +22,13 @@ const props = withDefaults(
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
|
||||
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
color: #fff;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
|
||||
.home-folder-backdrop {
|
||||
|
||||
@@ -402,7 +402,7 @@ onBeforeUnmount(() => {
|
||||
rgb(30 38 42 / 35%),
|
||||
rgb(0 0 0 / 84%) 72%
|
||||
);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
|
||||
.radio-hud[data-horizontal='left'] {
|
||||
|
||||
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: blur(26px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||
cursor: pointer;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||
'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
||||
const mainCssSource = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function styleSources(directory: string): Array<{
|
||||
file: string
|
||||
source: string
|
||||
}> {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) return styleSources(path)
|
||||
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
||||
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('Inter font contract', () => {
|
||||
it('bundles normal and italic variable fonts for every supported weight', () => {
|
||||
expect(
|
||||
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
||||
).toBe(true)
|
||||
expect(
|
||||
existsSync(
|
||||
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
||||
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
||||
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
||||
})
|
||||
|
||||
it('applies the shared font to the document and native form controls', () => {
|
||||
expect(mainCssSource).toMatch(
|
||||
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
||||
)
|
||||
expect(mainCssSource).toMatch(
|
||||
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not bypass the shared token with a generic system UI stack', () => {
|
||||
const genericSystemStack =
|
||||
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
||||
const violations = styleSources(sourceDirectory)
|
||||
.filter(({ source }) => genericSystemStack.test(source))
|
||||
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
||||
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
Vendored
+2
-3
@@ -525,9 +525,8 @@
|
||||
height: var(--sky-widget-label-height);
|
||||
overflow: hidden;
|
||||
color: var(--sky-widget-label-color, #fff);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-family: var(--sky-font-family);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: var(--sky-widget-label-height);
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--sky-device-pixel-ratio: 1;
|
||||
--sky-hairline-scale: 1;
|
||||
@@ -26,9 +42,7 @@
|
||||
--sky-font-title: 17px;
|
||||
--sky-font-medium-title: 24px;
|
||||
--sky-font-large-title: 34px;
|
||||
--sky-font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--sky-font-family: 'Inter', Arial, sans-serif;
|
||||
--sky-transition-fast: 100ms;
|
||||
--sky-transition-normal: 200ms;
|
||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -55,8 +69,10 @@
|
||||
--sky-shadow-thumb:
|
||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||
--sky-home-label-font-size: 13px;
|
||||
--sky-home-label-height: 16px;
|
||||
--sky-widget-label-gap: 5px;
|
||||
--sky-widget-label-height: 15px;
|
||||
--sky-widget-label-height: var(--sky-home-label-height);
|
||||
--sky-widget-radius-small: 23px;
|
||||
--sky-widget-radius-medium: 25px;
|
||||
--sky-widget-radius-large: 28px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
|
||||
expect(() => new Function(runtime)).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the LB iframe layout contract before revealing vendor apps', () => {
|
||||
const document = createLbPhoneFrameDocument(
|
||||
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
|
||||
{
|
||||
appName: 'radio-app',
|
||||
localStorage: {},
|
||||
resourceName: 'lb-radioapp',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: 'Main phone',
|
||||
isDarkMode: true,
|
||||
language: 'en',
|
||||
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||
securityEnabled: false,
|
||||
}),
|
||||
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
|
||||
},
|
||||
)
|
||||
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
|
||||
expect(runtime).toBeTruthy()
|
||||
|
||||
const messageListeners: Array<(event: { data: unknown }) => void> = []
|
||||
const readyListeners: Array<() => void> = []
|
||||
const documentElement = { dataset: {}, style: {} }
|
||||
const body = {
|
||||
dataset: {},
|
||||
style: { visibility: 'hidden' },
|
||||
}
|
||||
const sandbox = {
|
||||
addEventListener(
|
||||
eventName: string,
|
||||
listener: (event: { data: unknown }) => void,
|
||||
) {
|
||||
if (eventName === 'message') messageListeners.push(listener)
|
||||
},
|
||||
componentsLoaded: undefined as boolean | undefined,
|
||||
console,
|
||||
document: {
|
||||
addEventListener(eventName: string, listener: () => void) {
|
||||
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
|
||||
},
|
||||
body,
|
||||
documentElement,
|
||||
},
|
||||
parent: { postMessage() {} },
|
||||
}
|
||||
|
||||
runInNewContext(runtime ?? '', sandbox)
|
||||
expect(body.style.visibility).toBe('hidden')
|
||||
|
||||
expect(readyListeners).toHaveLength(1)
|
||||
readyListeners[0]?.()
|
||||
expect(documentElement.style).toMatchObject({
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
})
|
||||
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
|
||||
expect(body.style).toMatchObject({
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
visibility: 'visible',
|
||||
width: '100%',
|
||||
})
|
||||
|
||||
body.style.visibility = 'hidden'
|
||||
expect(messageListeners).toHaveLength(1)
|
||||
messageListeners[0]?.({ data: 'componentsLoaded' })
|
||||
expect(body.style.visibility).toBe('visible')
|
||||
expect(sandbox.componentsLoaded).toBe(true)
|
||||
})
|
||||
|
||||
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
|
||||
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
|
||||
if (document.body) document.body.dataset.theme = theme;
|
||||
}
|
||||
|
||||
function prepareDocument() {
|
||||
Object.assign(document.documentElement.style, {
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%'
|
||||
});
|
||||
if (!document.body) return;
|
||||
|
||||
document.body.dataset.device = 'phone';
|
||||
Object.assign(document.body.style, {
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
visibility: 'visible',
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.resourceName = config.resourceName;
|
||||
globalThis.appName = config.appName;
|
||||
globalThis.components = globalThis.components ?? {};
|
||||
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
|
||||
|
||||
globalThis.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (message === 'componentsLoaded') {
|
||||
globalThis.componentsLoaded = true;
|
||||
prepareDocument();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.type === 'sky-phone:lb-settings') {
|
||||
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
|
||||
});
|
||||
|
||||
applySettings(config.settings);
|
||||
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
prepareDocument();
|
||||
applySettings(globalThis.settings);
|
||||
}, { once: true });
|
||||
`
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
function measuredRect(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): RectMeasurement {
|
||||
return { height, left, top, width }
|
||||
}
|
||||
|
||||
function expectRectClose(
|
||||
actual: PhoneViewportRect,
|
||||
expected: PhoneViewportRect,
|
||||
): void {
|
||||
for (const key of [
|
||||
'bottom',
|
||||
'height',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'width',
|
||||
] as const) {
|
||||
expect(actual[key]).toBeCloseTo(expected[key], 6)
|
||||
}
|
||||
}
|
||||
|
||||
function createGeometryFixture(options: {
|
||||
canvasOffsetHeight: number
|
||||
canvasOffsetWidth: number
|
||||
rawCanvasRect: RectMeasurement
|
||||
wrapperRect: RectMeasurement
|
||||
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
|
||||
const wrapper = {
|
||||
getBoundingClientRect: () => options.wrapperRect,
|
||||
} as unknown as HTMLElement
|
||||
const canvas = {
|
||||
closest: (selector: string) =>
|
||||
selector === '.phone-resolution-wrapper' ? wrapper : null,
|
||||
getBoundingClientRect: () => options.rawCanvasRect,
|
||||
offsetHeight: options.canvasOffsetHeight,
|
||||
offsetWidth: options.canvasOffsetWidth,
|
||||
} as unknown as HTMLElement
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
closest: (selector: string) =>
|
||||
selector === '.phone-resolution-canvas' ? canvas : null,
|
||||
} as unknown as Element,
|
||||
element: (rect) =>
|
||||
({ getBoundingClientRect: () => rect }) as unknown as Element,
|
||||
}
|
||||
}
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||
|
||||
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
|
||||
bottom: layer.top + layer.height,
|
||||
height: layer.height,
|
||||
left: layer.left,
|
||||
right: layer.left + layer.width,
|
||||
top: layer.top,
|
||||
width: layer.width,
|
||||
})
|
||||
})
|
||||
|
||||
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
|
||||
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
|
||||
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
|
||||
|
||||
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
|
||||
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
|
||||
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
|
||||
expect(corrected.left).toBeGreaterThan(wrapper.left)
|
||||
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80%', 0.6624],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])(
|
||||
'normalizes fractional positions, sizes, and deltas at %s scaling',
|
||||
(_label, zoom) => {
|
||||
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
|
||||
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
|
||||
const rawStart = measuredRect(
|
||||
rawCanvas.left + 20.125,
|
||||
rawCanvas.top + 50.375,
|
||||
349.75,
|
||||
73.125,
|
||||
)
|
||||
const rawEnd = measuredRect(
|
||||
rawStart.left + 73.25,
|
||||
rawStart.top - 41.75,
|
||||
rawStart.width,
|
||||
rawStart.height,
|
||||
)
|
||||
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
|
||||
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
|
||||
|
||||
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
|
||||
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
|
||||
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
|
||||
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
|
||||
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
|
||||
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
canvasOffsetWidth: 390,
|
||||
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
||||
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
||||
|
||||
expect(geometry).not.toBeNull()
|
||||
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
||||
})
|
||||
|
||||
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
||||
const rawCanvas = measuredRect(100, 50, 0, 0)
|
||||
const corrected = normalizePhoneViewportRect(
|
||||
measuredRect(112.5, 58.25, 40, 20),
|
||||
rawCanvas,
|
||||
measuredRect(500, 300, 0, 0),
|
||||
)
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 0,
|
||||
canvasOffsetWidth: 0,
|
||||
rawCanvasRect: rawCanvas,
|
||||
wrapperRect: measuredRect(500, 300, 0, 0),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
|
||||
expect(corrected).toEqual({
|
||||
bottom: 328.25,
|
||||
height: 20,
|
||||
left: 512.5,
|
||||
right: 552.5,
|
||||
top: 308.25,
|
||||
width: 40,
|
||||
})
|
||||
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
||||
expect(geometry?.scaleX).toBe(1)
|
||||
expect(geometry?.scaleY).toBe(1)
|
||||
expect(readPhoneViewportGeometry(null)).toBeNull()
|
||||
expect(
|
||||
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export type PhoneViewportRect = {
|
||||
bottom: number
|
||||
height: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
width: number
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
export type PhoneViewportGeometry = {
|
||||
readonly scaleX: number
|
||||
readonly scaleY: number
|
||||
rect(element: Element): PhoneViewportRect
|
||||
}
|
||||
|
||||
function positiveRatio(numerator: number, denominator: number): number {
|
||||
return Number.isFinite(numerator) &&
|
||||
Number.isFinite(denominator) &&
|
||||
numerator > 0 &&
|
||||
denominator > 0
|
||||
? numerator / denominator
|
||||
: 1
|
||||
}
|
||||
|
||||
export function normalizePhoneViewportRect(
|
||||
rawRect: RectMeasurement,
|
||||
rawCanvasRect: RectMeasurement,
|
||||
wrapperRect: RectMeasurement,
|
||||
): PhoneViewportRect {
|
||||
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
|
||||
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
|
||||
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
|
||||
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
|
||||
const width = rawRect.width * factorX
|
||||
const height = rawRect.height * factorY
|
||||
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
export function readPhoneViewportGeometry(
|
||||
anchor: Element | null,
|
||||
): PhoneViewportGeometry | null {
|
||||
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
|
||||
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||
if (!canvas || !wrapper) return null
|
||||
|
||||
const rawCanvasRect = canvas.getBoundingClientRect()
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
|
||||
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
|
||||
rect(element: Element): PhoneViewportRect {
|
||||
return normalizePhoneViewportRect(
|
||||
element.getBoundingClientRect(),
|
||||
rawCanvasRect,
|
||||
wrapperRect,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
|
||||
expect(delta.x).toBeCloseTo(100)
|
||||
expect(delta.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80% preview', 0.6624],
|
||||
['80% production clamp', 2 / 3],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
|
||||
const layoutWidth = 350
|
||||
const layoutHeight = 808
|
||||
const viewportLeft = 128.25
|
||||
const viewportTop = 64.5
|
||||
const viewportWidth = layoutWidth * zoom
|
||||
const viewportHeight = layoutHeight * zoom
|
||||
const pointerStart = {
|
||||
x: viewportLeft + 48.5 * zoom,
|
||||
y: viewportTop + 132.25 * zoom,
|
||||
}
|
||||
const viewportDelta = { x: 73.25, y: -41.75 }
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const end = springboardViewportToLocal(
|
||||
pointerStart.x + viewportDelta.x,
|
||||
pointerStart.y + viewportDelta.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const localDelta = springboardViewportDeltaToLocal(
|
||||
viewportDelta.x,
|
||||
viewportDelta.y,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
|
||||
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||
|
||||
export type PageTurnDirection = -1 | 0 | 1
|
||||
|
||||
export type SpringboardEdgeTurn = {
|
||||
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
|
||||
'.springboard-page, .home-folder-panel',
|
||||
)
|
||||
if (!surface) return null
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
const bounds =
|
||||
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||
surface.getBoundingClientRect()
|
||||
return {
|
||||
layoutHeight: surface.offsetHeight,
|
||||
layoutWidth: surface.offsetWidth,
|
||||
|
||||
@@ -19,11 +19,31 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokensCss = readFileSync(
|
||||
new URL('../ui/tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const foundationCss = readFileSync(
|
||||
new URL('../ui/foundation.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
|
||||
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
|
||||
expect(mainCss).toMatch(
|
||||
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||
)
|
||||
expect(foundationCss).toMatch(
|
||||
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the built-in wallpapers visually restrained', () => {
|
||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
|
||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||
})
|
||||
|
||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
||||
expect(viewSource).not.toContain(':external-drag-visual')
|
||||
it('renders the home drag visual through the unzoomed phone portal', () => {
|
||||
expect(viewSource).toContain('source.cloneNode(true)')
|
||||
expect(viewSource).toContain(
|
||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
||||
'<Teleport defer to="#phone-home-drag-portal">',
|
||||
)
|
||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(appIconSource).not.toContain('externalDragVisual')
|
||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
||||
expect(mainCss).not.toContain('.home-drag-layer')
|
||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
||||
expect(viewSource).toContain(
|
||||
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
|
||||
)
|
||||
expect(viewSource).toContain('readPhoneViewportGeometry')
|
||||
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
|
||||
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
|
||||
expect(viewSource).not.toContain('homeDragLocalPoint')
|
||||
expect(viewSource).not.toContain('springboardViewportToLocal')
|
||||
expect(viewSource).toContain("position.className = 'home-drag-position'")
|
||||
expect(viewSource).toMatch(
|
||||
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
|
||||
)
|
||||
expect(
|
||||
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||
).toHaveLength(4)
|
||||
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
|
||||
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(viewSource).not.toContain('springboard--home-dragging')
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
expect(folderIconSource).toContain('app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
||||
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toContain('.home-drag-position')
|
||||
expect(mainCss).toContain('.home-drag-ghost')
|
||||
expect(mainCss).toContain('.app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the external drag visual on every terminal path', () => {
|
||||
expect(
|
||||
viewSource.match(
|
||||
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
|
||||
),
|
||||
).toHaveLength(4)
|
||||
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
|
||||
expect(viewSource).toContain(
|
||||
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
||||
expect(viewSource).toContain(':data-home-page="page.page"')
|
||||
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
||||
expect(viewSource).toContain('nearestGridDropTarget')
|
||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
||||
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||
expect(viewSource).toContain('function homeDragViewportRect(')
|
||||
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
|
||||
expect(viewSource).toMatch(
|
||||
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'springboardBounds.left + (bounds.left - pageBounds.left)',
|
||||
)
|
||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
type HomeItem,
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||
import {
|
||||
maximumRenderedWidgetPage,
|
||||
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
const openedFolderId = ref<string | null>(null)
|
||||
const folderDraggingOutside = ref(false)
|
||||
@@ -125,6 +132,11 @@ let edgePageDirection = 0
|
||||
let edgePageLocked = false
|
||||
let folderHoverTimer: number | undefined
|
||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||
let homeDragGhost: HTMLElement | null = null
|
||||
let homeDragGrip: { x: number; y: number } | null = null
|
||||
let homeDragGeometry: PhoneViewportGeometry | null = null
|
||||
let homeDragClipBounds: PhoneViewportRect | null = null
|
||||
let homeDragPreviewBounds: PhoneViewportRect | null = null
|
||||
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
|
||||
).find((candidate) => candidate.dataset.widgetId === id)
|
||||
if (!widget) return
|
||||
pageTransitioning.value = false
|
||||
const bounds = widget.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(widget)
|
||||
draggingWidgetId.value = id
|
||||
widgetDragGrip.value = {
|
||||
x: event.clientX - bounds.left,
|
||||
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
if (!grid || !pageElement) return
|
||||
const renderedGridBounds = grid.getBoundingClientRect()
|
||||
const renderedGridBounds = homeDragViewportRect(grid)
|
||||
const gridStyle = getComputedStyle(grid)
|
||||
const scaleX =
|
||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
||||
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return null
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(springboard)
|
||||
const renderedLastPage = appPages.value.length
|
||||
return resolveSpringboardHomeEdgeTurn(
|
||||
event.clientX,
|
||||
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
|
||||
): void {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(springboard)
|
||||
const turn =
|
||||
dragType === 'app'
|
||||
? resolveHomeEdgeTurn(event)
|
||||
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
|
||||
if (configured) changePage(configured.page)
|
||||
}
|
||||
|
||||
function startHomeDrag(area: HomeArea, index: number): void {
|
||||
function homeDragViewportRect(
|
||||
element: Element,
|
||||
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
|
||||
): PhoneViewportRect {
|
||||
if (geometry) return geometry.rect(element)
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function viewportRectAt(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): PhoneViewportRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||
homeDragGhost?.remove()
|
||||
homeDragGhost = null
|
||||
homeDragGrip = null
|
||||
homeDragGeometry = null
|
||||
homeDragClipBounds = null
|
||||
homeDragPreviewBounds = null
|
||||
homeDragVisualActive.value = false
|
||||
const layer = homeDragLayer.value
|
||||
layer?.style.removeProperty('left')
|
||||
layer?.style.removeProperty('top')
|
||||
layer?.style.removeProperty('width')
|
||||
layer?.style.removeProperty('height')
|
||||
}
|
||||
|
||||
function updateHomeDragGhost(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}): void {
|
||||
const ghost = homeDragGhost
|
||||
const grip = homeDragGrip
|
||||
const clipBounds = homeDragClipBounds
|
||||
const previewBounds = homeDragPreviewBounds
|
||||
if (!ghost || !grip || !clipBounds || !previewBounds) return
|
||||
|
||||
const left = event.clientX - grip.x
|
||||
const top = event.clientY - grip.y
|
||||
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
|
||||
homeDragPreviewBounds = viewportRectAt(
|
||||
left,
|
||||
top,
|
||||
previewBounds.width,
|
||||
previewBounds.height,
|
||||
)
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null
|
||||
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||
const layer = homeDragLayer.value
|
||||
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||
|
||||
const portalBounds = portal.getBoundingClientRect()
|
||||
const clipBounds = geometry.rect(springboard)
|
||||
const sourceBounds = geometry.rect(source)
|
||||
if (
|
||||
clipBounds.width <= 0 ||
|
||||
clipBounds.height <= 0 ||
|
||||
sourceBounds.width <= 0 ||
|
||||
sourceBounds.height <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||
layer.style.top = `${clipBounds.top - portalBounds.top}px`
|
||||
layer.style.width = `${clipBounds.width}px`
|
||||
layer.style.height = `${clipBounds.height}px`
|
||||
|
||||
const ghost = source.cloneNode(true) as HTMLElement
|
||||
ghost.classList.remove(
|
||||
'app-icon-item--dragging',
|
||||
'app-icon-item--editing',
|
||||
'app-icon-item--drag-source',
|
||||
'app-icon-item--drop-settling',
|
||||
'home-folder-item--dragging',
|
||||
'home-item--folder-hover',
|
||||
)
|
||||
ghost.classList.add('home-drag-ghost')
|
||||
ghost.removeAttribute('style')
|
||||
ghost
|
||||
.querySelector<HTMLElement>('.app-icon-button')
|
||||
?.style.removeProperty('transform')
|
||||
ghost.setAttribute('aria-hidden', 'true')
|
||||
ghost.querySelector('.app-icon-remove')?.remove()
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
'a, button, input, select, textarea, [tabindex]',
|
||||
)) {
|
||||
control.tabIndex = -1
|
||||
}
|
||||
|
||||
const position = document.createElement('div')
|
||||
position.className = 'home-drag-position'
|
||||
position.style.width = `${sourceBounds.width}px`
|
||||
position.style.height = `${sourceBounds.height}px`
|
||||
ghost.style.width = `${source.offsetWidth}px`
|
||||
ghost.style.height = `${source.offsetHeight}px`
|
||||
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
|
||||
position.appendChild(ghost)
|
||||
|
||||
homeDragGhost = position
|
||||
homeDragGrip = {
|
||||
x: event.clientX - sourceBounds.left,
|
||||
y: event.clientY - sourceBounds.top,
|
||||
}
|
||||
homeDragGeometry = geometry
|
||||
homeDragClipBounds = clipBounds
|
||||
homeDragPreviewBounds = sourceBounds
|
||||
layer.appendChild(position)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
clearTemporaryHomePage()
|
||||
lastHomePointer = null
|
||||
draggingHomeApp.value = { area, index }
|
||||
createHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function clearFolderHover(): void {
|
||||
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
||||
.find((element) => element && !element.closest('.app-icon-item--dragging'))
|
||||
const targetElement = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
|
||||
)
|
||||
.filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea | undefined
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
if (
|
||||
(area !== 'grid' && area !== 'dock') ||
|
||||
!Number.isInteger(index) ||
|
||||
(area === dragged.area && index === dragged.index)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
area === 'grid' &&
|
||||
Number(
|
||||
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
|
||||
) !== phone.currentPage
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const bounds = homeDragViewportRect(element)
|
||||
return (
|
||||
event.clientX >= bounds.left &&
|
||||
event.clientX <= bounds.right &&
|
||||
event.clientY >= bounds.top &&
|
||||
event.clientY <= bounds.bottom
|
||||
)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftIsDock = left.dataset.homeArea === 'dock'
|
||||
const rightIsDock = right.dataset.homeArea === 'dock'
|
||||
return Number(rightIsDock) - Number(leftIsDock)
|
||||
})[0]
|
||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||
if (
|
||||
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
if (folderId) {
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
function moveHomeDrag(event: PointerEvent): void {
|
||||
if (!draggingHomeApp.value) return
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (resolveHomeEdgeTurn(event)) {
|
||||
clearFolderHover()
|
||||
queueEdgePageTurn(event, 'app')
|
||||
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
|
||||
item: HomeItem,
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
from: DOMRect,
|
||||
from: PhoneViewportRect,
|
||||
): Promise<void> {
|
||||
await nextTick()
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
|
||||
)
|
||||
if (!appElement) return
|
||||
|
||||
const target = appElement.getBoundingClientRect()
|
||||
const target = homeDragViewportRect(
|
||||
appElement,
|
||||
readPhoneViewportGeometry(appElement),
|
||||
)
|
||||
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
||||
from.left - target.left,
|
||||
from.top - target.top,
|
||||
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
if (!grid || !pageElement) return null
|
||||
const pageBounds = pageElement.getBoundingClientRect()
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
|
||||
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||
if (
|
||||
event.clientX < pageBounds.left ||
|
||||
event.clientX > pageBounds.right ||
|
||||
event.clientY < pageBounds.top ||
|
||||
event.clientY > pageBounds.bottom
|
||||
event.clientX < springboardBounds.left ||
|
||||
event.clientX > springboardBounds.right ||
|
||||
event.clientY < springboardBounds.top ||
|
||||
event.clientY > springboardBounds.bottom
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
slots.map((slot) => {
|
||||
const bounds = homeDragViewportRect(slot, geometry)
|
||||
return {
|
||||
height: bounds.height,
|
||||
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||
width: bounds.width,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
|
||||
}): HTMLElement | null {
|
||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||
if (!dock) return null
|
||||
const dockBounds = dock.getBoundingClientRect()
|
||||
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
|
||||
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||
if (
|
||||
event.clientX < dockBounds.left ||
|
||||
event.clientX > dockBounds.right ||
|
||||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||
)
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) return
|
||||
if (!dragged) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
updateHomeDragGhost(event)
|
||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||
const draggedElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
||||
)
|
||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||
const dockTarget = nearestDockDropTarget(event)
|
||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||
let dropArea = dragged.area
|
||||
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||
if (moved && draggedItem && dropOrigin) {
|
||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
||||
void animateHomeItemDrop(
|
||||
draggedItem,
|
||||
dropArea,
|
||||
dropIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
} else {
|
||||
clearHomeDragGhost()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
|
||||
clearFolderHover()
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
|
||||
const appElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||
)
|
||||
const moveOrigin = appElement?.getBoundingClientRect()
|
||||
const moveOrigin = appElement
|
||||
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
|
||||
: null
|
||||
const targetIndex = homeKeyboardTarget(
|
||||
appStore.homeLayout,
|
||||
area,
|
||||
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
||||
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||
const sourceBounds = sourceElement
|
||||
? homeDragViewportRect(sourceElement, geometry)
|
||||
: null
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = candidate.getBoundingClientRect()
|
||||
const closestBounds = closest.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(candidate, geometry)
|
||||
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
|
||||
pendingWidgetPointer = null
|
||||
lastWidgetPointer = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
|
||||
'springboard--dragging': dragging,
|
||||
'springboard--editing': editMode,
|
||||
'springboard--folder-open': folderOverlayVisible,
|
||||
'springboard--home-dragging': draggingHomeApp !== null,
|
||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||
},
|
||||
]"
|
||||
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="cell.folder"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(cell.folder.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Teleport defer to="#phone-home-drag-portal">
|
||||
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||
</Teleport>
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="`app-${slot.app.id}`"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(slot.app.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="slot.folder.id"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="slot.folder"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(slot.folder.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
|
||||
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
color: #f6f7f9;
|
||||
background: #07090c;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.billing-app--light {
|
||||
--billing-border: rgb(15 23 42 / 10%);
|
||||
|
||||
@@ -1156,13 +1156,7 @@ onMounted(async () => {
|
||||
padding: 47px 0 24px;
|
||||
background: var(--bg);
|
||||
color: var(--label);
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'SF Pro Display',
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
|
||||
.calendar--light {
|
||||
|
||||
@@ -2195,7 +2195,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #151613;
|
||||
color: #f8f8f4;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.citymarkt--light {
|
||||
--ink: #fff;
|
||||
|
||||
@@ -3586,7 +3586,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #12171b !important;
|
||||
color: #f7f8f4;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.feather-app--active.feather-app--light {
|
||||
--feather-panel: #f0f1ec;
|
||||
|
||||
@@ -3395,7 +3395,7 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.state,
|
||||
.light-empty,
|
||||
|
||||
@@ -1526,7 +1526,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #12171b;
|
||||
color: #f7f7f2;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.pages--light {
|
||||
--yellow: #8a6500;
|
||||
|
||||
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
|
||||
background:
|
||||
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
|
||||
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
|
||||
background:
|
||||
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
|
||||
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
|
||||
padding: 52px 14px 25px;
|
||||
color: #f5fbff;
|
||||
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
background:
|
||||
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
|
||||
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:var(--sky-font-family);user-select:none;touch-action:manipulation; }
|
||||
.flappy-app--playing { padding:0; }
|
||||
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
|
||||
|
||||
@@ -306,7 +306,7 @@ onBeforeUnmount(() => {
|
||||
background:
|
||||
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
|
||||
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: var(--sky-font-family); user-select: none; touch-action: manipulation; }
|
||||
.tower-app--playing { padding: 0; }
|
||||
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
Reference in New Issue
Block a user