* fix(android): 修复移动端设置与润色页排版及底栏遮挡 (#960) 设置弹窗 z-index 低于底栏导致底部控件无法点击;润色风格/市场页与 Android 权限区补齐 mobile 布局。 Co-authored-by: Cursor <cursoragent@cursor.com> * feat(ui): 通用设置新增易读布局,大字号小屏防溢出换行 新增 stackedRowLayout 偏好与 useLayoutStack;设置外观区可手动开启同行控件换行,覆盖老人机等极限场景。关联 #960。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 安全查询悬浮窗权限,修复 HyperOS canDrawOverlays 闪退 WebView IPC 线程不再直接 JNI 调用 Settings.canDrawOverlays,改走 Kotlin 主线程桥接并捕获异常,避免 Redmi/HyperOS 在权限轮询时崩溃。 Co-authored-by: Cursor <cursoragent@cursor.com> * feat(ui): 保守排版开关与易读布局 min-width 增强 新增 conservativeLayout 偏好与 ol-conservative-scope 单列满宽规则;易读布局补充 min-width 换行;设置与主要页面支持双开关独立并存。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): can_draw_overlays JNI 生命周期以通过 Android CI 编译 为 env 与 context 绑定同一 lifetime,满足 call_static_bool_with_context_class 的借用约束。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 恢复 AccessibilityService 对 Settings.Secure 的 import isEnabled 仍使用 Settings.Secure 查询无障碍状态,与 canDrawOverlays 移除的 import 无关。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ui): 将易读/保守排版开关独立为「布局」区块并置顶 原开关藏在「外观」卡片深处不易发现;现于设置→通用页录音输入下方展示独立「布局」卡片。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ui): 统一易读与保守排版布局规则 --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a19830d312
commit
ec33bd5dd2
40 files changed
+1394
-148
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Icon } from '../../../src/components/Icon';
|
||||
@@ -6,6 +6,7 @@ import { getSettings, setSettings } from '../../../src/lib/ipc/settings';
|
||||
import type { UserPreferences } from '../../../src/lib/types';
|
||||
import { Btn, Pill } from '../../../src/pages/_atoms';
|
||||
import { SettingRow } from '../../../src/pages/settings/shared';
|
||||
import { useLayoutStack, useConservativeLayout } from '../../../src/lib/useMobileLayout';
|
||||
import {
|
||||
getAndroidAccessibilityStatus,
|
||||
getAndroidOverlayStatus,
|
||||
@@ -73,6 +74,9 @@ interface AndroidPermissionsPanelProps {
|
||||
|
||||
export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const layoutStack = baseLayoutStack || conservative;
|
||||
const [androidOverlay, setAndroidOverlay] = useState<AndroidOverlayStatus | null>(null);
|
||||
const [androidAccessibility, setAndroidAccessibility] = useState<AndroidAccessibilityStatus | null>(null);
|
||||
const [androidShizuku, setAndroidShizuku] = useState<AndroidShizukuStatus | null>(null);
|
||||
@@ -261,13 +265,31 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
const showAccessibility = mode === 'all' || mode === 'accessibility';
|
||||
const showOverlayConfig = mode === 'all' || mode === 'overlayConfig';
|
||||
|
||||
const rowJustify = layoutStack ? 'flex-start' : 'flex-end';
|
||||
const colAlign = layoutStack ? 'stretch' : 'flex-end';
|
||||
const hintAlign = layoutStack ? 'left' : 'right';
|
||||
const hintMaxWidth = layoutStack ? undefined : 300;
|
||||
const hintMaxWidthSm = layoutStack ? undefined : 220;
|
||||
const selectStyle = layoutStack
|
||||
? { width: '100%', maxWidth: '100%' as const }
|
||||
: { minWidth: 180, maxWidth: '100%' as const };
|
||||
const actionRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: conservative ? 'column' : 'row',
|
||||
gap: 8,
|
||||
alignItems: conservative ? 'flex-start' : layoutStack ? 'flex-start' : 'center',
|
||||
justifyContent: rowJustify,
|
||||
width: '100%', flexWrap: conservative ? 'nowrap' : 'wrap', minWidth: 0,
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{showOverlayPermission && (
|
||||
<SettingRow label={t('settings.permissions.androidOverlayLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div className="ol-flex-row" style={actionRowStyle}>
|
||||
{androidOverlay?.message && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidthSm, textAlign: hintAlign }}>
|
||||
{androidOverlay.message}
|
||||
</span>
|
||||
)}
|
||||
@@ -282,10 +304,10 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
)}
|
||||
{showAccessibility && (
|
||||
<SettingRow label={t('settings.permissions.androidAccessibilityLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%', minWidth: 0 }}>
|
||||
<div className="ol-flex-row" style={actionRowStyle}>
|
||||
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidthSm, textAlign: hintAlign }}>
|
||||
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey)}
|
||||
</span>
|
||||
)}
|
||||
@@ -296,7 +318,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidth, textAlign: hintAlign }}>
|
||||
{t('settings.permissions.androidAccessibilityImpact')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -304,10 +326,10 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
)}
|
||||
{showAccessibility && (
|
||||
<SettingRow label={t('settings.permissions.androidShizukuLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%', minWidth: 0 }}>
|
||||
<div className="ol-flex-row" style={actionRowStyle}>
|
||||
{resolveShizukuMessage(t, shizukuDisplayMessageKey) && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidthSm, textAlign: hintAlign }}>
|
||||
{resolveShizukuMessage(t, shizukuDisplayMessageKey)}
|
||||
</span>
|
||||
)}
|
||||
@@ -366,7 +388,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
)}
|
||||
</div>
|
||||
{androidShizuku?.state === 'authorized' && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidth, textAlign: hintAlign }}>
|
||||
{androidShizuku.accessibility.operational
|
||||
? t('settings.permissions.androidShizukuAccessibilityOperational')
|
||||
: t('settings.permissions.androidShizukuAccessibilityRegistered', {
|
||||
@@ -379,7 +401,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidth, textAlign: hintAlign }}>
|
||||
{t('settings.permissions.androidShizukuHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -388,87 +410,87 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
{showOverlayConfig && (
|
||||
<>
|
||||
<SettingRow label={t('settings.permissions.androidInsertStrategyLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
|
||||
<select
|
||||
value={androidPrefs?.androidInsertStrategy ?? 'accessibility'}
|
||||
onChange={(event) => { void updateAndroidPref('androidInsertStrategy', event.target.value as AndroidInsertStrategy); }}
|
||||
style={{ minWidth: 180, maxWidth: '100%' }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="accessibility">{t('settings.permissions.androidInsertStrategy.accessibility')}</option>
|
||||
<option value="clipboard">{t('settings.permissions.androidInsertStrategy.clipboard')}</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.permissions.androidOverlayTriggerLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
|
||||
<select
|
||||
value={androidPrefs?.androidOverlayTrigger ?? 'background'}
|
||||
onChange={(event) => { void updateAndroidPref('androidOverlayTrigger', event.target.value as AndroidOverlayTrigger); }}
|
||||
style={{ minWidth: 180, maxWidth: '100%' }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="background">{t('settings.permissions.androidOverlayTrigger.background')}</option>
|
||||
<option value="keyboard" disabled>{t('settings.permissions.androidOverlayTrigger.keyboard')}</option>
|
||||
<option value="always">{t('settings.permissions.androidOverlayTrigger.always')}</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t('settings.permissions.androidOverlayTriggerDisabled.keyboard')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.permissions.androidOverlayActivationModeLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
|
||||
<select
|
||||
value={androidPrefs?.androidOverlayActivationMode ?? 'tap'}
|
||||
onChange={(event) => { void updateAndroidPref('androidOverlayActivationMode', event.target.value as AndroidOverlayActivationMode); }}
|
||||
style={{ minWidth: 180, maxWidth: '100%' }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="tap">{t('settings.permissions.androidOverlayActivationMode.tap')}</option>
|
||||
<option value="long_press">{t('settings.permissions.androidOverlayActivationMode.long_press')}</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.permissions.androidOverlayLeftSwipeActionLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
|
||||
<select
|
||||
value={androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}
|
||||
onChange={(event) => { void updateAndroidPref('androidOverlayLeftSwipeAction', event.target.value as AndroidOverlayLeftSwipeAction); }}
|
||||
style={{ minWidth: 180, maxWidth: '100%' }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="translation">{t('settings.permissions.androidOverlayLeftSwipeAction.translation')}</option>
|
||||
<option value="style_pack">{t('settings.permissions.androidOverlayLeftSwipeAction.style_pack')}</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.permissions.androidOverlayCancelSwipeDirectionLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
|
||||
<select
|
||||
value={androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}
|
||||
onChange={(event) => { void updateAndroidPref('androidOverlayCancelSwipeDirection', event.target.value as AndroidOverlayCancelSwipeDirection); }}
|
||||
style={{ minWidth: 180, maxWidth: '100%' }}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="up">{t('settings.permissions.androidOverlayCancelSwipeDirection.up')}</option>
|
||||
<option value="down">{t('settings.permissions.androidOverlayCancelSwipeDirection.down')}</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.permissions.androidOverlaySizeLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 180, maxWidth: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', maxWidth: '100%' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={48}
|
||||
@@ -487,13 +509,13 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
|
||||
onBlur={(event) => {
|
||||
flushAndroidOverlaySizeSave(Number(event.currentTarget.value));
|
||||
}}
|
||||
style={{ width: 132 }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', minWidth: 42, textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', minWidth: 42, textAlign: hintAlign }}>
|
||||
{sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
||||
{t('settings.permissions.androidOverlaySizeHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -114,11 +114,7 @@ class OpenLessAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
private fun canDrawOverlays(): Boolean {
|
||||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(this)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
|
||||
}
|
||||
|
||||
private fun performPasteToFocusedFieldInternal(pasteText: String? = null): AccessibilityPasteResult {
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
@@ -66,11 +65,7 @@ class OpenLessApplication : Application() {
|
||||
}
|
||||
|
||||
private fun canDrawOverlays(): Boolean {
|
||||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(this)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
|
||||
}
|
||||
|
||||
private fun sendOverlayAction(action: String) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
@@ -266,11 +265,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
|
||||
}
|
||||
|
||||
private fun canDrawOverlays(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(this)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
|
||||
}
|
||||
|
||||
private fun refreshExistingOverlayLayout(
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package com.openless.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AppOpsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Keep
|
||||
@@ -46,4 +52,70 @@ object OpenLessPermissionBridge {
|
||||
Log.i(TAG, "RECORD_AUDIO permission completed granted=$granted")
|
||||
requestInFlight.set(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe overlay permission query for Rust JNI / WebView IPC threads.
|
||||
* HyperOS and some OEM skins may throw from Settings.canDrawOverlays off the main thread.
|
||||
*/
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun canDrawOverlaysSafely(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return true
|
||||
}
|
||||
val appContext = OpenLessAppContext.context ?: context.applicationContext
|
||||
return if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
queryCanDrawOverlays(appContext)
|
||||
} else {
|
||||
val result = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
result.set(queryCanDrawOverlays(appContext))
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
latch.await(2, TimeUnit.SECONDS)
|
||||
} catch (error: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
Log.w(TAG, "canDrawOverlaysSafely interrupted", error)
|
||||
}
|
||||
result.get()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryCanDrawOverlays(context: Context): Boolean {
|
||||
return try {
|
||||
Settings.canDrawOverlays(context)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Settings.canDrawOverlays failed, trying AppOpsManager", error)
|
||||
queryCanDrawOverlaysViaAppOps(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryCanDrawOverlaysViaAppOps(context: Context): Boolean {
|
||||
return try {
|
||||
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
appOps.unsafeCheckOpNoThrow(
|
||||
AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,
|
||||
android.os.Process.myUid(),
|
||||
context.packageName,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,
|
||||
android.os.Process.myUid(),
|
||||
context.packageName,
|
||||
)
|
||||
}
|
||||
mode == AppOpsManager.MODE_ALLOWED
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "AppOpsManager overlay check failed", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ class OverlayPermissionActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||
!OpenLessPermissionBridge.canDrawOverlaysSafely(this)
|
||||
) {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:$packageName"),
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const APP_URL = process.env.OPENLESS_H5_URL || 'http://127.0.0.1:1420/';
|
||||
const CHROME_PATH = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const OUTPUT_DIR = process.env.OPENLESS_LAYOUT_ARTIFACT_DIR || join(tmpdir(), 'openless-layout-h5');
|
||||
const VIEWPORTS = [
|
||||
{ width: 360, height: 640 },
|
||||
{ width: 320, height: 568 },
|
||||
];
|
||||
const ZOOMS = [1.1, 2];
|
||||
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
constructor(socket) {
|
||||
this.socket = socket;
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
socket.addEventListener('message', event => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (!message.id) return;
|
||||
const waiter = this.pending.get(message.id);
|
||||
if (!waiter) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
|
||||
else waiter.resolve(message.result);
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url) {
|
||||
const socket = new WebSocket(url);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true });
|
||||
socket.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
return new CdpClient(socket);
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPageTarget(port) {
|
||||
const deadline = Date.now() + 15000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:' + port + '/json/list');
|
||||
const targets = await response.json();
|
||||
const target = targets.find(item => item.type === 'page' && item.url.startsWith(APP_URL));
|
||||
if (target?.webSocketDebuggerUrl) return target;
|
||||
} catch {
|
||||
// Chrome is still starting.
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('Chrome DevTools target did not become ready');
|
||||
}
|
||||
|
||||
async function launchChrome() {
|
||||
const port = 9300 + Math.floor(Math.random() * 400);
|
||||
const profile = join(tmpdir(), 'openless-layout-cdp-' + Date.now());
|
||||
const child = spawn(CHROME_PATH, [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--remote-debugging-port=' + port,
|
||||
'--user-data-dir=' + profile,
|
||||
APP_URL,
|
||||
], { stdio: 'ignore' });
|
||||
const target = await waitForPageTarget(port);
|
||||
return { child, client: await CdpClient.connect(target.webSocketDebuggerUrl) };
|
||||
}
|
||||
|
||||
async function evaluate(client, expression) {
|
||||
const response = await client.send('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(
|
||||
response.exceptionDetails.exception?.description || response.exceptionDetails.text || 'Runtime.evaluate failed',
|
||||
);
|
||||
}
|
||||
return response.result.value;
|
||||
}
|
||||
|
||||
async function evaluateFn(client, fn, ...args) {
|
||||
const expression = '(' + fn.toString() + ')(' + args.map(value => JSON.stringify(value)).join(',') + ')';
|
||||
return evaluate(client, expression);
|
||||
}
|
||||
|
||||
async function waitForFn(client, fn, args = [], timeoutMs = 10000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluateFn(client, fn, ...args)) return;
|
||||
await sleep(100);
|
||||
}
|
||||
const diagnostics = await evaluateFn(client, function collectDiagnostics() {
|
||||
const visible = element => element.getBoundingClientRect().width > 0;
|
||||
return {
|
||||
buttons: Array.from(document.querySelectorAll('button')).filter(visible)
|
||||
.map(button => button.innerText.trim()).filter(Boolean).slice(0, 40),
|
||||
leaves: Array.from(document.querySelectorAll('div,span')).filter(element => (
|
||||
visible(element) && element.children.length === 0 && element.textContent.trim()
|
||||
)).map(element => element.textContent.trim()).slice(0, 80),
|
||||
layoutTexts: document.body.innerText.split('\n')
|
||||
.map(text => text.trim()).filter(text => /布局|排版|易读/.test(text)),
|
||||
};
|
||||
});
|
||||
throw new Error(
|
||||
'Timed out waiting for browser state: ' + fn.name + ' diagnostics=' + JSON.stringify(diagnostics),
|
||||
);
|
||||
}
|
||||
|
||||
function hasExactButton(text) {
|
||||
return Array.from(document.querySelectorAll('button')).some(button => (
|
||||
button.getBoundingClientRect().width > 0 && button.innerText.trim() === text
|
||||
));
|
||||
}
|
||||
|
||||
function clickExactButton(text) {
|
||||
const button = Array.from(document.querySelectorAll('button')).find(candidate => (
|
||||
candidate.getBoundingClientRect().width > 0 && candidate.innerText.trim() === text
|
||||
));
|
||||
if (!button) {
|
||||
return {
|
||||
ok: false,
|
||||
buttons: Array.from(document.querySelectorAll('button'))
|
||||
.filter(candidate => candidate.getBoundingClientRect().width > 0)
|
||||
.map(candidate => candidate.innerText.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
button.click();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function clickButton(client, text) {
|
||||
const result = await evaluateFn(client, clickExactButton, text);
|
||||
assert.equal(result.ok, true, '找不到按钮 ' + text + ',当前按钮:' + JSON.stringify(result.buttons));
|
||||
await sleep(180);
|
||||
}
|
||||
|
||||
function clickCompositeRow(text) {
|
||||
const button = Array.from(document.querySelectorAll('button.ol-nav-btn')).find(candidate => (
|
||||
candidate.children.length === 3 &&
|
||||
candidate.getBoundingClientRect().width > 0 &&
|
||||
candidate.innerText.trim() === text
|
||||
));
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasSettingLabel(label) {
|
||||
return document.body.innerText.includes(label);
|
||||
}
|
||||
|
||||
async function openSettings(client) {
|
||||
await clickButton(client, '更多');
|
||||
await waitForFn(client, hasExactButton, ['设置']);
|
||||
await clickButton(client, '设置');
|
||||
await waitForFn(client, hasExactButton, ['通用']);
|
||||
await clickButton(client, '通用');
|
||||
await waitForFn(client, hasSettingLabel, ['易读布局(防溢出换行)']);
|
||||
}
|
||||
|
||||
function closeCurrentOverlay() {
|
||||
const buttons = Array.from(document.querySelectorAll('button[aria-label="关闭"]'))
|
||||
.filter(button => button.getBoundingClientRect().width > 0);
|
||||
if (!buttons.length) return false;
|
||||
buttons[0].click();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function closeOverlay(client) {
|
||||
assert.equal(await evaluateFn(client, closeCurrentOverlay), true, '找不到可见的关闭按钮');
|
||||
await sleep(220);
|
||||
}
|
||||
|
||||
function clickSettingToggle(label) {
|
||||
const labelNode = Array.from(document.querySelectorAll('div,span'))
|
||||
.find(node => node.textContent.trim() === label);
|
||||
let row = labelNode;
|
||||
while (row && !row.querySelector('button')) row = row.parentElement;
|
||||
const button = row?.querySelector('button');
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function rootPreferenceState() {
|
||||
return {
|
||||
readable: document.documentElement.dataset.olStackedLayout === 'true',
|
||||
conservative: document.documentElement.dataset.olConservativeLayout === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
async function setPreference(client, label, key, desired) {
|
||||
const state = await evaluateFn(client, rootPreferenceState);
|
||||
if (state[key] !== desired) {
|
||||
assert.equal(await evaluateFn(client, clickSettingToggle, label), true, '找不到布局开关:' + label);
|
||||
}
|
||||
await waitForFn(client, function waitPreference(prefKey, expected) {
|
||||
const root = document.documentElement.dataset;
|
||||
const actual = prefKey === 'readable'
|
||||
? root.olStackedLayout === 'true'
|
||||
: root.olConservativeLayout === 'true';
|
||||
return actual === expected;
|
||||
}, [key, desired]);
|
||||
await sleep(180);
|
||||
}
|
||||
|
||||
async function setPreferences(client, readable, conservative) {
|
||||
await clickButton(client, '通用');
|
||||
await waitForFn(client, hasSettingLabel, ['易读布局(防溢出换行)']);
|
||||
await setPreference(client, '易读布局(防溢出换行)', 'readable', readable);
|
||||
await setPreference(client, '保守排版', 'conservative', conservative);
|
||||
}
|
||||
|
||||
function inspectGeneralLayout(zoom) {
|
||||
const inspectToggle = label => {
|
||||
const labelNode = Array.from(document.querySelectorAll('div,span'))
|
||||
.find(node => node.textContent.trim() === label);
|
||||
let row = labelNode;
|
||||
while (row && !row.querySelector('button')) row = row.parentElement;
|
||||
const button = row?.querySelector('button');
|
||||
const control = button?.parentElement;
|
||||
if (!row || !button || !control) return null;
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
return {
|
||||
normalizedWidth: Number((buttonRect.width / zoom).toFixed(2)),
|
||||
buttonLeft: Number(buttonRect.left.toFixed(2)),
|
||||
rowLeft: Number(rowRect.left.toFixed(2)),
|
||||
rowRight: Number(rowRect.right.toFixed(2)),
|
||||
justifyContent: getComputedStyle(control).justifyContent,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsRoot = document.querySelector('.ol-settings-surface') || document.body;
|
||||
const isScrollContained = element => {
|
||||
for (let parent = element.parentElement; parent && parent !== settingsRoot; parent = parent.parentElement) {
|
||||
const overflowX = getComputedStyle(parent).overflowX;
|
||||
if (overflowX === 'auto' || overflowX === 'scroll') return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const outliers = Array.from(settingsRoot.querySelectorAll('button,input,select,textarea,[role="button"],.ol-inline-composite'))
|
||||
.filter(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0 && (rect.left < -1 || rect.right > innerWidth + 1);
|
||||
})
|
||||
.filter(element => !isScrollContained(element))
|
||||
.slice(0, 12)
|
||||
.map(element => ({
|
||||
tag: element.tagName,
|
||||
text: element.textContent.trim().slice(0, 30),
|
||||
rect: Array.from([
|
||||
element.getBoundingClientRect().left,
|
||||
element.getBoundingClientRect().right,
|
||||
innerWidth,
|
||||
]).map(value => Number(value.toFixed(1))),
|
||||
}));
|
||||
|
||||
return {
|
||||
root: {
|
||||
readable: document.documentElement.dataset.olStackedLayout === 'true',
|
||||
conservative: document.documentElement.dataset.olConservativeLayout === 'true',
|
||||
},
|
||||
readableToggle: inspectToggle('易读布局(防溢出换行)'),
|
||||
conservativeToggle: inspectToggle('保守排版'),
|
||||
outliers,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectServiceActions() {
|
||||
const edit = Array.from(document.querySelectorAll('button')).find(button => (
|
||||
button.getBoundingClientRect().width > 0 &&
|
||||
(button.getAttribute('aria-label') || '').startsWith('编辑')
|
||||
));
|
||||
const group = edit?.parentElement;
|
||||
if (!group) return null;
|
||||
const style = getComputedStyle(group);
|
||||
const childTops = Array.from(group.children)
|
||||
.filter(child => child.getBoundingClientRect().height > 0)
|
||||
.map(child => Math.round(child.getBoundingClientRect().top));
|
||||
return {
|
||||
className: group.className,
|
||||
flexDirection: style.flexDirection,
|
||||
alignItems: style.alignItems,
|
||||
childCount: childTops.length,
|
||||
uniqueRows: new Set(childTops).size,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPermissionActions() {
|
||||
const surface = document.querySelector('.ol-settings-surface');
|
||||
const labelNode = Array.from(surface?.querySelectorAll('div,span') || [])
|
||||
.find(node => node.textContent.trim() === '麦克风');
|
||||
let row = labelNode;
|
||||
while (row && getComputedStyle(row).display !== 'grid') row = row.parentElement;
|
||||
const group = row?.children[1]?.firstElementChild;
|
||||
if (!group) return null;
|
||||
const style = getComputedStyle(group);
|
||||
return {
|
||||
flexDirection: style.flexDirection,
|
||||
alignItems: style.alignItems,
|
||||
justifyContent: style.justifyContent,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectAboutComposite() {
|
||||
const row = document.querySelector('.ol-inline-composite');
|
||||
if (!row) return null;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const children = Array.from(row.children).map(child => {
|
||||
const rect = child.getBoundingClientRect();
|
||||
return {
|
||||
left: Number(rect.left.toFixed(1)),
|
||||
right: Number(rect.right.toFixed(1)),
|
||||
top: Number(rect.top.toFixed(1)),
|
||||
bottom: Number(rect.bottom.toFixed(1)),
|
||||
};
|
||||
});
|
||||
const style = getComputedStyle(row);
|
||||
return {
|
||||
flexDirection: style.flexDirection,
|
||||
row: [rowRect.left, rowRect.right, rowRect.top, rowRect.bottom].map(value => Number(value.toFixed(1))),
|
||||
flexWrap: style.flexWrap,
|
||||
childCount: children.length,
|
||||
withinRow: children.every(rect => rect.left >= rowRect.left - 1 && rect.right <= rowRect.right + 1),
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectCompositeSheet(expectedRows) {
|
||||
const rows = Array.from(document.querySelectorAll('button.ol-nav-btn')).filter(button => (
|
||||
button.children.length === 3 &&
|
||||
button.getBoundingClientRect().width > 0 &&
|
||||
getComputedStyle(button).flexDirection === 'row'
|
||||
));
|
||||
return {
|
||||
count: rows.length,
|
||||
expectedRows,
|
||||
valid: rows.every(row => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const children = Array.from(row.children).map(child => child.getBoundingClientRect());
|
||||
return rect.left >= -1 &&
|
||||
rect.right <= innerWidth + 1 &&
|
||||
children.every(child => child.top < rect.bottom && child.bottom > rect.top);
|
||||
}),
|
||||
rows: rows.map(row => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const parentRect = row.parentElement.getBoundingClientRect();
|
||||
const style = getComputedStyle(row);
|
||||
return {
|
||||
text: row.innerText.trim(),
|
||||
rect: [rect.left, rect.right, rect.top, rect.bottom, innerWidth].map(value => Number(value.toFixed(1))),
|
||||
parentRect: [parentRect.left, parentRect.right, parentRect.width].map(value => Number(value.toFixed(1))),
|
||||
computed: { boxSizing: style.boxSizing, width: style.width, padding: style.padding },
|
||||
children: Array.from(row.children).map(child => {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
return [childRect.left, childRect.right, childRect.top, childRect.bottom].map(value => Number(value.toFixed(1)));
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectPageOutliers() {
|
||||
const root = document.querySelector('main');
|
||||
if (!root) return [{ tag: 'MISSING_MAIN' }];
|
||||
const isScrollContained = element => {
|
||||
for (let parent = element.parentElement; parent && parent !== root; parent = parent.parentElement) {
|
||||
const overflowX = getComputedStyle(parent).overflowX;
|
||||
if (overflowX === 'auto' || overflowX === 'scroll') return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return Array.from(root.querySelectorAll('h1,p,button,input,select,textarea'))
|
||||
.filter(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0 && (rect.left < -1 || rect.right > innerWidth + 1);
|
||||
})
|
||||
.filter(element => !isScrollContained(element))
|
||||
.slice(0, 12)
|
||||
.map(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
tag: element.tagName,
|
||||
text: element.textContent.trim().slice(0, 30),
|
||||
rect: [
|
||||
Number(rect.left.toFixed(1)),
|
||||
Number(rect.right.toFixed(1)),
|
||||
innerWidth,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
function pageGridFingerprint() {
|
||||
const grid = document.querySelector('.ol-grid-auto-cards');
|
||||
if (!grid) return null;
|
||||
const rect = grid.getBoundingClientRect();
|
||||
return {
|
||||
columns: getComputedStyle(grid).gridTemplateColumns,
|
||||
width: Number(rect.width.toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
async function openStylePage(client, label) {
|
||||
await clickButton(client, '风格');
|
||||
await waitForFn(client, function hasComposite(labelText) {
|
||||
return Array.from(document.querySelectorAll('button.ol-nav-btn')).some(button => (
|
||||
button.children.length === 3 &&
|
||||
button.getBoundingClientRect().width > 0 &&
|
||||
button.innerText.trim() === labelText
|
||||
));
|
||||
}, [label]);
|
||||
assert.equal(await evaluateFn(client, clickCompositeRow, label), true, '找不到风格抽屉入口:' + label);
|
||||
await waitForFn(client, function hasGrid() {
|
||||
return Boolean(document.querySelector('.ol-grid-auto-cards'));
|
||||
});
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
async function assertCompositeSheets(client) {
|
||||
await clickButton(client, '更多');
|
||||
await sleep(120);
|
||||
const more = await evaluateFn(client, inspectCompositeSheet, 4);
|
||||
assert.equal(more.count, 4, '更多抽屉应有四个三元素复合行');
|
||||
assert.equal(more.valid, true, '更多抽屉复合行溢出或未保持同行:' + JSON.stringify(more));
|
||||
await closeOverlay(client);
|
||||
|
||||
await clickButton(client, '风格');
|
||||
await sleep(120);
|
||||
const style = await evaluateFn(client, inspectCompositeSheet, 2);
|
||||
assert.equal(style.count, 2, '风格抽屉应有两个三元素复合行');
|
||||
assert.equal(style.valid, true, '风格抽屉复合行溢出或未保持同行:' + JSON.stringify(style));
|
||||
await closeOverlay(client);
|
||||
}
|
||||
|
||||
async function captureScreenshot(client, filename) {
|
||||
const result = await client.send('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: false,
|
||||
});
|
||||
const path = join(OUTPUT_DIR, filename);
|
||||
writeFileSync(path, Buffer.from(result.data, 'base64'));
|
||||
return path;
|
||||
}
|
||||
|
||||
async function runCase(client, viewport, zoom) {
|
||||
await client.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
await client.send('Page.navigate', { url: APP_URL });
|
||||
await waitForFn(client, hasExactButton, ['更多'], 15000);
|
||||
await evaluateFn(client, function applyTestZoom(value) {
|
||||
document.documentElement.style.zoom = String(value);
|
||||
}, zoom);
|
||||
await sleep(250);
|
||||
|
||||
await openSettings(client);
|
||||
await setPreferences(client, false, false);
|
||||
const baselineGeneral = await evaluateFn(client, inspectGeneralLayout, zoom);
|
||||
|
||||
await closeOverlay(client);
|
||||
await openStylePage(client, '润色模式');
|
||||
const baselineStyle = await evaluateFn(client, pageGridFingerprint);
|
||||
await openStylePage(client, '风格市场');
|
||||
const baselineMarketplace = await evaluateFn(client, pageGridFingerprint);
|
||||
|
||||
await openSettings(client);
|
||||
const combinations = [
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
];
|
||||
|
||||
const results = [];
|
||||
for (const combination of combinations) {
|
||||
const readable = combination[0];
|
||||
const conservative = combination[1];
|
||||
await setPreferences(client, readable, conservative);
|
||||
const general = await evaluateFn(client, inspectGeneralLayout, zoom);
|
||||
assert.deepEqual(general.root, { readable, conservative });
|
||||
for (const toggle of [general.readableToggle, general.conservativeToggle]) {
|
||||
assert.ok(toggle, '布局开关未渲染');
|
||||
assert.ok(Math.abs(toggle.normalizedWidth - 36) <= 0.75, '开关宽度不再是 36px:' + JSON.stringify(toggle));
|
||||
assert.equal(toggle.justifyContent, 'flex-start', '小尺寸布局开关应靠左');
|
||||
assert.ok(toggle.buttonLeft >= -1 && toggle.buttonLeft <= toggle.rowRight, '布局开关超出设置行');
|
||||
}
|
||||
assert.deepEqual(general.outliers, [], '设置页存在未被滚动容器承接的横向溢出');
|
||||
|
||||
if (!readable && conservative) {
|
||||
await clickButton(client, '服务');
|
||||
await waitForFn(client, function hasEditAction() {
|
||||
return Array.from(document.querySelectorAll('button')).some(button => (
|
||||
button.getBoundingClientRect().width > 0 &&
|
||||
(button.getAttribute('aria-label') || '').startsWith('编辑')
|
||||
));
|
||||
});
|
||||
const service = await evaluateFn(client, inspectServiceActions);
|
||||
assert.ok(service, '服务卡片动作组未渲染');
|
||||
assert.equal(service.flexDirection, 'column');
|
||||
assert.equal(service.alignItems, 'flex-start');
|
||||
assert.ok(service.childCount === 3 && service.uniqueRows === 3, '服务卡片应有验证、开关、编辑三个纵排动作');
|
||||
|
||||
await clickButton(client, '隐私');
|
||||
await waitForFn(client, hasSettingLabel, ['麦克风']);
|
||||
const privacy = await evaluateFn(client, inspectPermissionActions);
|
||||
assert.deepEqual(privacy, {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
});
|
||||
|
||||
await clickButton(client, '关于');
|
||||
await waitForFn(client, function hasAboutComposite() {
|
||||
return Boolean(document.querySelector('.ol-inline-composite'));
|
||||
});
|
||||
const about = await evaluateFn(client, inspectAboutComposite);
|
||||
assert.ok(about, '关于页复合行未渲染');
|
||||
assert.equal(about.flexDirection, 'row');
|
||||
assert.equal(about.flexWrap, 'nowrap');
|
||||
assert.equal(about.childCount, 3);
|
||||
assert.equal(about.withinRow, true, '关于页三元素未保持在容器内:' + JSON.stringify(about));
|
||||
await closeOverlay(client);
|
||||
await openStylePage(client, '润色模式');
|
||||
const styleOutliers = await evaluateFn(client, inspectPageOutliers);
|
||||
assert.deepEqual(styleOutliers, [], '保守排版下风格页存在横向溢出:' + JSON.stringify(styleOutliers));
|
||||
await openStylePage(client, '风格市场');
|
||||
const marketplaceOutliers = await evaluateFn(client, inspectPageOutliers);
|
||||
assert.deepEqual(marketplaceOutliers, [], '保守排版下风格市场存在横向溢出:' + JSON.stringify(marketplaceOutliers));
|
||||
await openSettings(client);
|
||||
}
|
||||
|
||||
results.push({ readable, conservative, outliers: general.outliers.length });
|
||||
}
|
||||
|
||||
await setPreferences(client, false, false);
|
||||
const restoredGeneral = await evaluateFn(client, inspectGeneralLayout, zoom);
|
||||
assert.deepEqual(restoredGeneral.root, { readable: false, conservative: false });
|
||||
assert.deepEqual(restoredGeneral.readableToggle, baselineGeneral.readableToggle);
|
||||
assert.deepEqual(restoredGeneral.conservativeToggle, baselineGeneral.conservativeToggle);
|
||||
|
||||
await closeOverlay(client);
|
||||
await openStylePage(client, '润色模式');
|
||||
const restoredStyle = await evaluateFn(client, pageGridFingerprint);
|
||||
assert.deepEqual(restoredStyle, baselineStyle, '关闭两个偏好后风格页未恢复基线布局');
|
||||
await openStylePage(client, '风格市场');
|
||||
const restoredMarketplace = await evaluateFn(client, pageGridFingerprint);
|
||||
assert.deepEqual(restoredMarketplace, baselineMarketplace, '关闭两个偏好后风格市场未恢复基线布局');
|
||||
|
||||
await assertCompositeSheets(client);
|
||||
await openSettings(client);
|
||||
await setPreferences(client, false, true);
|
||||
await closeOverlay(client);
|
||||
await openStylePage(client, '风格市场');
|
||||
const screenshot = await captureScreenshot(
|
||||
client,
|
||||
'layout-' + viewport.width + 'x' + viewport.height + '-zoom-' + String(zoom).replace('.', '_') + '.png',
|
||||
);
|
||||
|
||||
return {
|
||||
viewport,
|
||||
zoom,
|
||||
combinations: results,
|
||||
baselineStyle,
|
||||
baselineMarketplace,
|
||||
screenshot,
|
||||
};
|
||||
}
|
||||
|
||||
const { child, client } = await launchChrome();
|
||||
const reports = [];
|
||||
|
||||
try {
|
||||
await client.send('Page.enable');
|
||||
await client.send('Runtime.enable');
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const zoom of ZOOMS) {
|
||||
const report = await runCase(client, viewport, zoom);
|
||||
reports.push(report);
|
||||
console.log('[layout-h5] passed ' + viewport.width + 'x' + viewport.height + ' zoom=' + zoom);
|
||||
}
|
||||
}
|
||||
const reportPath = join(OUTPUT_DIR, 'layout-report.json');
|
||||
writeFileSync(reportPath, JSON.stringify(reports, null, 2));
|
||||
console.log('[layout-h5] report ' + reportPath);
|
||||
} finally {
|
||||
try {
|
||||
await client.send('Browser.close');
|
||||
} catch {
|
||||
child.kill();
|
||||
}
|
||||
client.close();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const read = relativePath => readFileSync(join(appRoot, relativePath), 'utf8');
|
||||
|
||||
const css = read('src/styles/global.css');
|
||||
const stackedLayout = read('src/lib/stackedLayout.ts');
|
||||
const shared = read('src/pages/settings/shared.tsx');
|
||||
const pageAtoms = read('src/pages/_atoms.tsx');
|
||||
const row = read('src/components/ui/Row.tsx');
|
||||
const channelList = read('src/pages/settings/ChannelList.tsx');
|
||||
const permissions = read('src/pages/settings/PermissionsSection.tsx');
|
||||
const androidPermissions = read('android/frontend/components/AndroidPermissionsPanel.tsx');
|
||||
const about = read('src/pages/settings/AboutSection.tsx');
|
||||
const updateButton = read('src/pages/settings/CheckUpdateButton.tsx');
|
||||
const moreSheet = read('src/components/MobileMoreSheet.tsx');
|
||||
const styleSheet = read('src/components/MobileStyleSheet.tsx');
|
||||
|
||||
assert.doesNotMatch(css, /\.ol-flex-row\s*>\s*\*/);
|
||||
assert.doesNotMatch(css, /\[class\*=["']ol-grid["']\]/);
|
||||
assert.match(css, /\.ol-flex-row\.ol-flex-split/);
|
||||
assert.match(css, /\.ol-conservative-stack/);
|
||||
|
||||
assert.match(stackedLayout, /stackedRowLayout\?: boolean/);
|
||||
assert.doesNotMatch(stackedLayout, /mobile\s*\|\|/);
|
||||
assert.match(shared, /flex:\s*"0 0 36px"/);
|
||||
assert.match(shared, /minWidth:\s*36/);
|
||||
assert.match(shared, /maxWidth:\s*36/);
|
||||
assert.match(pageAtoms, /preferenceStack\s*\?/);
|
||||
assert.match(row, /stackLayout\s*\?\s*'minmax\(0, 1fr\)'/);
|
||||
|
||||
assert.match(channelList, /ol-conservative-stack/);
|
||||
assert.match(channelList, /settings\.channels\.edit/);
|
||||
assert.match(permissions, /permissionActionsStyle/);
|
||||
assert.match(permissions, /justifyContent:\s*stackLayout\s*\?\s*'flex-start'\s*:\s*'flex-end'/);
|
||||
assert.match(androidPermissions, /const baseLayoutStack = useLayoutStack\(\)/);
|
||||
assert.match(androidPermissions, /const rowJustify = layoutStack \? 'flex-start' : 'flex-end'/);
|
||||
|
||||
assert.match(about, /ol-inline-composite/);
|
||||
assert.match(about, /compact=\{compactLayout\}/);
|
||||
assert.match(updateButton, /minWidth:\s*compact\s*\?\s*32\s*:\s*160/);
|
||||
assert.match(updateButton, /aria-label=\{compact\s*\?\s*label\s*:\s*undefined\}/);
|
||||
assert.match(updateButton, /display:\s*compact\s*\?\s*'none'/);
|
||||
assert.match(moreSheet, /chevRight/);
|
||||
assert.match(styleSheet, /chevRight/);
|
||||
assert.match(moreSheet, /flexWrap:\s*'nowrap'/);
|
||||
assert.match(styleSheet, /flexWrap:\s*'nowrap'/);
|
||||
assert.match(moreSheet, /position:\s*'fixed'/);
|
||||
assert.match(styleSheet, /position:\s*'fixed'/);
|
||||
assert.match(moreSheet, /boxSizing:\s*'border-box'/);
|
||||
assert.match(styleSheet, /boxSizing:\s*'border-box'/);
|
||||
|
||||
console.log('layout mode contract tests passed');
|
||||
@@ -446,18 +446,21 @@ pub mod android {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn can_draw_overlays(env: &mut JNIEnv, context: &JObject) -> Result<bool, String> {
|
||||
pub fn can_draw_overlays<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
) -> Result<bool, String> {
|
||||
if android_sdk_int(env)? < 23 {
|
||||
return Ok(true);
|
||||
}
|
||||
env.call_static_method(
|
||||
"android/provider/Settings",
|
||||
"canDrawOverlays",
|
||||
call_static_bool_with_context_class(
|
||||
env,
|
||||
context,
|
||||
"com.openless.app.OpenLessPermissionBridge",
|
||||
"canDrawOverlaysSafely",
|
||||
"(Landroid/content/Context;)Z",
|
||||
&[JValue::Object(context)],
|
||||
)
|
||||
.and_then(|value| value.z())
|
||||
.map_err(|error| format!("Settings.canDrawOverlays: {error}"))
|
||||
}
|
||||
|
||||
pub fn check_self_permission(
|
||||
|
||||
@@ -1169,6 +1169,12 @@ pub struct UserPreferences {
|
||||
/// 活动计数照常记录(persistence/activity.rs),再打开时全年数据仍在。
|
||||
#[serde(default = "default_true")]
|
||||
pub show_overview_activity_heatmap: bool,
|
||||
/// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出与文字被压扁。默认 false。
|
||||
#[serde(default)]
|
||||
pub stacked_row_layout: bool,
|
||||
/// 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。默认 false。
|
||||
#[serde(default)]
|
||||
pub conservative_layout: bool,
|
||||
/// 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
|
||||
/// Android 开启后自动检查并下载,校验后打开系统安装器;桌面仅自动检查 + 用户确认安装。
|
||||
/// 关闭后仅 Settings 手动「检查更新」按钮可用。
|
||||
@@ -1441,6 +1447,10 @@ struct UserPreferencesWire {
|
||||
cursor_context_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
show_overview_activity_heatmap: bool,
|
||||
#[serde(default)]
|
||||
stacked_row_layout: bool,
|
||||
#[serde(default)]
|
||||
conservative_layout: bool,
|
||||
#[serde(default = "default_true")]
|
||||
auto_update_check: bool,
|
||||
#[serde(default)]
|
||||
@@ -1591,6 +1601,8 @@ impl Default for UserPreferencesWire {
|
||||
streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard,
|
||||
cursor_context_enabled: prefs.cursor_context_enabled,
|
||||
show_overview_activity_heatmap: prefs.show_overview_activity_heatmap,
|
||||
stacked_row_layout: prefs.stacked_row_layout,
|
||||
conservative_layout: prefs.conservative_layout,
|
||||
auto_update_check: prefs.auto_update_check,
|
||||
history_max_entries: prefs.history_max_entries,
|
||||
record_audio_for_debug: prefs.record_audio_for_debug,
|
||||
@@ -1747,6 +1759,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard,
|
||||
cursor_context_enabled: wire.cursor_context_enabled,
|
||||
show_overview_activity_heatmap: wire.show_overview_activity_heatmap,
|
||||
stacked_row_layout: wire.stacked_row_layout,
|
||||
conservative_layout: wire.conservative_layout,
|
||||
auto_update_check: wire.auto_update_check,
|
||||
history_max_entries: wire.history_max_entries,
|
||||
record_audio_for_debug: wire.record_audio_for_debug,
|
||||
@@ -2563,6 +2577,8 @@ impl Default for UserPreferences {
|
||||
streaming_insert_save_clipboard: true,
|
||||
cursor_context_enabled: false,
|
||||
show_overview_activity_heatmap: true,
|
||||
stacked_row_layout: false,
|
||||
conservative_layout: false,
|
||||
auto_update_check: true,
|
||||
history_max_entries: None,
|
||||
record_audio_for_debug: false,
|
||||
|
||||
@@ -37,7 +37,10 @@ import { type SettingsSectionId } from './SettingsModal';
|
||||
import { MobileMoreSheet } from './MobileMoreSheet';
|
||||
import { MobileStyleSheet } from './MobileStyleSheet';
|
||||
import { subItemLabelKey } from '../lib/navLabels';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import { applyStackedLayoutFromPrefs } from '../lib/stackedLayout';
|
||||
import { applyConservativeLayout } from '../lib/conservativeLayout';
|
||||
import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { useAppState, type AppTab } from '../state/useAppState';
|
||||
|
||||
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk'];
|
||||
@@ -92,6 +95,8 @@ export function FloatingShell({ os: osProp, initialTab = 'overview', initialSett
|
||||
function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initialTab: AppTab; initialSettings: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const conservative = useConservativeLayout();
|
||||
const { prefs } = useHotkeySettings();
|
||||
const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
||||
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
||||
@@ -118,6 +123,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
applyFontScale(readFontScale());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyStackedLayoutFromPrefs(prefs?.stackedRowLayout);
|
||||
applyConservativeLayout(prefs?.conservativeLayout === true);
|
||||
}, [prefs?.stackedRowLayout, prefs?.conservativeLayout]);
|
||||
|
||||
const Page = PAGE_CMP[displayTab as Exclude<AppTab, 'localAsr'>] ?? Overview;
|
||||
|
||||
// 分组展开态:默认展开「当前所在页所属的分组」。用户点分组标题手动切换。
|
||||
@@ -453,7 +463,12 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
{displayTab === 'overview' ? (
|
||||
<Overview onOpenHistory={() => setCurrentTab('history')} />
|
||||
) : (
|
||||
<Page />
|
||||
<div
|
||||
className={conservative ? 'ol-conservative-scope' : undefined}
|
||||
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<Page />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
@@ -462,6 +477,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
|
||||
{mobile && (
|
||||
<>
|
||||
{!settingsOpen && !styleOpen && !moreOpen && (
|
||||
<MobileBottomNav
|
||||
currentTab={currentTab}
|
||||
moreOpen={moreOpen}
|
||||
@@ -483,6 +499,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
setMoreOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MobileStyleSheet
|
||||
open={styleOpen}
|
||||
currentTab={currentTab}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function MobileMoreSheet({
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'rgba(15,17,22,0.32)',
|
||||
@@ -79,7 +79,8 @@ export function MobileMoreSheet({
|
||||
style={rowBtnStyle}
|
||||
>
|
||||
<Icon name={item.icon} size={16} />
|
||||
<span style={{ flex: 1 }}>{t(`nav.${item.id}`)}</span>
|
||||
<span style={rowLabelStyle}>{t(`nav.${item.id}`)}</span>
|
||||
<Icon name="chevRight" size={13} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -93,7 +94,8 @@ export function MobileMoreSheet({
|
||||
style={rowBtnStyle}
|
||||
>
|
||||
<Icon name="settings" size={16} />
|
||||
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
|
||||
<span style={rowLabelStyle}>{t('shell.footer.settings')}</span>
|
||||
<Icon name="chevRight" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +118,10 @@ const iconBtnStyle: CSSProperties = {
|
||||
|
||||
const rowBtnStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
@@ -127,3 +133,11 @@ const rowBtnStyle: CSSProperties = {
|
||||
cursor: 'default',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const rowLabelStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export function MobileStyleSheet({
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'rgba(15,17,22,0.32)',
|
||||
@@ -77,7 +77,8 @@ export function MobileStyleSheet({
|
||||
style={rowBtnStyle}
|
||||
>
|
||||
<Icon name={item.icon} size={16} />
|
||||
<span style={{ flex: 1 }}>{t(subItemLabelKey(item.id))}</span>
|
||||
<span style={rowLabelStyle}>{t(subItemLabelKey(item.id))}</span>
|
||||
<Icon name="chevRight" size={13} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -102,6 +103,10 @@ const iconBtnStyle: CSSProperties = {
|
||||
|
||||
const rowBtnStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
@@ -113,3 +118,11 @@ const rowBtnStyle: CSSProperties = {
|
||||
cursor: 'default',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const rowLabelStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
@@ -14,7 +14,7 @@ import { Icon } from './Icon';
|
||||
import { SavedToast } from './SavedToast';
|
||||
import { useSavedToastListener } from '../lib/savedEvent';
|
||||
import { openExternal } from '../lib/ipc';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout';
|
||||
import type { OS } from './WindowChrome';
|
||||
import { AboutTab, GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
|
||||
import { chipSelectedStyle } from '../pages/settings/shared';
|
||||
@@ -59,6 +59,7 @@ const LINK_ITEMS: ModalNavItem[] = [
|
||||
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const conservative = useConservativeLayout();
|
||||
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
|
||||
const savedToast = useSavedToastListener();
|
||||
|
||||
@@ -88,7 +89,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
alignItems: mobile ? 'stretch' : 'center',
|
||||
justifyContent: mobile ? 'stretch' : 'center',
|
||||
padding: mobile ? 0 : 28,
|
||||
zIndex: 50,
|
||||
zIndex: mobile ? 70 : 50,
|
||||
animation: mobile ? undefined : 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
|
||||
}}>
|
||||
|
||||
@@ -246,7 +247,10 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
)}
|
||||
|
||||
<div
|
||||
className="ol-thinscroll ol-scroll-fade"
|
||||
className={[
|
||||
'ol-thinscroll ol-scroll-fade',
|
||||
conservative ? 'ol-conservative-scope' : undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Row — two-column row used in the Settings modal sub-sections.
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
|
||||
|
||||
interface RowProps {
|
||||
label: string;
|
||||
@@ -9,13 +10,34 @@ interface RowProps {
|
||||
}
|
||||
|
||||
export function Row({ label, desc, children }: RowProps) {
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const stackLayout = baseLayoutStack || conservative;
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16, padding: '12px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: stackLayout ? 'minmax(0, 1fr)' : '180px minmax(0, 1fr)',
|
||||
gap: stackLayout ? 8 : 16,
|
||||
padding: '12px 0',
|
||||
borderTop: '0.5px solid var(--ol-line-soft)',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</div>
|
||||
{desc && <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 4, lineHeight: 1.5 }}>{desc}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>{children}</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
minWidth: 0,
|
||||
width: stackLayout ? '100%' : 'auto',
|
||||
maxWidth: '100%',
|
||||
flexWrap: stackLayout ? 'wrap' : 'nowrap',
|
||||
gap: stackLayout ? 6 : undefined,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1331,10 +1331,17 @@ export const en: typeof zhCN = {
|
||||
ko: '한국어 (Beta)',
|
||||
restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.',
|
||||
},
|
||||
layout: {
|
||||
title: 'Layout',
|
||||
},
|
||||
theme: {
|
||||
title: 'Appearance',
|
||||
label: 'Theme',
|
||||
activityHeatmapLabel: 'Show annual activity heatmap on Overview',
|
||||
stackedRowLayoutLabel: 'Readable layout (wrap rows)',
|
||||
stackedRowLayoutDesc: 'On small screens or with large text, buttons and controls that no longer fit on one line move to the next line instead of overflowing or squashing text.',
|
||||
conservativeLayoutLabel: 'Conservative layout',
|
||||
conservativeLayoutDesc: 'Outside the home page, top bar, and bottom bar, settings and feature pages use a single full-width column to minimize horizontal overflow.',
|
||||
system: 'Follow system',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
|
||||
@@ -1299,10 +1299,17 @@ export const ja: typeof zhCN = {
|
||||
ko: '한국어 (Beta)',
|
||||
restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。',
|
||||
},
|
||||
layout: {
|
||||
title: 'レイアウト',
|
||||
},
|
||||
theme: {
|
||||
title: '外観',
|
||||
label: 'テーマ',
|
||||
activityHeatmapLabel: '概要ページに年間アクティビティを表示',
|
||||
stackedRowLayoutLabel: '読みやすいレイアウト(はみ出し防止)',
|
||||
stackedRowLayoutDesc: '小さい画面や大きな文字サイズでは、1行に収まらないボタンや設定が次の行に折り返され、横方向のはみ出しや文字の潰れを防ぎます。',
|
||||
conservativeLayoutLabel: '保守レイアウト',
|
||||
conservativeLayoutDesc: 'ホーム、上部バー、下部バー以外の設定・機能ページを単列・全幅にし、横方向のはみ出しを最大限防ぎます。',
|
||||
system: 'システムに従う',
|
||||
light: 'ライト',
|
||||
dark: 'ダーク',
|
||||
|
||||
@@ -1299,10 +1299,17 @@ export const ko: typeof zhCN = {
|
||||
ko: '한국어 (Beta)',
|
||||
restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.',
|
||||
},
|
||||
layout: {
|
||||
title: '레이아웃',
|
||||
},
|
||||
theme: {
|
||||
title: '모양',
|
||||
label: '테마',
|
||||
activityHeatmapLabel: '개요 페이지에 연간 활동 표시',
|
||||
stackedRowLayoutLabel: '읽기 쉬운 레이아웃(넘침 방지 줄바꿈)',
|
||||
stackedRowLayoutDesc: '작은 화면이나 큰 글꼴에서 한 줄에 맞지 않는 버튼과 옵션은 다음 줄로 넘어가 가로 넘침과 글자 눌림을 방지합니다.',
|
||||
conservativeLayoutLabel: '보수적 레이아웃',
|
||||
conservativeLayoutDesc: '홈, 상단 바, 하단 바 외 설정·기능 페이지를 단일 열·전체 너비로 표시하여 가로 넘침을 최대한 방지합니다.',
|
||||
system: '시스템 따르기',
|
||||
light: '라이트',
|
||||
dark: '다크',
|
||||
|
||||
@@ -1329,10 +1329,17 @@ export const zhCN = {
|
||||
ko: '한국어 (Beta)',
|
||||
restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。',
|
||||
},
|
||||
layout: {
|
||||
title: '布局',
|
||||
},
|
||||
theme: {
|
||||
title: '外观',
|
||||
label: '主题',
|
||||
activityHeatmapLabel: '概览页显示年度活动热力图',
|
||||
stackedRowLayoutLabel: '易读布局(防溢出换行)',
|
||||
stackedRowLayoutDesc: '小屏或大字时,同一行放不下的按钮和选项会自动换到下一行,避免横向挤出屏幕或文字被压扁。',
|
||||
conservativeLayoutLabel: '保守排版',
|
||||
conservativeLayoutDesc: '除首页、顶栏与底栏外,设置与功能页改为单列满宽,最大程度避免横向溢出。',
|
||||
system: '跟随系统',
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
|
||||
@@ -1297,10 +1297,17 @@ export const zhTW: typeof zhCN = {
|
||||
ko: '한국어 (Beta)',
|
||||
restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。',
|
||||
},
|
||||
layout: {
|
||||
title: '布局',
|
||||
},
|
||||
theme: {
|
||||
title: '外觀',
|
||||
label: '主題',
|
||||
activityHeatmapLabel: '概覽頁顯示年度活動熱力圖',
|
||||
stackedRowLayoutLabel: '易讀布局(防溢出換行)',
|
||||
stackedRowLayoutDesc: '小螢幕或大字時,同一行放不下的按鈕和選項會自動換到下一行,避免橫向擠出螢幕或文字被壓扁。',
|
||||
conservativeLayoutLabel: '保守排版',
|
||||
conservativeLayoutDesc: '除首頁、頂欄與底欄外,設定與功能頁改為單列滿寬,最大程度避免橫向溢出。',
|
||||
system: '跟隨系統',
|
||||
light: '淺色',
|
||||
dark: '深色',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。
|
||||
// 通过 html[data-ol-conservative-layout] + global.css 工具类生效。
|
||||
|
||||
export function applyConservativeLayout(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
document.documentElement.dataset.olConservativeLayout = 'true';
|
||||
} else {
|
||||
delete document.documentElement.dataset.olConservativeLayout;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConservativeLayoutActive(): boolean {
|
||||
return document.documentElement.dataset.olConservativeLayout === 'true';
|
||||
}
|
||||
@@ -110,6 +110,8 @@ export let mockSettings: UserPreferences = {
|
||||
streamingInsertSaveClipboard: true,
|
||||
cursorContextEnabled: false,
|
||||
showOverviewActivityHeatmap: true,
|
||||
stackedRowLayout: false,
|
||||
conservativeLayout: false,
|
||||
autoUpdateCheck: true,
|
||||
historyMaxEntries: null,
|
||||
recordAudioForDebug: false,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
applyStackedLayout,
|
||||
applyStackedLayoutFromPrefs,
|
||||
isStackedLayoutActive,
|
||||
} from './stackedLayout';
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const dataset: Record<string, string> = {};
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: { documentElement: { dataset } },
|
||||
});
|
||||
|
||||
try {
|
||||
applyStackedLayoutFromPrefs(true);
|
||||
assert(dataset.olStackedLayout === 'true', 'enabled preference should set the root layout attribute');
|
||||
assert(isStackedLayoutActive(), 'active query should reflect the root layout attribute');
|
||||
|
||||
applyStackedLayoutFromPrefs(false);
|
||||
assert(!('olStackedLayout' in dataset), 'disabled preference should remove the root layout attribute');
|
||||
assert(!isStackedLayoutActive(), 'active query should become false after removal');
|
||||
|
||||
applyStackedLayout(true);
|
||||
applyStackedLayoutFromPrefs(undefined);
|
||||
assert(!('olStackedLayout' in dataset), 'missing preference should restore the base layout');
|
||||
} finally {
|
||||
if (previousDocument) {
|
||||
Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('stacked layout tests passed');
|
||||
@@ -0,0 +1,20 @@
|
||||
// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。
|
||||
// 通过 html[data-ol-stacked-layout] + global.css 工具类生效。
|
||||
|
||||
export function applyStackedLayout(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
document.documentElement.dataset.olStackedLayout = 'true';
|
||||
} else {
|
||||
delete document.documentElement.dataset.olStackedLayout;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStackedLayoutFromPrefs(
|
||||
stackedRowLayout?: boolean,
|
||||
): void {
|
||||
applyStackedLayout(stackedRowLayout === true);
|
||||
}
|
||||
|
||||
export function isStackedLayoutActive(): boolean {
|
||||
return document.documentElement.dataset.olStackedLayout === 'true';
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const previousPrefs: UserPreferences = {
|
||||
selectionPolishOutputMode: 'directReplace',
|
||||
cursorContextEnabled: false,
|
||||
showOverviewActivityHeatmap: true,
|
||||
stackedRowLayout: false,
|
||||
conservativeLayout: false,
|
||||
defaultMode: 'light',
|
||||
enabledModes: ['raw', 'light', 'structured'],
|
||||
activeStylePackId: '',
|
||||
|
||||
@@ -490,6 +490,10 @@ export interface UserPreferences {
|
||||
cursorContextEnabled: boolean;
|
||||
/** 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,活动计数照常记录。 */
|
||||
showOverviewActivityHeatmap: boolean;
|
||||
/** 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。默认 false。 */
|
||||
stackedRowLayout: boolean;
|
||||
/** 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。默认 false。 */
|
||||
conservativeLayout: boolean;
|
||||
/** 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
|
||||
* Android:开启后自动检查并下载,校验后打开系统安装器。
|
||||
* 桌面:开启后自动检查,发现更新弹窗由用户确认安装。
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
|
||||
function shouldUseMobileLayout(breakpoint: number): boolean {
|
||||
export function shouldUseMobileLayout(breakpoint = 720): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const osQuery = new URLSearchParams(window.location.search).get('os');
|
||||
return osQuery === 'android' || detectOS() === 'android' || window.innerWidth < breakpoint;
|
||||
@@ -23,3 +24,22 @@ export function useMobileLayout(breakpoint = 720): boolean {
|
||||
|
||||
return mobile;
|
||||
}
|
||||
|
||||
/** 易读布局:只表示用户偏好,不混入移动端基础布局。 */
|
||||
export function useReadableLayout(): boolean {
|
||||
const { prefs } = useHotkeySettings();
|
||||
return prefs?.stackedRowLayout === true;
|
||||
}
|
||||
|
||||
/** 排版堆叠:移动端壳层 或 用户开启「易读布局」。用于 grid/flex 换行,不含底栏等壳层逻辑。 */
|
||||
export function useLayoutStack(breakpoint = 720): boolean {
|
||||
const mobile = useMobileLayout(breakpoint);
|
||||
const readable = useReadableLayout();
|
||||
return mobile || readable;
|
||||
}
|
||||
|
||||
/** 保守排版:仅读用户偏好,不与 mobile 联动。 */
|
||||
export function useConservativeLayout(): boolean {
|
||||
const { prefs } = useHotkeySettings();
|
||||
return prefs?.conservativeLayout === true;
|
||||
}
|
||||
@@ -48,12 +48,17 @@ import {
|
||||
type MarketplaceInstallError,
|
||||
} from '../lib/marketplaceInstall';
|
||||
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
|
||||
import { useMobileLayout, useLayoutStack, useConservativeLayout } from '../lib/useMobileLayout';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
type SortMode = 'popular' | 'new' | 'liked';
|
||||
|
||||
export function Marketplace() {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const stackLayout = conservative || baseLayoutStack;
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
|
||||
// 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。
|
||||
@@ -536,10 +541,12 @@ export function Marketplace() {
|
||||
|
||||
{/* 顶部搜索 + 排序 */}
|
||||
<div
|
||||
className="ol-flex-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
alignItems: stackLayout ? 'stretch' : 'center',
|
||||
flexDirection: stackLayout ? 'column' : 'row',
|
||||
padding: '4px 0 14px',
|
||||
}}
|
||||
>
|
||||
@@ -571,7 +578,7 @@ export function Marketplace() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div className="ol-flex-row ol-flex-split" style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sortPills.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -642,7 +649,7 @@ export function Marketplace() {
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
|
||||
<div className="ol-grid-auto-cards" style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
|
||||
<AnimatePresence mode="sync">
|
||||
{visibleItems.map(p => {
|
||||
const isDownloading = downloadingPackId === p.id;
|
||||
@@ -744,7 +751,7 @@ export function Marketplace() {
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{selectedId && (
|
||||
<Modal onClose={() => { setSelectedId(null); setInstallError(null); }}>
|
||||
<Modal zIndex={mobile || stackLayout ? 70 : 50} onClose={() => { setSelectedId(null); setInstallError(null); }}>
|
||||
{detailLoading || !detail ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -872,7 +879,7 @@ export function Marketplace() {
|
||||
{/* 上传选包器 —— zIndex 60 让它叠在「我的发布」(zIndex 50) 之上 */}
|
||||
{showUpload && (
|
||||
<Modal
|
||||
zIndex={60}
|
||||
zIndex={mobile || stackLayout ? 70 : 60}
|
||||
onClose={() => {
|
||||
setShowUpload(false);
|
||||
setUploadOriginPackId(null);
|
||||
@@ -962,7 +969,7 @@ export function Marketplace() {
|
||||
|
||||
{/* 我的发布 · 弹框形态(叠在风格市场页面之上)*/}
|
||||
{showMyPacks && (
|
||||
<Modal onClose={() => setShowMyPacks(false)}>
|
||||
<Modal zIndex={mobile || stackLayout ? 70 : 50} onClose={() => setShowMyPacks(false)}>
|
||||
{/* 顶部一行:搜索 (左) + 用户名/登录 (中) + 关闭 × (右) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
{/* 搜索框 (最左) */}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SavedToast, type SaveToastState } from '../components/SavedToast';
|
||||
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
|
||||
import { useMobileLayout, useLayoutStack, useConservativeLayout } from '../lib/useMobileLayout';
|
||||
|
||||
type BusyAction =
|
||||
| 'loading'
|
||||
@@ -130,6 +131,10 @@ function modeTone(mode: PolishMode): 'default' | 'blue' | 'ok' | 'outline' | 'da
|
||||
|
||||
export function Style() {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const stackLayout = conservative || baseLayoutStack;
|
||||
const { prefs: marketplacePrefs, updatePrefs: updateMarketplacePrefs } = useHotkeySettings();
|
||||
const marketplaceDisplayLogin = (marketplacePrefs?.marketplaceDevLogin ?? '').trim();
|
||||
const [marketplaceSignedIn, setMarketplaceSignedIn] = useState(false);
|
||||
@@ -597,7 +602,15 @@ export function Style() {
|
||||
title={t('style.pack.title')}
|
||||
desc={t('style.pack.desc')}
|
||||
right={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 40 }}>
|
||||
<div className="ol-flex-row ol-flex-push" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: stackLayout ? 'flex-start' : 'flex-end',
|
||||
marginTop: stackLayout ? 0 : 40,
|
||||
width: stackLayout ? '100%' : undefined,
|
||||
}}>
|
||||
{/* 风格市场入口已移到侧栏「风格」展开组(用户拍板);此处不再放按钮。 */}
|
||||
<Btn variant="ghost" icon="refresh" onClick={() => void loadPacks(selectedId)} disabled={busy === 'loading'}>
|
||||
{t('common.refresh')}
|
||||
@@ -614,8 +627,15 @@ export function Style() {
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
<Card padding={0} style={{ overflow: 'hidden', flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: stackLayout ? 14 : 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
|
||||
<div className="ol-flex-row" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: stackLayout ? 'column' : 'row',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)' }}>
|
||||
@@ -655,7 +675,7 @@ export function Style() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div className="ol-flex-row ol-flex-split" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0, width: stackLayout ? '100%' : undefined }}>
|
||||
<div style={{ display: 'inline-flex', padding: 3, borderRadius: 8, background: 'var(--ol-surface-2)', border: '0.5px solid var(--ol-line)' }}>
|
||||
{([
|
||||
['dictation', t('style.pack.dictationTab')],
|
||||
@@ -670,7 +690,7 @@ export function Style() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="ol-thinscroll" style={{ padding: 18, overflow: 'auto', flex: '1 1 0', minHeight: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||
<div className="ol-grid-auto-cards" style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||
<AnimatePresence mode="sync">
|
||||
{bodyPacks.map(pack => {
|
||||
const isBuiltin = pack.kind === 'builtin';
|
||||
@@ -680,15 +700,12 @@ export function Style() {
|
||||
return (
|
||||
<motion.div
|
||||
key={pack.id}
|
||||
layout
|
||||
// 仅当包列表增减时才重算 layout;切换 active(pack.active 变化)
|
||||
// 不再触发整列卡片的 layout 重测,消除切换风格包时的卡顿。
|
||||
layoutDependency={bodyPacks.length}
|
||||
{...(!stackLayout ? { layout: true, layoutDependency: bodyPacks.length } : {})}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.85 }}
|
||||
transition={{
|
||||
layout: { type: 'spring', damping: 25, stiffness: 220 },
|
||||
...(stackLayout ? {} : { layout: { type: 'spring', damping: 25, stiffness: 220 } }),
|
||||
opacity: { duration: 0.2 },
|
||||
scale: { duration: 0.2 }
|
||||
}}
|
||||
@@ -823,11 +840,11 @@ export function Style() {
|
||||
})}
|
||||
<motion.button
|
||||
key="add-new-pack-btn"
|
||||
layout
|
||||
{...(!stackLayout ? { layout: true } : {})}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
layout: { type: 'spring', damping: 25, stiffness: 220 },
|
||||
...(stackLayout ? {} : { layout: { type: 'spring', damping: 25, stiffness: 220 } }),
|
||||
opacity: { duration: 0.2 },
|
||||
scale: { duration: 0.2 }
|
||||
}}
|
||||
@@ -876,6 +893,7 @@ export function Style() {
|
||||
<>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="ol-modal-backdrop"
|
||||
onClick={closeEditor}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -885,9 +903,11 @@ export function Style() {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--ol-overlay-bg)',
|
||||
backdropFilter: 'blur(8px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
|
||||
zIndex: 40,
|
||||
...(mobile ? {} : {
|
||||
backdropFilter: 'blur(8px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
|
||||
}),
|
||||
zIndex: mobile ? 70 : 40,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
@@ -898,7 +918,12 @@ export function Style() {
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 280 }}
|
||||
style={{
|
||||
style={mobile ? {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
zIndex: 71,
|
||||
} : {
|
||||
position: 'fixed',
|
||||
top: 16,
|
||||
right: 16,
|
||||
@@ -914,7 +939,8 @@ export function Style() {
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--ol-shadow-xl)',
|
||||
boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)',
|
||||
borderRadius: mobile ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
@@ -997,7 +1023,7 @@ export function Style() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('style.pack.fieldName')}</span>
|
||||
<input
|
||||
@@ -1043,7 +1069,7 @@ export function Style() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('style.pack.fieldModel')}</span>
|
||||
<input
|
||||
@@ -1148,7 +1174,7 @@ export function Style() {
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('style.pack.metaTitle')}</div>
|
||||
<Pill tone="default" size="sm">{draft.id}</Pill>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
|
||||
<MetaItem label={t('style.pack.metaSource')} value={draft.kind === 'builtin' ? t('style.pack.builtin') : t('style.pack.imported')} />
|
||||
<MetaItem label={t('style.pack.metaBaseMode')} value={t(`style.modes.${draft.baseMode}.name`)} />
|
||||
<MetaItem label={t('style.pack.metaUpdatedAt')} value={draft.updatedAt || '—'} />
|
||||
@@ -1207,7 +1233,7 @@ export function Style() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../lib/useMobileLayout';
|
||||
|
||||
interface PageHeaderProps {
|
||||
kicker?: string;
|
||||
@@ -16,14 +16,18 @@ interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) {
|
||||
const mobile = useMobileLayout();
|
||||
const readable = useReadableLayout();
|
||||
const conservative = useConservativeLayout();
|
||||
const preferenceStack = readable || conservative;
|
||||
const stackLayout = mobile || preferenceStack;
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: mobile ? 12 : 24,
|
||||
marginBottom: mobile ? 16 : 24,
|
||||
flexWrap: mobile ? 'wrap' : 'nowrap',
|
||||
gap: stackLayout ? 12 : mobile ? 12 : 24,
|
||||
marginBottom: stackLayout ? 16 : mobile ? 16 : 24,
|
||||
flexWrap: stackLayout ? 'wrap' : 'nowrap',
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{kicker && (
|
||||
@@ -33,9 +37,17 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade
|
||||
<h1 style={{ margin: 0, fontSize: mobile ? 22 : 26, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)' }}>{title}</h1>
|
||||
{titleRight}
|
||||
</div>
|
||||
{desc && <p style={{ margin: '8px 0 0', fontSize: 13, color: 'var(--ol-ink-3)', maxWidth: 640, lineHeight: 1.55 }}>{desc}</p>}
|
||||
{desc && <p style={{ margin: '8px 0 0', fontSize: 13, color: 'var(--ol-ink-3)', maxWidth: preferenceStack ? undefined : 640, lineHeight: 1.55 }}>{desc}</p>}
|
||||
</div>
|
||||
{right}
|
||||
{right && (
|
||||
preferenceStack ? (
|
||||
<div className="ol-flex-row ol-flex-split" style={{ width: '100%' }}>
|
||||
{right}
|
||||
</div>
|
||||
) : (
|
||||
right
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getPlatformCapabilities, openExternal } from '../../lib/ipc';
|
||||
import type { PlatformCapabilities } from '../../lib/types';
|
||||
import { APP_VERSION_LABEL } from '../../lib/appVersion';
|
||||
import { Card } from '../_atoms';
|
||||
import { useConservativeLayout, useLayoutStack } from '../../lib/useMobileLayout';
|
||||
import { btnGhostStyle, SectionTitle } from './shared';
|
||||
import { CheckUpdateButton } from './CheckUpdateButton';
|
||||
|
||||
@@ -19,6 +20,10 @@ const RELEASE_NOTES_URL = 'https://github.com/Open-Less/openless/releases';
|
||||
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslation();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservativeLayout = useConservativeLayout();
|
||||
const compactLayout = baseLayoutStack || conservativeLayout;
|
||||
const appIconSize = compactLayout ? 36 : 56;
|
||||
const [qqCopied, setQqCopied] = useState(false);
|
||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||
const qqCopiedRef = useRef<number | null>(null);
|
||||
@@ -42,21 +47,43 @@ export function AboutSection() {
|
||||
<>
|
||||
{/* ─── 版本信息 + 检查更新(正式版)─────────────────────────────── */}
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div
|
||||
className="ol-inline-composite"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: compactLayout ? 4 : 14,
|
||||
minWidth: 0,
|
||||
flexWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="AppIcon.png"
|
||||
alt=""
|
||||
style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }}
|
||||
style={{
|
||||
width: appIconSize,
|
||||
height: appIconSize,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 2 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>OpenLess</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--ol-ink-3)',
|
||||
marginTop: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{t('modal.about.tagline')} · {APP_VERSION_LABEL}
|
||||
</div>
|
||||
</div>
|
||||
{/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */}
|
||||
{platformCaps?.supportsAutoUpdate === true && (
|
||||
<CheckUpdateButton channel="stable" />
|
||||
<CheckUpdateButton channel="stable" compact={compactLayout} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type Channel,
|
||||
} from '../../lib/ipc';
|
||||
import { emitSaved } from '../../lib/savedEvent';
|
||||
import { useMobileLayout } from '../../lib/useMobileLayout';
|
||||
import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../../lib/useMobileLayout';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { getPlatformCapabilities } from '../../lib/platform';
|
||||
import { Card } from '../_atoms';
|
||||
@@ -154,6 +154,9 @@ export function ChannelList({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const readable = useReadableLayout();
|
||||
const conservative = useConservativeLayout();
|
||||
const preferenceStack = readable || conservative;
|
||||
const os = detectOS();
|
||||
// 初值 false:getPlatformCapabilities() 的权威值是架构感知的(Apple Silicon /
|
||||
// Intel),以 os === 'mac' 起步会让 Intel Mac 打开下拉时闪现一次 MLX 预设,
|
||||
@@ -452,7 +455,8 @@ export function ChannelList({
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: preferenceStack ? 'column' : 'row',
|
||||
alignItems: preferenceStack ? 'stretch' : 'center',
|
||||
gap: 10,
|
||||
// 左侧补偿 2px 竖条与 0.5px 细边的宽度差,文字基线保持对齐。
|
||||
padding: isActive ? '10px 12px 10px 10.5px' : '10px 12px',
|
||||
@@ -470,6 +474,7 @@ export function ChannelList({
|
||||
transition: draggingId ? undefined : 'opacity 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, flex: 1, width: preferenceStack ? '100%' : undefined }}>
|
||||
<span
|
||||
onPointerDown={e => onDragHandleDown(e, channel.id)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
@@ -500,6 +505,18 @@ export function ChannelList({
|
||||
{channel.lastTest && <span>{relativeTime(channel.lastTest.at, t)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={conservative ? 'ol-conservative-stack' : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: conservative ? 'column' : 'row',
|
||||
alignItems: conservative ? 'flex-start' : 'center',
|
||||
gap: 8,
|
||||
flexWrap: readable ? 'wrap' : 'nowrap',
|
||||
width: preferenceStack ? '100%' : undefined,
|
||||
}}
|
||||
>
|
||||
<VerifyButton
|
||||
channel={channel}
|
||||
testing={Boolean(testingIds[channel.id])}
|
||||
@@ -512,11 +529,13 @@ export function ChannelList({
|
||||
onClick={() => setEditingId(channel.id)}
|
||||
title={t('settings.channels.edit')}
|
||||
aria-label={t('settings.channels.edit')}
|
||||
style={iconBtn}
|
||||
style={{ ...iconBtn, ...(conservative ? { width: 'auto', padding: '0 10px', gap: 6 } : {}) }}
|
||||
>
|
||||
<Icon name="chevRight" size={13} />
|
||||
{conservative && <span style={{ fontSize: 12 }}>{t('settings.channels.edit')}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Icon } from '../../components/Icon';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate';
|
||||
import type { UpdateChannel } from '../../lib/ipc';
|
||||
|
||||
export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
export function CheckUpdateButton({ channel, compact = false }: { channel: UpdateChannel; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const updater = useAutoUpdate();
|
||||
const { status, checking, busy } = updater;
|
||||
@@ -36,16 +36,33 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
<button
|
||||
onClick={() => void updater.checkForUpdates(channel)}
|
||||
disabled={checking || busy}
|
||||
aria-label={compact ? label : undefined}
|
||||
title={
|
||||
failed
|
||||
? (updater.errorMessage ?? t('settings.about.updateError'))
|
||||
: upToDate
|
||||
? t('settings.about.upToDate')
|
||||
: undefined
|
||||
: compact
|
||||
? label
|
||||
: undefined
|
||||
}
|
||||
// 固定宽度 + 内容居中:图标(check ↔ refresh)与 label("检查更新" ↔ "检查中…")
|
||||
// 切换时按钮尺寸不再跳动;图标固定容器保证旋转时绕中心转、不位移。
|
||||
style={{ ...btnGhostStyle, color, opacity: checking || busy ? 0.7 : 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, minWidth: 160 }}
|
||||
// 桌面保留稳定文字宽度;紧凑布局使用带可访问名称的图标按钮。
|
||||
style={{
|
||||
...btnGhostStyle,
|
||||
color,
|
||||
opacity: checking || busy ? 0.7 : 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
boxSizing: 'border-box',
|
||||
width: compact ? 32 : undefined,
|
||||
minWidth: compact ? 32 : 160,
|
||||
maxWidth: compact ? 32 : '100%',
|
||||
minHeight: compact ? 32 : undefined,
|
||||
padding: compact ? 5 : btnGhostStyle.padding,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', width: 14, height: 14, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon
|
||||
@@ -62,6 +79,7 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
<span
|
||||
key={label}
|
||||
style={{
|
||||
display: compact ? 'none' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
// 状态文案("检查更新" ↔ "检查中…" ↔ 结果提示)切换时淡入微滑移,
|
||||
// 与 SelectLite 选中值切换动画同款(ol-select-value-in,global.css)。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { Card } from '../_atoms';
|
||||
import { SettingRow, Toggle } from './shared';
|
||||
|
||||
export function LayoutSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
|
||||
if (!prefs) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
|
||||
{t('settings.layout.title')}
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.theme.stackedRowLayoutLabel')}
|
||||
desc={t('settings.theme.stackedRowLayoutDesc')}
|
||||
>
|
||||
<Toggle
|
||||
on={prefs.stackedRowLayout === true}
|
||||
onToggle={next =>
|
||||
void updatePrefs(current => ({ ...current, stackedRowLayout: next }))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.theme.conservativeLayoutLabel')}
|
||||
desc={t('settings.theme.conservativeLayoutDesc')}
|
||||
>
|
||||
<Toggle
|
||||
on={prefs.conservativeLayout === true}
|
||||
onToggle={next =>
|
||||
void updatePrefs(current => ({ ...current, conservativeLayout: next }))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 权限/连通性面板:麦克风 / 辅助功能 / 全局热键 / Windows IME / 网络。
|
||||
// 内含三个状态 Pill + 适配器名称翻译辅助函数。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AndroidPermissionsPanel } from '@android/components/AndroidPermissionsPanel';
|
||||
import { Icon } from '../../components/Icon';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { NetworkCheckResult } from '../../lib/ipc';
|
||||
import { emitSaved } from '../../lib/savedEvent';
|
||||
import { getPlatformCapabilities } from '../../lib/platform';
|
||||
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
|
||||
import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '@android/lib/androidMicrophonePermission';
|
||||
import type {
|
||||
HotkeyStatus,
|
||||
@@ -41,6 +42,17 @@ export function PermissionsSection() {
|
||||
const [network, setNetwork] = useState<NetworkCheckResult | null>(null);
|
||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||
const { capability } = useHotkeySettings();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const stackLayout = baseLayoutStack || conservative;
|
||||
const permissionActionsStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: conservative ? 'column' : 'row',
|
||||
alignItems: conservative ? 'flex-start' : 'center',
|
||||
justifyContent: stackLayout ? 'flex-start' : 'flex-end',
|
||||
width: '100%', flexWrap: conservative ? 'nowrap' : 'wrap', minWidth: 0, gap: 8,
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
void getPlatformCapabilities().then(setPlatformCaps);
|
||||
@@ -152,7 +164,7 @@ export function PermissionsSection() {
|
||||
)}
|
||||
</div>
|
||||
<SettingRow label={t('settings.permissions.micLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div style={permissionActionsStyle}>
|
||||
<PermissionPill status={microphone} />
|
||||
{microphone === 'noDevice' ? (
|
||||
<Btn variant="ghost" size="sm" onClick={refreshPermissions}>
|
||||
@@ -167,7 +179,7 @@ export function PermissionsSection() {
|
||||
</SettingRow>
|
||||
{capability?.requiresAccessibilityPermission && platformCaps?.platform !== 'android' && (
|
||||
<SettingRow label={t('settings.permissions.accLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div style={permissionActionsStyle}>
|
||||
<PermissionPill status={accessibility} />
|
||||
{accessibility !== 'granted' && accessibility !== 'notApplicable' && accessibility !== 'loading' && (
|
||||
<Btn variant="ghost" size="sm" onClick={reRequestAccessibility}>
|
||||
@@ -189,7 +201,7 @@ export function PermissionsSection() {
|
||||
)}
|
||||
{platformCaps?.supportsDesktopHotkey === true && (
|
||||
<SettingRow label={t('settings.permissions.hotkeyLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap' }}>
|
||||
<div style={permissionActionsStyle}>
|
||||
{hotkey?.message && (
|
||||
<span style={{
|
||||
fontSize: 11.5, color: 'var(--ol-ink-4)',
|
||||
@@ -208,7 +220,7 @@ export function PermissionsSection() {
|
||||
)}
|
||||
{windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && (
|
||||
<SettingRow label={t('settings.permissions.windowsImeLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap' }}>
|
||||
<div style={permissionActionsStyle}>
|
||||
{windowsIme && (
|
||||
<span style={{
|
||||
fontSize: 11.5, color: 'var(--ol-ink-4)',
|
||||
@@ -223,7 +235,7 @@ export function PermissionsSection() {
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label={t('settings.permissions.networkLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<div style={permissionActionsStyle}>
|
||||
{network && network.latencyMs != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{network.latencyMs}ms
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
validateProviderCredentials,
|
||||
} from '../../lib/ipc';
|
||||
import { emitSaved } from '../../lib/savedEvent';
|
||||
import { useMobileLayout } from '../../lib/useMobileLayout';
|
||||
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { SelectLite, type SelectOption } from '../../components/ui/SelectLite';
|
||||
import { Card } from '../_atoms';
|
||||
@@ -55,18 +55,20 @@ function isLocalAsrPreset(id: string): boolean {
|
||||
|
||||
function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const layoutStack = conservative || baseLayoutStack;
|
||||
return (
|
||||
<div
|
||||
title={t('settings.providers.thinkingModeHint')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: mobile ? '1 1 100%' : undefined,
|
||||
flexWrap: mobile ? 'wrap' : 'nowrap',
|
||||
flex: layoutStack ? '1 1 100%' : undefined,
|
||||
flexWrap: layoutStack ? 'wrap' : 'nowrap',
|
||||
gap: 6,
|
||||
paddingLeft: 2,
|
||||
whiteSpace: mobile ? 'normal' : 'nowrap',
|
||||
whiteSpace: layoutStack ? 'normal' : 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
|
||||
@@ -304,7 +306,9 @@ export function ChannelCredentialFields({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const layoutStack = conservative || baseLayoutStack;
|
||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
||||
const [asrModelRevision, setAsrModelRevision] = useState(0);
|
||||
const unifiedBailian = providerType === 'bailian';
|
||||
@@ -421,7 +425,7 @@ export function ChannelCredentialFields({
|
||||
{ value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') },
|
||||
]}
|
||||
ariaLabel={t('settings.providers.volcengineAuthModeLabel')}
|
||||
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 260 }}
|
||||
style={{ ...inputStyle, width: '100%', maxWidth: layoutStack ? '100%' : 260 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
{/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key)
|
||||
@@ -739,7 +743,9 @@ type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
|
||||
|
||||
function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const layoutStack = conservative || baseLayoutStack;
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [status, setStatus] = useState<ProviderToolStatus>('idle');
|
||||
@@ -827,7 +833,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
|
||||
|
||||
return (
|
||||
<SettingRow label={t('settings.providers.toolsLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: mobile ? '100%' : 420 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', width: '100%' }}>
|
||||
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
|
||||
{showFetchModels && (
|
||||
@@ -841,7 +847,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
|
||||
options={models.map(model => ({ value: model, label: model }))}
|
||||
placeholder={t('settings.providers.selectModel')}
|
||||
ariaLabel={t('settings.providers.selectModel')}
|
||||
style={{ flex: mobile ? '1 1 100%' : '1 1 180px', maxWidth: mobile ? '100%' : 220, minWidth: 0 }}
|
||||
style={{ flex: layoutStack ? '1 1 100%' : '1 1 180px', maxWidth: layoutStack ? '100%' : 220, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -904,7 +910,9 @@ interface CredentialFieldProps {
|
||||
|
||||
function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const layoutStack = conservative || baseLayoutStack;
|
||||
const [value, setValue] = useState('');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -1042,8 +1050,8 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
|
||||
|
||||
return (
|
||||
<SettingRow label={label}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', maxWidth: mobile ? '100%' : 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: mobile ? 'wrap' : 'nowrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
|
||||
{options && !customModelMode ? (
|
||||
<SelectLite
|
||||
value={value}
|
||||
@@ -1068,7 +1076,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
|
||||
placeholder={loaded ? placeholder : t('common.loading')}
|
||||
disabled={disabled}
|
||||
ariaLabel={label}
|
||||
style={{ flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
style={{ flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -1078,7 +1086,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
style={{ ...inputStyle, flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
style={{ ...inputStyle, flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
/>
|
||||
)}
|
||||
{options && customModelMode && (
|
||||
@@ -1169,7 +1177,9 @@ const iconBtnStyle: CSSProperties = {
|
||||
*/
|
||||
export function OmniChannelSection() {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobileLayout();
|
||||
const baseLayoutStack = useLayoutStack();
|
||||
const conservative = useConservativeLayout();
|
||||
const layoutStack = conservative || baseLayoutStack;
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const [omniProvider, setOmniProvider] = useState<OmniPresetId>('custom');
|
||||
const [committedOmniProvider, setCommittedOmniProvider] = useState<OmniPresetId>('custom');
|
||||
@@ -1246,7 +1256,7 @@ export function OmniChannelSection() {
|
||||
label={t('settings.providers.pipelineModeLabel')}
|
||||
desc={t('settings.providers.pipelineModeHint')}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: mobile ? 'wrap' : 'nowrap' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
|
||||
<div style={segmentedTrackStyle}>
|
||||
{(['traditional', 'multimodal'] as const).map(mode => (
|
||||
<button
|
||||
@@ -1287,7 +1297,7 @@ export function OmniChannelSection() {
|
||||
label: t(`settings.providers.presets.${p.nameKey}`),
|
||||
}))}
|
||||
ariaLabel={t('settings.providers.providerLabel')}
|
||||
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }}
|
||||
style={{ ...inputStyle, width: '100%', maxWidth: layoutStack ? '100%' : 200 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<CredentialField
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { Tooltip } from "../../components/Tooltip"
|
||||
import { useMobileLayout } from "../../lib/useMobileLayout"
|
||||
import { useMobileLayout, useReadableLayout, useConservativeLayout } from "../../lib/useMobileLayout"
|
||||
|
||||
// 带说明的文字统一加虚线下划线 + help 光标,暗示「悬停可看解释」。
|
||||
const hintableTextStyle: CSSProperties = {
|
||||
@@ -68,6 +68,9 @@ export function SettingRow({
|
||||
controlWidth,
|
||||
}: SettingRowProps) {
|
||||
const mobile = useMobileLayout()
|
||||
const readable = useReadableLayout()
|
||||
const conservative = useConservativeLayout()
|
||||
const stackLayout = mobile || readable || conservative
|
||||
const labelStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
@@ -78,9 +81,9 @@ export function SettingRow({
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: mobile ? "minmax(0, 1fr)" : "minmax(0, 180px) minmax(0, 1fr)",
|
||||
gap: mobile ? 8 : 16,
|
||||
padding: mobile ? "12px 0" : "14px 0",
|
||||
gridTemplateColumns: stackLayout ? "minmax(0, 1fr)" : "minmax(0, 180px) minmax(0, 1fr)",
|
||||
gap: stackLayout ? 8 : 16,
|
||||
padding: stackLayout ? "12px 0" : "14px 0",
|
||||
borderTop: "0.5px solid var(--ol-line-soft)",
|
||||
alignItems: "center",
|
||||
}}
|
||||
@@ -96,14 +99,16 @@ export function SettingRow({
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="ol-flex-row"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
minWidth: 0,
|
||||
width: mobile ? "100%" : controlWidth ?? "auto",
|
||||
width: stackLayout ? "100%" : controlWidth ?? "auto",
|
||||
maxWidth: "100%",
|
||||
flexWrap: mobile ? "wrap" : "nowrap",
|
||||
gap: mobile ? 6 : undefined,
|
||||
flexWrap: stackLayout ? "wrap" : "nowrap",
|
||||
gap: stackLayout ? 6 : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -124,7 +129,10 @@ export function Toggle({
|
||||
onClick={() => onToggle?.(!on)}
|
||||
style={{
|
||||
position: "relative",
|
||||
flex: "0 0 36px",
|
||||
width: 36,
|
||||
minWidth: 36,
|
||||
maxWidth: 36,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ShortcutsSection } from './ShortcutsSection';
|
||||
import { SelectionPolishSection } from './SelectionPolishSection';
|
||||
import { LanguageSection } from './LanguageSection';
|
||||
import { ThemeSection } from './ThemeSection';
|
||||
import { LayoutSection } from './LayoutSection';
|
||||
import { ProvidersSection } from './ChannelList';
|
||||
import { NetworkSection } from './NetworkSection';
|
||||
import { MarketplaceSection } from './MarketplaceSection';
|
||||
@@ -46,6 +47,7 @@ export function GeneralTab() {
|
||||
return (
|
||||
<>
|
||||
<RecordingInputSection />
|
||||
<LayoutSection />
|
||||
{showRemoteInput && <RemoteInputSection />}
|
||||
<SelectionPolishSection />
|
||||
{showDesktopShortcuts && <ShortcutsSection />}
|
||||
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
} from "../lib/types"
|
||||
import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n"
|
||||
import { applyThemeFromPreference } from "../lib/themeMode"
|
||||
import { applyStackedLayoutFromPrefs } from "../lib/stackedLayout"
|
||||
import { applyConservativeLayout } from "../lib/conservativeLayout"
|
||||
import { emitSaved } from "../lib/savedEvent"
|
||||
|
||||
interface HotkeySettingsContextValue {
|
||||
@@ -65,6 +67,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
persistedPrefsRef.current = prefsResult.value
|
||||
setPrefs(prefsResult.value)
|
||||
applyThemeFromPreference(prefsResult.value.themeMode ?? "system")
|
||||
applyStackedLayoutFromPrefs(prefsResult.value.stackedRowLayout)
|
||||
applyConservativeLayout(prefsResult.value.conservativeLayout === true)
|
||||
} else {
|
||||
console.error(
|
||||
"[hotkey-settings] failed to load preferences",
|
||||
@@ -126,6 +130,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
persistedPrefsRef.current = nextPrefs
|
||||
setPrefs(nextPrefs)
|
||||
applyThemeFromPreference(nextPrefs.themeMode ?? "system")
|
||||
applyStackedLayoutFromPrefs(nextPrefs.stackedRowLayout)
|
||||
applyConservativeLayout(nextPrefs.conservativeLayout === true)
|
||||
},
|
||||
)
|
||||
if (cancelled) {
|
||||
@@ -200,6 +206,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
|
||||
if (resolved === current) return
|
||||
setPrefs(resolved)
|
||||
latestPrefsRef.current = resolved
|
||||
applyStackedLayoutFromPrefs(resolved.stackedRowLayout)
|
||||
applyConservativeLayout(resolved.conservativeLayout === true)
|
||||
try {
|
||||
await queueSetSettings(resolved)
|
||||
persistedPrefsRef.current = resolved
|
||||
|
||||
@@ -204,6 +204,78 @@ html[data-ol-platform="android"] .ol-frost::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Android WebView:modal 遮罩禁用 backdrop-filter,避免触摸迟滞与渲染异常。 */
|
||||
html[data-ol-platform="android"] .ol-modal-backdrop {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* ── 易读布局(data-ol-stacked-layout)──────────────────────────────────────────
|
||||
小屏或大字号时强制同行控件换行,避免横向溢出与 flex 压扁文字。 */
|
||||
.ol-flex-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
html[data-ol-stacked-layout="true"] .ol-flex-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
html[data-ol-stacked-layout="true"] .ol-flex-row.ol-flex-split {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-ol-stacked-layout="true"] .ol-flex-row.ol-flex-push {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
html[data-ol-stacked-layout="true"] .ol-grid-auto-cards {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
html[data-ol-stacked-layout="true"] .ol-console-main {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* ── 保守排版(data-ol-conservative-layout)──────────────────────────────────────
|
||||
除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。 */
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-stack > * {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-grid-auto-cards,
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope select,
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope input:not([type="checkbox"]):not([type="radio"]),
|
||||
html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-trigger {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ol-app-shell-bg {
|
||||
background: var(--ol-app-shell-bg);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user