mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
fix(editor): edgeless can't slider with finger (#15091)
fix bug edgeless can't slider with finger <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added mobile immersive edgeless mode with dynamic chrome auto-hide and tap-gesture controls. * Added a mobile zoom ruler UI for edgeless. * **Bug Fixes** * Improved iOS rendering/zoom by applying low-zoom survival behavior, gesture-aware refresh deferral, and effective-DPR canvas scaling. * Fixed iOS webview zoom/bounce and process-termination reload behavior. * Improved placeholder styling with theme-aware colors. * **Chores** * Updated local ignore rules and iOS app build/version configuration. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Memento } from '@toeverything/infra';
|
||||
import {
|
||||
@@ -115,6 +116,9 @@ export class PersistentJSONFileStorage implements Memento {
|
||||
exhaustMapWithTrailing(() => {
|
||||
return fromPromise(async () => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(this.filepath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
this.filepath,
|
||||
JSON.stringify(this.data, null, 2),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -88,8 +88,6 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Plugins;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -309,9 +307,13 @@
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||
@@ -504,7 +506,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
@@ -517,7 +519,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)",
|
||||
);
|
||||
MARKETING_VERSION = 0.26.3;
|
||||
MARKETING_VERSION = 0.26.5;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -540,7 +542,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
@@ -553,7 +555,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)",
|
||||
);
|
||||
MARKETING_VERSION = 0.26.3;
|
||||
MARKETING_VERSION = 0.26.5;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -1,31 +1,55 @@
|
||||
import Capacitor
|
||||
import Intelligents
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
class AFFiNEViewController: CAPBridgeViewController {
|
||||
class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate {
|
||||
var intelligentsButton: IntelligentsButton?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
webView?.allowsBackForwardNavigationGestures = false
|
||||
navigationController?.navigationBar.isHidden = true
|
||||
extendedLayoutIncludesOpaqueBars = false
|
||||
edgesForExtendedLayout = []
|
||||
|
||||
// Disable WKWebView scrollView zoom/bounce to prevent conflict with edgeless canvas gestures
|
||||
webView?.scrollView.minimumZoomScale = 1.0
|
||||
webView?.scrollView.maximumZoomScale = 1.0
|
||||
webView?.scrollView.bouncesZoom = false
|
||||
webView?.scrollView.bounces = false
|
||||
webView?.scrollView.pinchGestureRecognizer?.isEnabled = false
|
||||
webView?.scrollView.delegate = self
|
||||
|
||||
// Inject viewport meta to prevent WKWebView smart zoom
|
||||
let viewportScript = """
|
||||
(function() {
|
||||
function setViewport() {
|
||||
var meta = document.querySelector('meta[name="viewport"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'viewport';
|
||||
(document.head || document.documentElement).appendChild(meta);
|
||||
}
|
||||
meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover';
|
||||
}
|
||||
if (document.head) {
|
||||
setViewport();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', setViewport);
|
||||
}
|
||||
})();
|
||||
"""
|
||||
webView?.configuration.userContentController.addUserScript(
|
||||
WKUserScript(source: viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||
)
|
||||
|
||||
let intelligentsButton = installIntelligentsButton()
|
||||
intelligentsButton.delegate = self
|
||||
self.intelligentsButton = intelligentsButton
|
||||
dismissIntelligentsButton()
|
||||
}
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let configuration = super.webViewConfiguration(for: instanceConfiguration)
|
||||
return configuration
|
||||
}
|
||||
|
||||
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
|
||||
super.webView(with: frame, configuration: configuration)
|
||||
}
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
let plugins: [CAPPlugin] = [
|
||||
AuthPlugin(),
|
||||
@@ -56,7 +80,7 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
private func checkEligibilityOfIntelligent() {
|
||||
guard !isCheckingIntelligentEligibility else { return }
|
||||
assert(intelligentsButton != nil)
|
||||
guard intelligentsButton?.isHidden ?? false else { return } // already eligible
|
||||
guard intelligentsButton?.isHidden ?? false else { return }
|
||||
isCheckingIntelligentEligibility = true
|
||||
IntelligentContext.shared.webView = webView
|
||||
IntelligentContext.shared.preparePresent { [self] result in
|
||||
@@ -75,4 +99,31 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
scrollView.zoomScale = 1.0
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
if scrollView.contentOffset != .zero {
|
||||
scrollView.contentOffset = .zero
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Web Content Process Crash Recovery
|
||||
|
||||
// NOTE: Capacitor's CAPBridgeViewController owns the WKWebView
|
||||
// navigationDelegate (it assigns its own WebViewDelegationHandler), so this
|
||||
// override is NOT called in practice — Capacitor's handler logs
|
||||
// "⚡️ WebView process terminated" and reloads instead. Kept as defensive
|
||||
// fallback, matching the prior baseline behavior.
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
webView.reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>10</string>
|
||||
<string>17</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
@@ -42,7 +42,7 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
|
||||
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIImageName</key>
|
||||
@@ -69,10 +69,10 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+4
-4
@@ -171,16 +171,16 @@ private extension ChatManager {
|
||||
let uploadableAttachments: [CopilotAttachmentUpload] = [
|
||||
editorData.fileAttachments.map { file -> CopilotAttachmentUpload in
|
||||
.init(
|
||||
originalName: file.name,
|
||||
data: file.data ?? .init(),
|
||||
mimeType: mimeType(text: file.name),
|
||||
data: file.data ?? .init()
|
||||
originalName: file.name
|
||||
)
|
||||
},
|
||||
editorData.imageAttachments.map { image -> CopilotAttachmentUpload in
|
||||
.init(
|
||||
originalName: "image.jpg",
|
||||
data: image.imageData,
|
||||
mimeType: mimeType(pathExtension: "jpg"),
|
||||
data: image.imageData
|
||||
originalName: "image.jpg"
|
||||
)
|
||||
},
|
||||
].flatMap(\.self)
|
||||
|
||||
@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
|
||||
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: a5bf59e09f9dd82694fdcca4d107b4d215ac470f
|
||||
CapacitorApp: 3ddbd30ac18c321531c3da5e707b60873d89dd60
|
||||
CapacitorBrowser: 66aa8ff09cdca2a327ce464b113b470e6f667753
|
||||
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
|
||||
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
|
||||
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
|
||||
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
|
||||
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
|
||||
CapacitorKeyboard: 45cae3956a6f4fb1753f9a4df3e884aeaed8fe82
|
||||
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
|
||||
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
|
||||
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
|
||||
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
|
||||
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
||||
|
||||
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
|
||||
|
||||
@@ -21,6 +21,8 @@ const config: CapacitorConfig & AppConfig = {
|
||||
scheme: 'AFFiNE',
|
||||
path: '.',
|
||||
webContentsDebuggingEnabled: true,
|
||||
// Silence Capacitor's bridge logging (⚡️ TO JS / ⚡️ To Native -> / ⚡️ [log]).
|
||||
loggingBehavior: 'none',
|
||||
},
|
||||
server: {
|
||||
// url: 'http://localhost:8080',
|
||||
|
||||
@@ -1,3 +1,88 @@
|
||||
import '@affine/core/bootstrap/browser';
|
||||
import '@affine/core/bootstrap/cleanup';
|
||||
import './proxy';
|
||||
|
||||
import { viewportRuntimeConfig } from '@blocksuite/affine/std/gfx';
|
||||
|
||||
// iOS WKWebView terminates the web content process when edgeless compositing
|
||||
// memory (GPU-side IOSurface tiles) spikes. Two distinct triggers exist:
|
||||
// 1. Resting canvas pixel memory — bounded by CANVAS_DPR_CAP_BY_ZOOM below.
|
||||
// 2. The transient GPU/DOM churn of a *fast* gesture at extreme zoom-out,
|
||||
// where the whole document composites at once.
|
||||
//
|
||||
// These overrides are applied once at module load, before any editor or
|
||||
// readonly preview mounts, so every Viewport instance is constructed with the
|
||||
// mobile-safe limits. Setting them at construction (rather than mutating a live
|
||||
// Viewport afterward) avoids both the race condition and the wrong-instance
|
||||
// problem that previously left the preview viewport on desktop defaults.
|
||||
//
|
||||
// Strategy (multi-layer, stability first):
|
||||
// - The dpr cap (below) is the real memory lever: canvas backing-store memory
|
||||
// scales with dpr^2, so forcing dpr 1 across the zoom-out range is what
|
||||
// keeps the compositing budget bounded and stops the web process crashing.
|
||||
// - ZOOM_MIN 0.4 bounds how small content can get (and keeps the live zoom in
|
||||
// the dpr-1 bucket); it is a guardrail, not the primary crash fix.
|
||||
// - OVERSCAN_RATIO pre-rasterizes a margin around the visible area on the
|
||||
// *canvas* path, so a pan/zoom moves into content that is *already* painted
|
||||
// instead of blanking out and waiting for the post-gesture refresh. This is
|
||||
// what fixes "connectors/elements vanish for 1-2s". Canvas overscan grows
|
||||
// backing-store area, so keep it modest and rely on the dpr cap to bound
|
||||
// mobile memory.
|
||||
// - OVERSCAN_RATIO_BLOCK is the *separate* knob for DOM block mounting, which
|
||||
// is expensive: each mounted block is its own composited layer subtree, so
|
||||
// enlarging this multiplies resident memory and is what drives the iOS
|
||||
// jetsam kill. On-device diagnostics showed the active-block count doubling
|
||||
// (~16 → ~32) right before each crash when block mounting shared the wide
|
||||
// canvas margin. Kept at 0 so blocks mount on the exact visible bound while
|
||||
// connectors still pre-paint via the wider canvas margin above.
|
||||
viewportRuntimeConfig.ZOOM_MIN = 0.4;
|
||||
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD = 60;
|
||||
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL = 300;
|
||||
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||
|
||||
// Pre-paint a 20% margin on every side of the viewport for the *canvas* render
|
||||
// path. This keeps nearby connectors/shapes warm during orientation and zoom
|
||||
// gestures, but trims the backing-store and paint budget versus the previous
|
||||
// 35% setting so low-zoom survival mode has less work to recover from on iOS.
|
||||
viewportRuntimeConfig.OVERSCAN_RATIO = 0.2;
|
||||
|
||||
// Keep DOM block mounting on the exact visible bound (no overscan). Each mounted
|
||||
// block adds a composited layer subtree to the WebContent process; widening this
|
||||
// is what doubled the active-block count (~16 → ~32) and triggered the iOS
|
||||
// jetsam memory kill in on-device logs. Connectors/elements still pre-paint via
|
||||
// the generous canvas OVERSCAN_RATIO above, so visibility is preserved without
|
||||
// paying the block-mounting memory cost.
|
||||
viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK = 0;
|
||||
|
||||
// After a gesture ends, blocks and canvases are repainted once. The default
|
||||
// 800ms felt sluggish on device (elements/connectors took ~3-4s to settle once
|
||||
// the trailing 200ms panning/zooming debounce and rescheduling were factored
|
||||
// in). Shorten the post-gesture refresh so the total settle time — ~200ms
|
||||
// debounce + this delay — lands under ~500ms. Both the canvas and block timers
|
||||
// read this same value, so connectors and elements reappear together instead of
|
||||
// staggered.
|
||||
viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY = 220;
|
||||
|
||||
// At far-out zoom each block is tiny on screen, so a full retina backing store
|
||||
// (width * devicePixelRatio) is wasted pixels — and on iOS that waste is what
|
||||
// pushes WKWebView's compositing budget over the edge and crashes the web
|
||||
// content process during pan. Cap the canvas backing-store dpr the further out
|
||||
// we zoom: the smaller the content, the less resolution it needs.
|
||||
//
|
||||
// Canvas memory scales with backing-store area and dpr^2. With a fixed viewport
|
||||
// and overscan ratio, the dpr bucket dominates: on-device diagnostics showed
|
||||
// zoom 0.4 with dpr 2 hitting ~7.2 mp and crashing on the first fast zoom-out;
|
||||
// the same scene at dpr 1 sits near ~1.8 mp and is stable. Raising ZOOM_MIN
|
||||
// alone cannot fix it — it only moves the zoom between buckets.
|
||||
//
|
||||
// Therefore force dpr 1 for the entire mobile zoom-out range (zoom < 0.5, which
|
||||
// covers the 0.4 floor) to keep the compositing budget bounded. Connectors are
|
||||
// slightly thinner at the floor as a result; stability is the hard requirement
|
||||
// here, so that trade is accepted. dpr 2 is kept for the near-1.0 range where
|
||||
// content is large and crispness matters. Buckets are checked low-to-high; the
|
||||
// first matching `zoom < threshold` wins.
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
|
||||
@@ -19,9 +19,11 @@ import type { AppTabLink } from './type';
|
||||
export const AppTabs = ({
|
||||
background,
|
||||
fixed = true,
|
||||
hidden = false,
|
||||
}: {
|
||||
background?: string;
|
||||
fixed?: boolean;
|
||||
hidden?: boolean;
|
||||
}) => {
|
||||
const virtualKeyboardService = useService(VirtualKeyboardService);
|
||||
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
|
||||
@@ -47,7 +49,8 @@ export const AppTabs = ({
|
||||
...assignInlineVars({
|
||||
[styles.appTabsBackground]: background,
|
||||
}),
|
||||
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
|
||||
visibility: hidden || virtualKeyboardVisible ? 'hidden' : 'visible',
|
||||
pointerEvents: hidden || virtualKeyboardVisible ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<ul className={styles.appTabsInner} role="tablist">
|
||||
|
||||
@@ -8,6 +8,13 @@ export const root = style({
|
||||
minHeight: '100dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
selectors: {
|
||||
'&:has([data-mode="edgeless"])': {
|
||||
height: '100dvh',
|
||||
maxHeight: '100dvh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
@@ -76,6 +83,10 @@ export const affineDocViewport = style({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
containerType: 'normal',
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -122,3 +133,27 @@ export const journalIconButton = style({
|
||||
export const journalDatePicker = style({
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
// When edgeless mode is active, prevent document-level scrolling
|
||||
// so native scrollView pan gestures don't scroll the page away from the canvas
|
||||
globalStyle('html:has([data-lock-document-scroll="true"])', {
|
||||
overflow: 'hidden',
|
||||
height: '100dvh',
|
||||
overscrollBehavior: 'none',
|
||||
});
|
||||
|
||||
globalStyle('body:has([data-lock-document-scroll="true"])', {
|
||||
height: '100dvh',
|
||||
minHeight: '100dvh',
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
});
|
||||
|
||||
globalStyle('body:has([data-lock-document-scroll="true"]):has(>#app-tabs)', {
|
||||
paddingBottom: 0,
|
||||
});
|
||||
|
||||
// Prevent native touch handling on edgeless viewport so canvas handles all gestures
|
||||
globalStyle('[data-mode="edgeless"] .affine-edgeless-viewport', {
|
||||
touchAction: 'none',
|
||||
});
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import * as immersiveModule from './mobile-detail-page.immersive';
|
||||
import {
|
||||
getImmersiveZoomToolbarBottom,
|
||||
isImmersiveTapTarget,
|
||||
isLandscapeWindow,
|
||||
isTapWithinSlop,
|
||||
shouldEnableEdgelessImmersive,
|
||||
shouldLockEdgelessDocumentScroll,
|
||||
shouldShowMobileDetailPageTitle,
|
||||
shouldTrackMobileDetailPageTitleScroll,
|
||||
} from './mobile-detail-page.immersive';
|
||||
|
||||
describe('mobile detail page immersive helpers', () => {
|
||||
test('enables immersive mode only for edgeless landscape', () => {
|
||||
expect(
|
||||
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: true })
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldEnableEdgelessImmersive({ mode: 'page', isLandscape: true })
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: false })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('treats window as landscape only when media query and geometry agree', () => {
|
||||
expect(
|
||||
isLandscapeWindow({
|
||||
width: 844,
|
||||
height: 390,
|
||||
matchesLandscape: true,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isLandscapeWindow({
|
||||
width: 390,
|
||||
height: 844,
|
||||
matchesLandscape: true,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isLandscapeWindow({
|
||||
width: 844,
|
||||
height: 390,
|
||||
matchesLandscape: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('marks mismatched orientation signals as unsettled so immersive mode can retry the first rotation sample', () => {
|
||||
expect('getLandscapeWindowMeasurement' in immersiveModule).toBe(true);
|
||||
|
||||
const getLandscapeWindowMeasurement = (
|
||||
immersiveModule as {
|
||||
getLandscapeWindowMeasurement: (params: {
|
||||
width: number;
|
||||
height: number;
|
||||
matchesLandscape: boolean;
|
||||
}) => {
|
||||
isLandscape: boolean;
|
||||
settled: boolean;
|
||||
};
|
||||
}
|
||||
).getLandscapeWindowMeasurement;
|
||||
|
||||
expect(
|
||||
getLandscapeWindowMeasurement({
|
||||
width: 844,
|
||||
height: 390,
|
||||
matchesLandscape: false,
|
||||
})
|
||||
).toEqual({
|
||||
isLandscape: false,
|
||||
settled: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
getLandscapeWindowMeasurement({
|
||||
width: 390,
|
||||
height: 844,
|
||||
matchesLandscape: true,
|
||||
})
|
||||
).toEqual({
|
||||
isLandscape: false,
|
||||
settled: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
getLandscapeWindowMeasurement({
|
||||
width: 844,
|
||||
height: 390,
|
||||
matchesLandscape: true,
|
||||
})
|
||||
).toEqual({
|
||||
isLandscape: true,
|
||||
settled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores taps from edgeless toolbar chrome targets', () => {
|
||||
const toolbar = document.createElement('edgeless-toolbar-widget');
|
||||
const toolbarButton = document.createElement('button');
|
||||
toolbar.append(toolbarButton);
|
||||
|
||||
const zoomToolbar = document.createElement('div');
|
||||
zoomToolbar.className = 'edgeless-zoom-toolbar-container';
|
||||
const zoomButton = document.createElement('button');
|
||||
zoomToolbar.append(zoomButton);
|
||||
|
||||
const selectedRect = document.createElement('div');
|
||||
selectedRect.className = 'affine-edgeless-selected-rect';
|
||||
const resizeHandle = document.createElement('div');
|
||||
selectedRect.append(resizeHandle);
|
||||
|
||||
const canvas = document.createElement('div');
|
||||
|
||||
document.body.append(toolbar, zoomToolbar, selectedRect, canvas);
|
||||
|
||||
expect(isImmersiveTapTarget(toolbarButton)).toBe(false);
|
||||
expect(isImmersiveTapTarget(zoomButton)).toBe(false);
|
||||
expect(isImmersiveTapTarget(resizeHandle)).toBe(false);
|
||||
expect(isImmersiveTapTarget(canvas)).toBe(true);
|
||||
expect(isImmersiveTapTarget(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts only small pointer movement as a tap', () => {
|
||||
expect(
|
||||
isTapWithinSlop(
|
||||
{ clientX: 100, clientY: 200 },
|
||||
{ clientX: 104, clientY: 205 }
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isTapWithinSlop(
|
||||
{ clientX: 100, clientY: 200 },
|
||||
{ clientX: 120, clientY: 205 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('raises zoom toolbar above tab bar only when immersive chrome is visible', () => {
|
||||
expect(
|
||||
getImmersiveZoomToolbarBottom({
|
||||
immersive: true,
|
||||
chromeVisible: false,
|
||||
})
|
||||
).toBe('10px');
|
||||
|
||||
expect(
|
||||
getImmersiveZoomToolbarBottom({
|
||||
immersive: true,
|
||||
chromeVisible: true,
|
||||
tabBarOffset: 'var(--appTabSafeArea)',
|
||||
})
|
||||
).toBe('calc(10px + var(--appTabSafeArea))');
|
||||
|
||||
expect(
|
||||
getImmersiveZoomToolbarBottom({
|
||||
immersive: false,
|
||||
chromeVisible: true,
|
||||
tabBarOffset: 'var(--appTabSafeArea)',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('locks document scroll whenever edgeless mode is active', () => {
|
||||
expect(shouldLockEdgelessDocumentScroll('edgeless')).toBe(true);
|
||||
expect(shouldLockEdgelessDocumentScroll('page')).toBe(false);
|
||||
});
|
||||
|
||||
test('tracks title scroll only in page mode', () => {
|
||||
expect(shouldTrackMobileDetailPageTitleScroll('page')).toBe(true);
|
||||
expect(shouldTrackMobileDetailPageTitleScroll('edgeless')).toBe(false);
|
||||
});
|
||||
|
||||
test('shows title only after crossing the existing scroll threshold', () => {
|
||||
expect(shouldShowMobileDetailPageTitle(157)).toBe(false);
|
||||
expect(shouldShowMobileDetailPageTitle(158)).toBe(true);
|
||||
expect(shouldShowMobileDetailPageTitle(240)).toBe(true);
|
||||
});
|
||||
});
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
export const EDGELESS_IMMERSIVE_TAP_SLOP = 8;
|
||||
|
||||
const IMMERSIVE_TAP_EXCLUDE_SELECTORS = [
|
||||
'edgeless-toolbar-widget',
|
||||
'.edgeless-toolbar-container',
|
||||
'affine-edgeless-zoom-toolbar-widget',
|
||||
'.edgeless-zoom-toolbar-container',
|
||||
'.affine-edgeless-selected-rect',
|
||||
].join(', ');
|
||||
|
||||
export function getLandscapeWindowMeasurement({
|
||||
width,
|
||||
height,
|
||||
matchesLandscape,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
matchesLandscape: boolean;
|
||||
}) {
|
||||
const geometryLandscape = width > height;
|
||||
|
||||
return {
|
||||
isLandscape: matchesLandscape && geometryLandscape,
|
||||
settled: matchesLandscape === geometryLandscape,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLandscapeWindow({
|
||||
width,
|
||||
height,
|
||||
matchesLandscape,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
matchesLandscape: boolean;
|
||||
}) {
|
||||
return getLandscapeWindowMeasurement({
|
||||
width,
|
||||
height,
|
||||
matchesLandscape,
|
||||
}).isLandscape;
|
||||
}
|
||||
|
||||
export function shouldEnableEdgelessImmersive({
|
||||
mode,
|
||||
isLandscape,
|
||||
}: {
|
||||
mode: 'page' | 'edgeless';
|
||||
isLandscape: boolean;
|
||||
}) {
|
||||
return mode === 'edgeless' && isLandscape;
|
||||
}
|
||||
|
||||
export function shouldLockEdgelessDocumentScroll(mode: 'page' | 'edgeless') {
|
||||
return mode === 'edgeless';
|
||||
}
|
||||
|
||||
export function shouldTrackMobileDetailPageTitleScroll(
|
||||
mode: 'page' | 'edgeless'
|
||||
) {
|
||||
return mode === 'page';
|
||||
}
|
||||
|
||||
export function shouldShowMobileDetailPageTitle(scrollY: number) {
|
||||
return scrollY >= 158;
|
||||
}
|
||||
|
||||
export function isImmersiveTapTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !target.closest(IMMERSIVE_TAP_EXCLUDE_SELECTORS);
|
||||
}
|
||||
|
||||
export function isTapWithinSlop(
|
||||
start: { clientX: number; clientY: number },
|
||||
end: { clientX: number; clientY: number },
|
||||
slop = EDGELESS_IMMERSIVE_TAP_SLOP
|
||||
) {
|
||||
return (
|
||||
Math.abs(start.clientX - end.clientX) <= slop &&
|
||||
Math.abs(start.clientY - end.clientY) <= slop
|
||||
);
|
||||
}
|
||||
|
||||
export function getImmersiveZoomToolbarBottom({
|
||||
immersive,
|
||||
chromeVisible,
|
||||
tabBarOffset,
|
||||
}: {
|
||||
immersive: boolean;
|
||||
chromeVisible: boolean;
|
||||
tabBarOffset?: string;
|
||||
}) {
|
||||
if (!immersive) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (chromeVisible && tabBarOffset) {
|
||||
return `calc(10px + ${tabBarOffset})`;
|
||||
}
|
||||
|
||||
return '10px';
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-he
|
||||
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
||||
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
|
||||
import { PageHeader } from '@affine/core/mobile/components';
|
||||
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
|
||||
import { AIButtonService } from '@affine/core/modules/ai-button';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
@@ -36,17 +35,44 @@ import {
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { AppTabs } from '../../../components';
|
||||
import { globalVars } from '../../../styles/variables.css';
|
||||
import { JournalConflictBlock } from './journal-conflict-block';
|
||||
import { JournalDatePicker } from './journal-date-picker';
|
||||
import * as styles from './mobile-detail-page.css';
|
||||
import {
|
||||
getImmersiveZoomToolbarBottom,
|
||||
getLandscapeWindowMeasurement,
|
||||
isImmersiveTapTarget,
|
||||
isLandscapeWindow,
|
||||
isTapWithinSlop,
|
||||
shouldEnableEdgelessImmersive,
|
||||
shouldLockEdgelessDocumentScroll,
|
||||
shouldShowMobileDetailPageTitle,
|
||||
shouldTrackMobileDetailPageTitleScroll,
|
||||
} from './mobile-detail-page.immersive';
|
||||
import { PageHeaderMenuButton } from './page-header-more-button';
|
||||
import { PageHeaderShareButton } from './page-header-share-button';
|
||||
|
||||
const DetailPageImpl = () => {
|
||||
type ImmersiveTapHandlers = {
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onPointerCancel: () => void;
|
||||
};
|
||||
|
||||
const DetailPageImpl = ({
|
||||
immersive,
|
||||
chromeVisible,
|
||||
immersiveTapHandlers,
|
||||
}: {
|
||||
immersive: boolean;
|
||||
chromeVisible: boolean;
|
||||
immersiveTapHandlers?: ImmersiveTapHandlers;
|
||||
}) => {
|
||||
const {
|
||||
editorService,
|
||||
docService,
|
||||
@@ -170,7 +196,7 @@ const DetailPageImpl = () => {
|
||||
|
||||
editor.bindEditorContainer(
|
||||
editorContainer,
|
||||
(editorContainer as any).docTitle, // set from proxy
|
||||
editorContainer.docTitle,
|
||||
scrollViewportRef.current
|
||||
);
|
||||
|
||||
@@ -189,19 +215,36 @@ const DetailPageImpl = () => {
|
||||
!enableKeyboardToolbar ||
|
||||
(mode === 'edgeless' && !enableEdgelessEditing);
|
||||
|
||||
const immersiveZoomToolbarBottom = getImmersiveZoomToolbarBottom({
|
||||
immersive,
|
||||
chromeVisible,
|
||||
tabBarOffset: globalVars.appTabSafeArea,
|
||||
});
|
||||
const lockDocumentScroll = shouldLockEdgelessDocumentScroll(mode);
|
||||
|
||||
const immersiveViewportStyle = immersiveZoomToolbarBottom
|
||||
? ({
|
||||
'--affine-edgeless-zoom-toolbar-bottom': immersiveZoomToolbarBottom,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<FrameworkScope scope={editor.scope}>
|
||||
<div className={styles.mainContainer}>
|
||||
<div
|
||||
data-mode={mode}
|
||||
data-lock-document-scroll={lockDocumentScroll ? 'true' : undefined}
|
||||
ref={scrollViewportRef}
|
||||
style={immersiveViewportStyle}
|
||||
className={clsx(
|
||||
'affine-page-viewport',
|
||||
styles.affineDocViewport,
|
||||
styles.editorContainer
|
||||
)}
|
||||
onPointerDown={immersiveTapHandlers?.onPointerDown}
|
||||
onPointerUp={immersiveTapHandlers?.onPointerUp}
|
||||
onPointerCancel={immersiveTapHandlers?.onPointerCancel}
|
||||
>
|
||||
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
||||
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
|
||||
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
||||
</AffineErrorBoundary>
|
||||
@@ -228,7 +271,262 @@ const skeletonWithBack = getSkeleton(true);
|
||||
const notFound = getNotFound(false);
|
||||
const notFoundWithBack = getNotFound(true);
|
||||
|
||||
const checkShowTitle = () => window.scrollY >= 158;
|
||||
const getShouldShowTitle = () =>
|
||||
shouldShowMobileDetailPageTitle(window.scrollY);
|
||||
|
||||
const LANDSCAPE_MEASUREMENT_MAX_RETRIES = 4;
|
||||
|
||||
const getIsLandscape = () =>
|
||||
isLandscapeWindow({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
matchesLandscape: window.matchMedia('(orientation: landscape)').matches,
|
||||
});
|
||||
|
||||
const MobileDetailPageHeader = ({
|
||||
date,
|
||||
fromTab,
|
||||
title,
|
||||
allJournalDates,
|
||||
handleDateChange,
|
||||
trackScrollTitle,
|
||||
}: {
|
||||
date?: string;
|
||||
fromTab: boolean;
|
||||
title?: string;
|
||||
allJournalDates: Set<string | null | undefined>;
|
||||
handleDateChange: (date: string) => void;
|
||||
trackScrollTitle: boolean;
|
||||
}) => {
|
||||
const [showTitle, setShowTitle] = useState(getShouldShowTitle);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackScrollTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const updateShowTitle = () => {
|
||||
frame = 0;
|
||||
setShowTitle(prev => {
|
||||
const next = getShouldShowTitle();
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
frame = window.requestAnimationFrame(updateShowTitle);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [trackScrollTitle]);
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
back={!fromTab}
|
||||
className={styles.header}
|
||||
contentClassName={styles.headerContent}
|
||||
suffix={
|
||||
<>
|
||||
<PageHeaderShareButton />
|
||||
<PageHeaderMenuButton />
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
date ? (
|
||||
<JournalDatePicker
|
||||
date={date}
|
||||
onChange={handleDateChange}
|
||||
withDotDates={allJournalDates}
|
||||
className={styles.journalDatePicker}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
bottomSpacer={94}
|
||||
>
|
||||
<span data-show={!!date || showTitle} className={styles.headerTitle}>
|
||||
{date
|
||||
? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })
|
||||
: title}
|
||||
</span>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileDetailPageContent = ({
|
||||
pageId,
|
||||
date,
|
||||
fromTab,
|
||||
title,
|
||||
allJournalDates,
|
||||
handleDateChange,
|
||||
}: {
|
||||
pageId: string;
|
||||
date?: string;
|
||||
fromTab: boolean;
|
||||
title?: string;
|
||||
allJournalDates: Set<string | null | undefined>;
|
||||
handleDateChange: (date: string) => void;
|
||||
}) => {
|
||||
const editor = useService(EditorService).editor;
|
||||
const mode = useLiveData(editor.mode$);
|
||||
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const tapStateRef = useRef<{
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
tappable: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const immersive = shouldEnableEdgelessImmersive({ mode, isLandscape });
|
||||
const trackScrollTitle = shouldTrackMobileDetailPageTitleScroll(mode);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(orientation: landscape)');
|
||||
let frame = 0;
|
||||
let disposed = false;
|
||||
let remainingRetries = 0;
|
||||
|
||||
const sampleLandscape = () => {
|
||||
frame = 0;
|
||||
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurement = getLandscapeWindowMeasurement({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
matchesLandscape: mediaQuery.matches,
|
||||
});
|
||||
|
||||
setIsLandscape(prev => {
|
||||
const next = measurement.isLandscape;
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
|
||||
if (!measurement.settled && remainingRetries > 0) {
|
||||
remainingRetries -= 1;
|
||||
frame = window.requestAnimationFrame(sampleLandscape);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLandscape = () => {
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
remainingRetries = LANDSCAPE_MEASUREMENT_MAX_RETRIES;
|
||||
frame = window.requestAnimationFrame(sampleLandscape);
|
||||
};
|
||||
|
||||
updateLandscape();
|
||||
window.addEventListener('resize', updateLandscape);
|
||||
mediaQuery.addEventListener('change', updateLandscape);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
window.removeEventListener('resize', updateLandscape);
|
||||
mediaQuery.removeEventListener('change', updateLandscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setChromeVisible(!immersive);
|
||||
tapStateRef.current = null;
|
||||
}, [immersive, pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!immersive || !chromeVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setChromeVisible(false);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [chromeVisible, immersive]);
|
||||
|
||||
const immersiveTapHandlers = useMemo<ImmersiveTapHandlers | undefined>(() => {
|
||||
if (!immersive) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
onPointerDown: event => {
|
||||
tapStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
tappable: isImmersiveTapTarget(event.target),
|
||||
};
|
||||
},
|
||||
onPointerUp: event => {
|
||||
const tapState = tapStateRef.current;
|
||||
tapStateRef.current = null;
|
||||
|
||||
if (
|
||||
!tapState ||
|
||||
tapState.pointerId !== event.pointerId ||
|
||||
!tapState.tappable ||
|
||||
!isTapWithinSlop(tapState, event)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChromeVisible(visible => !visible);
|
||||
},
|
||||
onPointerCancel: () => {
|
||||
tapStateRef.current = null;
|
||||
},
|
||||
};
|
||||
}, [immersive]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!immersive || chromeVisible) && (
|
||||
<MobileDetailPageHeader
|
||||
date={date}
|
||||
fromTab={fromTab}
|
||||
title={title}
|
||||
allJournalDates={allJournalDates}
|
||||
handleDateChange={handleDateChange}
|
||||
trackScrollTitle={trackScrollTitle}
|
||||
/>
|
||||
)}
|
||||
<JournalConflictBlock date={date} />
|
||||
<DetailPageImpl
|
||||
immersive={immersive}
|
||||
chromeVisible={chromeVisible}
|
||||
immersiveTapHandlers={immersiveTapHandlers}
|
||||
/>
|
||||
<AppTabs
|
||||
background={cssVarV2('layer/background/primary')}
|
||||
hidden={immersive && !chromeVisible}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileDetailPage = ({
|
||||
pageId,
|
||||
@@ -240,7 +538,6 @@ const MobileDetailPage = ({
|
||||
const docDisplayMetaService = useService(DocDisplayMetaService);
|
||||
const journalService = useService(JournalService);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const [showTitle, setShowTitle] = useState(checkShowTitle);
|
||||
const title = useLiveData(docDisplayMetaService.title$(pageId));
|
||||
|
||||
const canAccess = useGuard('Doc_Read', pageId);
|
||||
@@ -265,11 +562,6 @@ const MobileDetailPage = ({
|
||||
[fromTab, journalService, workbench]
|
||||
);
|
||||
|
||||
useGlobalEvent(
|
||||
'scroll',
|
||||
useCallback(() => setShowTitle(checkShowTitle()), [])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<DetailPageWrapper
|
||||
@@ -278,37 +570,15 @@ const MobileDetailPage = ({
|
||||
pageId={pageId}
|
||||
canAccess={canAccess}
|
||||
>
|
||||
<PageHeader
|
||||
back={!fromTab}
|
||||
className={styles.header}
|
||||
contentClassName={styles.headerContent}
|
||||
suffix={
|
||||
<>
|
||||
<PageHeaderShareButton />
|
||||
<PageHeaderMenuButton />
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
date ? (
|
||||
<JournalDatePicker
|
||||
date={date}
|
||||
onChange={handleDateChange}
|
||||
withDotDates={allJournalDates}
|
||||
className={styles.journalDatePicker}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
bottomSpacer={94}
|
||||
>
|
||||
<span data-show={!!date || showTitle} className={styles.headerTitle}>
|
||||
{date
|
||||
? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })
|
||||
: title}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<JournalConflictBlock date={date} />
|
||||
<DetailPageImpl />
|
||||
<AppTabs background={cssVarV2('layer/background/primary')} />
|
||||
<MobileDetailPageContent
|
||||
key={pageId}
|
||||
pageId={pageId}
|
||||
date={date}
|
||||
fromTab={fromTab}
|
||||
title={title}
|
||||
allJournalDates={allJournalDates}
|
||||
handleDateChange={handleDateChange}
|
||||
/>
|
||||
</DetailPageWrapper>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ globalStyle(':root', {
|
||||
vars: {
|
||||
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
||||
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||
'--affine-edgeless-zoom-toolbar-bottom': `calc(10px + ${globalVars.appTabSafeArea})`,
|
||||
},
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
|
||||
@@ -318,29 +318,71 @@ export class Editor extends Entity {
|
||||
}
|
||||
|
||||
// update scroll position when scrollViewport scroll
|
||||
const saveScrollPosition = () => {
|
||||
if (this.mode$.value === 'page' && scrollViewport) {
|
||||
this.scrollPosition.page = scrollViewport.scrollTop;
|
||||
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
|
||||
} else if (this.mode$.value === 'edgeless' && gfx) {
|
||||
const pos = {
|
||||
centerX: gfx.viewport.centerX,
|
||||
centerY: gfx.viewport.centerY,
|
||||
zoom: gfx.viewport.zoom,
|
||||
};
|
||||
this.scrollPosition.edgeless = pos;
|
||||
this.workbenchView?.setScrollPosition(pos);
|
||||
let edgelessWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushEdgelessScrollPosition = () => {
|
||||
if (edgelessWriteTimer) {
|
||||
clearTimeout(edgelessWriteTimer);
|
||||
edgelessWriteTimer = null;
|
||||
}
|
||||
|
||||
const pos = this.scrollPosition.edgeless;
|
||||
if (!pos) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workbenchView?.setScrollPosition(pos);
|
||||
};
|
||||
scrollViewport?.addEventListener('scroll', saveScrollPosition);
|
||||
|
||||
const savePageScrollPosition = () => {
|
||||
if (!scrollViewport || this.mode$.value !== 'page') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollPosition.page = scrollViewport.scrollTop;
|
||||
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
|
||||
};
|
||||
|
||||
const saveEdgelessScrollPosition = () => {
|
||||
if (!gfx || this.mode$.value !== 'edgeless') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollPosition.edgeless = {
|
||||
centerX: gfx.viewport.centerX,
|
||||
centerY: gfx.viewport.centerY,
|
||||
zoom: gfx.viewport.zoom,
|
||||
};
|
||||
|
||||
if (edgelessWriteTimer) {
|
||||
clearTimeout(edgelessWriteTimer);
|
||||
}
|
||||
edgelessWriteTimer = setTimeout(() => {
|
||||
flushEdgelessScrollPosition();
|
||||
}, 160);
|
||||
};
|
||||
|
||||
const handleViewportScroll = () => {
|
||||
if (this.mode$.value === 'edgeless' && scrollViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
savePageScrollPosition();
|
||||
};
|
||||
|
||||
scrollViewport?.addEventListener('scroll', handleViewportScroll);
|
||||
unsubs.push(() => {
|
||||
scrollViewport?.removeEventListener('scroll', saveScrollPosition);
|
||||
scrollViewport?.removeEventListener('scroll', handleViewportScroll);
|
||||
});
|
||||
if (gfx) {
|
||||
const subscription =
|
||||
gfx.viewport.viewportUpdated.subscribe(saveScrollPosition);
|
||||
const subscription = gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
saveEdgelessScrollPosition();
|
||||
});
|
||||
unsubs.push(subscription.unsubscribe.bind(subscription));
|
||||
}
|
||||
unsubs.push(() => {
|
||||
flushEdgelessScrollPosition();
|
||||
});
|
||||
|
||||
// update selection when focusAt$ changed
|
||||
const subscription = this.focusAt$
|
||||
|
||||
Reference in New Issue
Block a user