mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-01 17:50:50 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e343802b2d | |||
| 7ea8800c99 | |||
| 16196c6ca1 | |||
| 9a9f243966 | |||
| e2624d93c7 | |||
| 766219d4e1 | |||
| 01d7ef88e3 | |||
| 154d9e975d | |||
| 24e07f73bb | |||
| d500e472f0 | |||
| 13d9fe506e | |||
| 1256d66938 | |||
| da7781a751 | |||
| a77d89bb1a | |||
| c51bdb74de | |||
| ac3c93ccfa | |||
| 6a2b73e76f | |||
| 07a08e6d4d | |||
| 6faebcabd3 | |||
| d10dd12663 | |||
| edc87e38df | |||
| 65c3271beb | |||
| 489702eb66 |
@@ -59,13 +59,20 @@ runs:
|
||||
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare cache key
|
||||
id: cache-key
|
||||
shell: bash
|
||||
run: |
|
||||
shared_key="$(printf '%s' "${{ inputs.target }}-${{ inputs.package }}" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||
echo "shared-key=$shared_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
with:
|
||||
workspaces: ${{ env.DEV_DRIVE_WORKSPACE }}
|
||||
save-if: ${{ github.ref_name == 'canary' }}
|
||||
shared-key: ${{ inputs.target }}-${{ inputs.package }}
|
||||
shared-key: ${{ steps.cache-key.outputs.shared-key }}
|
||||
env:
|
||||
CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo
|
||||
RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup
|
||||
@@ -75,7 +82,7 @@ runs:
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
with:
|
||||
save-if: ${{ github.ref_name == 'canary' }}
|
||||
shared-key: ${{ inputs.target }}-${{ inputs.package }}
|
||||
shared-key: ${{ steps.cache-key.outputs.shared-key }}
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["*", "!/^@blocksuite//", "!/oxlint/"]
|
||||
"excludePackagePatterns": ["^@blocksuite/", "^oxlint$"]
|
||||
},
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
|
||||
@@ -135,6 +135,159 @@ jobs:
|
||||
echo "All changes are submitted"
|
||||
fi
|
||||
|
||||
mobile-native-build-filter:
|
||||
name: Mobile native build filter
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run-android: ${{ steps.mobile-native-filter.outputs.android }}
|
||||
run-ios: ${{ steps.mobile-native-filter.outputs.ios }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: mobile-native-filter
|
||||
with:
|
||||
filters: |
|
||||
android:
|
||||
- '.github/workflows/build-test.yml'
|
||||
- 'packages/frontend/apps/android/**'
|
||||
- 'packages/frontend/mobile-native/**'
|
||||
- '.cargo/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'rust-toolchain*'
|
||||
ios:
|
||||
- '.github/workflows/build-test.yml'
|
||||
- 'packages/frontend/apps/ios/**'
|
||||
- 'packages/frontend/mobile-native/**'
|
||||
- '.cargo/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'rust-toolchain*'
|
||||
|
||||
build-android-app:
|
||||
name: Build Android app
|
||||
if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- mobile-native-build-filter
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android
|
||||
electron-install: false
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: 'aarch64-linux-android'
|
||||
package: 'affine_mobile_native'
|
||||
no-build: 'true'
|
||||
|
||||
- name: Build Android web assets
|
||||
run: yarn affine @affine/android build
|
||||
env:
|
||||
PUBLIC_PATH: '/'
|
||||
|
||||
- name: Write CI Firebase config
|
||||
run: |
|
||||
cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON'
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "1",
|
||||
"project_id": "affine-ci",
|
||||
"storage_bucket": "affine-ci.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "app.affine.pro"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "ci-placeholder"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
JSON
|
||||
|
||||
- name: Cap sync
|
||||
run: yarn workspace @affine/android cap sync
|
||||
|
||||
- name: Build Android debug app
|
||||
working-directory: packages/frontend/apps/android/App
|
||||
run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace
|
||||
|
||||
build-ios-app:
|
||||
name: Build iOS app
|
||||
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
|
||||
runs-on: macos-15
|
||||
needs:
|
||||
- mobile-native-build-filter
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/ios
|
||||
electron-install: false
|
||||
hard-link-nm: false
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 26.2
|
||||
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: 'aarch64-apple-ios-sim'
|
||||
package: 'affine_mobile_native'
|
||||
no-build: 'true'
|
||||
|
||||
- name: Build iOS web assets
|
||||
run: yarn affine @affine/ios build
|
||||
env:
|
||||
PUBLIC_PATH: '/'
|
||||
|
||||
- name: Cap sync
|
||||
run: yarn workspace @affine/ios sync
|
||||
|
||||
- name: Build iOS simulator app
|
||||
run: |
|
||||
xcodebuild \
|
||||
-workspace packages/frontend/apps/ios/App/App.xcworkspace \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
ARCHS=arm64 \
|
||||
ONLY_ACTIVE_ARCH=YES \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
build
|
||||
|
||||
rust-test-filter:
|
||||
name: Rust test filter
|
||||
runs-on: ubuntu-latest
|
||||
@@ -795,99 +948,6 @@ jobs:
|
||||
name: affine
|
||||
fail_ci_if_error: false
|
||||
|
||||
miri:
|
||||
name: miri code check
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
RUST_BACKTRACE: full
|
||||
CARGO_TERM_COLOR: always
|
||||
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: miri
|
||||
- name: Install latest nextest release
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: nextest@0.9.98
|
||||
|
||||
- name: Miri Code Check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cargo +nightly miri nextest run -p y-octo -j4
|
||||
|
||||
loom:
|
||||
name: loom thread test
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
RUSTFLAGS: --cfg loom
|
||||
RUST_BACKTRACE: full
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Install latest nextest release
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: nextest@0.9.98
|
||||
|
||||
- name: Loom Thread Test
|
||||
run: |
|
||||
cargo nextest run -p y-octo --lib
|
||||
|
||||
fuzzing:
|
||||
name: fuzzing
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
|
||||
- name: fuzzing
|
||||
working-directory: ./packages/common/y-octo/utils
|
||||
run: |
|
||||
cargo install cargo-fuzz
|
||||
cargo +nightly fuzz run apply_update -- -max_total_time=30
|
||||
cargo +nightly fuzz run codec_doc_any_struct -- -max_total_time=30
|
||||
cargo +nightly fuzz run codec_doc_any -- -max_total_time=30
|
||||
cargo +nightly fuzz run decode_bytes -- -max_total_time=30
|
||||
cargo +nightly fuzz run i32_decode -- -max_total_time=30
|
||||
cargo +nightly fuzz run i32_encode -- -max_total_time=30
|
||||
cargo +nightly fuzz run ins_del_text -- -max_total_time=30
|
||||
cargo +nightly fuzz run sync_message -- -max_total_time=30
|
||||
cargo +nightly fuzz run u64_decode -- -max_total_time=30
|
||||
cargo +nightly fuzz run u64_encode -- -max_total_time=30
|
||||
cargo +nightly fuzz run apply_update -- -max_total_time=30
|
||||
|
||||
- name: upload fuzz artifacts
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-artifact
|
||||
path: packages/common/y-octo/utils/fuzz/artifacts/**/*
|
||||
|
||||
rust-test:
|
||||
name: Run native tests
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
@@ -1328,6 +1388,9 @@ jobs:
|
||||
- analyze
|
||||
- lint
|
||||
- typecheck
|
||||
- mobile-native-build-filter
|
||||
- build-android-app
|
||||
- build-ios-app
|
||||
- lint-rust
|
||||
- check-git-status
|
||||
- check-yarn-binary
|
||||
@@ -1342,9 +1405,6 @@ jobs:
|
||||
- build-server-native
|
||||
- build-electron-renderer
|
||||
- native-unit-test
|
||||
- miri
|
||||
- loom
|
||||
- fuzzing
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- rust-test
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
steps:
|
||||
- name: Decide whether to release
|
||||
id: decide
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
.yarn/versions
|
||||
.corepack-bin
|
||||
|
||||
# compiled output
|
||||
*dist
|
||||
@@ -50,6 +51,7 @@ tsconfig.tsbuildinfo
|
||||
.context
|
||||
/*.md
|
||||
.codex
|
||||
.cursor
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
@@ -94,3 +96,9 @@ af.cmd
|
||||
|
||||
# playwright
|
||||
storageState.json
|
||||
/.understand-anything
|
||||
|
||||
# local test/browser artifacts
|
||||
/.playwright-browsers/
|
||||
**/.vitest-attachments/
|
||||
/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/
|
||||
|
||||
Generated
+452
-501
File diff suppressed because it is too large
Load Diff
+2
-23
@@ -2,8 +2,6 @@
|
||||
members = [
|
||||
"./packages/backend/native",
|
||||
"./packages/common/native",
|
||||
"./packages/common/y-octo/core",
|
||||
"./packages/common/y-octo/utils",
|
||||
"./packages/frontend/mobile-native",
|
||||
"./packages/frontend/native",
|
||||
"./packages/frontend/native/nbstore",
|
||||
@@ -23,7 +21,6 @@ resolver = "3"
|
||||
anyhow = "1"
|
||||
arbitrary = { version = "1.3", features = ["derive"] }
|
||||
assert-json-diff = "2.0"
|
||||
async-lock = { version = "3.4.0", features = ["loom"] }
|
||||
base64-simd = "0.8"
|
||||
bitvec = "1.0"
|
||||
block2 = "0.6"
|
||||
@@ -37,7 +34,7 @@ resolver = "3"
|
||||
criterion2 = { version = "3", default-features = false }
|
||||
crossbeam-channel = "0.5"
|
||||
dispatch2 = "0.3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||
doc_extractor = "0.1.0"
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
hex = "0.4"
|
||||
@@ -58,7 +55,6 @@ resolver = "3"
|
||||
llm_adapter = { version = "0.2", default-features = false }
|
||||
llm_runtime = { version = "0.2", default-features = false }
|
||||
log = "0.4"
|
||||
loom = { version = "0.7", features = ["checkpoint"] }
|
||||
lru = "0.16"
|
||||
matroska = "0.30"
|
||||
memory-indexer = "0.3.1"
|
||||
@@ -84,8 +80,6 @@ resolver = "3"
|
||||
ordered-float = "5"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
parking_lot = "0.12"
|
||||
path-ext = "0.1.2"
|
||||
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
|
||||
phf = { version = "0.11", features = ["macros"] }
|
||||
proptest = "1.3"
|
||||
proptest-derive = "0.5"
|
||||
@@ -94,7 +88,6 @@ resolver = "3"
|
||||
rand_chacha = "0.9"
|
||||
rand_distr = "0.5"
|
||||
rayon = "1.10"
|
||||
readability = { version = "0.3.0", default-features = false }
|
||||
regex = "1.10"
|
||||
rubato = "0.16"
|
||||
safefetch = "0.1.0"
|
||||
@@ -112,24 +105,10 @@ resolver = "3"
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
] }
|
||||
strum_macros = "0.27.0"
|
||||
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
|
||||
text-splitter = "0.27"
|
||||
thiserror = "2"
|
||||
tiktoken-rs = "0.7"
|
||||
tokio = "1.45"
|
||||
tree-sitter = { version = "0.25" }
|
||||
tree-sitter-c = { version = "0.24" }
|
||||
tree-sitter-c-sharp = { version = "0.23" }
|
||||
tree-sitter-cpp = { version = "0.23" }
|
||||
tree-sitter-go = { version = "0.23" }
|
||||
tree-sitter-java = { version = "0.23" }
|
||||
tree-sitter-javascript = { version = "0.23" }
|
||||
tree-sitter-kotlin-ng = { version = "1.1" }
|
||||
tree-sitter-python = { version = "0.23" }
|
||||
tree-sitter-rust = { version = "0.24" }
|
||||
tree-sitter-scala = { version = "0.24" }
|
||||
tree-sitter-typescript = { version = "0.23" }
|
||||
typst = "0.14.2"
|
||||
typst-as-lib = { version = "0.15.4", default-features = false, features = [
|
||||
"packages",
|
||||
@@ -155,7 +134,7 @@ resolver = "3"
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
] }
|
||||
windows-core = { version = "0.61" }
|
||||
y-octo = { path = "./packages/common/y-octo/core" }
|
||||
y-octo = "0.0.3"
|
||||
y-sync = { version = "0.4" }
|
||||
yrs = "0.23.0"
|
||||
|
||||
|
||||
@@ -270,6 +270,54 @@ Hello world
|
||||
expect(meta?.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('preserves list text inside blockquotes without list blocks', async () => {
|
||||
const markdown = `> **Shopping List:**
|
||||
> - Apples
|
||||
> - Bananas
|
||||
> - Oranges
|
||||
`;
|
||||
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||
const snapshot = await mdAdapter.toDocSnapshot({
|
||||
file: markdown,
|
||||
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
|
||||
});
|
||||
|
||||
expect(simplifyBlockForSnapshot(snapshot.blocks, new Map())).toMatchObject({
|
||||
children: [
|
||||
{
|
||||
flavour: 'affine:note',
|
||||
children: [
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'quote',
|
||||
delta: [
|
||||
{ insert: 'Shopping List:' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Apples' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Bananas' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Oranges' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const exported = await mdAdapter.fromDocSnapshot({
|
||||
snapshot,
|
||||
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
|
||||
});
|
||||
expect(exported.file).toContain('> **Shopping List:**');
|
||||
expect(exported.file).toContain('> \\- Apples');
|
||||
expect(exported.file).toContain('> \\- Bananas');
|
||||
expect(exported.file).toContain('> \\- Oranges');
|
||||
});
|
||||
|
||||
test('imports obsidian vault fixtures', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
|
||||
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
|
||||
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
|
||||
import {
|
||||
paintPlaceholder,
|
||||
syncCanvasSize,
|
||||
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
|
||||
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
|
||||
|
||||
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
|
||||
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
|
||||
window,
|
||||
'devicePixelRatio'
|
||||
);
|
||||
|
||||
function setDevicePixelRatio(value: number) {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function createRect(width: number, height: number): DOMRect {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function createFakeBlockModel(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w = 10,
|
||||
h = 10
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
elementBound: new Bound(x, y, w, h),
|
||||
};
|
||||
}
|
||||
|
||||
type PaintPlaceholderForTest = (
|
||||
canvas: HTMLCanvasElement,
|
||||
layout: ViewportLayoutTree,
|
||||
viewport: {
|
||||
zoom: number;
|
||||
toViewCoord: (x: number, y: number) => [number, number];
|
||||
}
|
||||
) => void;
|
||||
|
||||
afterEach(() => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
|
||||
|
||||
if (originalDevicePixelRatio) {
|
||||
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('edgeless canvas budget', () => {
|
||||
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
|
||||
expect(
|
||||
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
|
||||
).toBe(true);
|
||||
|
||||
const shouldSyncCanvasBudgetOnViewportUpdate = (
|
||||
canvasRendererModule as {
|
||||
shouldSyncCanvasBudgetOnViewportUpdate: (
|
||||
previousZoom: number,
|
||||
nextZoom: number,
|
||||
rawDpr?: number
|
||||
) => boolean;
|
||||
}
|
||||
).shouldSyncCanvasBudgetOnViewportUpdate;
|
||||
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
|
||||
});
|
||||
|
||||
test('enables low-zoom survival mode only for active iOS gestures', () => {
|
||||
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
|
||||
|
||||
const shouldUseLowZoomSurvivalMode = (
|
||||
canvasRendererModule as {
|
||||
shouldUseLowZoomSurvivalMode: (
|
||||
isIOS: boolean,
|
||||
zoom: number,
|
||||
gestureActive: boolean
|
||||
) => boolean;
|
||||
}
|
||||
).shouldUseLowZoomSurvivalMode;
|
||||
|
||||
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
|
||||
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
|
||||
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
|
||||
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
|
||||
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
|
||||
|
||||
const shouldRenderCanvasPlaceholders = (
|
||||
canvasRendererModule as {
|
||||
shouldRenderCanvasPlaceholders: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
turboEnabled: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldRenderCanvasPlaceholders;
|
||||
|
||||
expect(
|
||||
shouldRenderCanvasPlaceholders({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
isPanning: true,
|
||||
isZooming: false,
|
||||
skipRefreshDuringGesture: true,
|
||||
turboEnabled: true,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldRenderCanvasPlaceholders({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
isPanning: false,
|
||||
isZooming: true,
|
||||
skipRefreshDuringGesture: true,
|
||||
turboEnabled: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
|
||||
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
|
||||
expect(
|
||||
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
|
||||
).toBe(true);
|
||||
|
||||
const getStackingCanvasBypassState = (
|
||||
canvasRendererModule as {
|
||||
getStackingCanvasBypassState: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).getStackingCanvasBypassState;
|
||||
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
|
||||
canvasRendererModule as {
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldBypassStackingCanvasesDuringLowZoomGesture;
|
||||
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: false,
|
||||
recoveryActive: true,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: false,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: false,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 430,
|
||||
viewportHeight: 932,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.6,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: false,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
|
||||
expect(
|
||||
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
|
||||
).toBe(true);
|
||||
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
|
||||
canvasRendererModule as {
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldBypassStackingCanvasesDuringLowZoomGesture;
|
||||
const getStackingCanvasAttachmentDiff = (
|
||||
canvasRendererModule as {
|
||||
getStackingCanvasAttachmentDiff: (params: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
wasAttached: boolean;
|
||||
shouldAttach: boolean;
|
||||
}) => {
|
||||
added: HTMLCanvasElement[];
|
||||
removed: HTMLCanvasElement[];
|
||||
};
|
||||
}
|
||||
).getStackingCanvasAttachmentDiff;
|
||||
|
||||
const canvases = [document.createElement('canvas')];
|
||||
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
});
|
||||
|
||||
expect(shouldBypass).toBe(true);
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: true,
|
||||
shouldAttach: !shouldBypass,
|
||||
})
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: canvases,
|
||||
});
|
||||
});
|
||||
|
||||
test('uses overscan for main-canvas fallback culling and render origin', () => {
|
||||
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
|
||||
|
||||
const getMainCanvasFallbackBounds = (
|
||||
canvasRendererModule as {
|
||||
getMainCanvasFallbackBounds: (params: {
|
||||
viewportBounds: Bound;
|
||||
overscanViewportBounds: Bound;
|
||||
}) => {
|
||||
cullBound: Bound;
|
||||
renderBound: Bound;
|
||||
};
|
||||
}
|
||||
).getMainCanvasFallbackBounds;
|
||||
|
||||
const viewportBounds = new Bound(100, 200, 300, 150);
|
||||
const overscanViewportBounds = new Bound(40, 170, 420, 210);
|
||||
|
||||
expect(
|
||||
getMainCanvasFallbackBounds({
|
||||
viewportBounds,
|
||||
overscanViewportBounds,
|
||||
})
|
||||
).toEqual({
|
||||
cullBound: overscanViewportBounds,
|
||||
renderBound: overscanViewportBounds,
|
||||
});
|
||||
});
|
||||
|
||||
test('lays out overscan canvases relative to the exact viewport', () => {
|
||||
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
|
||||
|
||||
const getCanvasViewportLayout = (
|
||||
canvasRendererModule as {
|
||||
getCanvasViewportLayout: (params: {
|
||||
bound: Bound;
|
||||
viewportBounds: Bound;
|
||||
zoom: number;
|
||||
viewScale: number;
|
||||
dpr: number;
|
||||
}) => {
|
||||
actualHeight: number;
|
||||
actualWidth: number;
|
||||
height: number;
|
||||
transform: string;
|
||||
width: number;
|
||||
};
|
||||
}
|
||||
).getCanvasViewportLayout;
|
||||
|
||||
expect(
|
||||
getCanvasViewportLayout({
|
||||
bound: new Bound(40, 170, 420, 210),
|
||||
viewportBounds: new Bound(100, 200, 300, 150),
|
||||
zoom: 1,
|
||||
viewScale: 1,
|
||||
dpr: 2,
|
||||
})
|
||||
).toEqual({
|
||||
actualHeight: 420,
|
||||
actualWidth: 840,
|
||||
height: 210,
|
||||
transform: 'translate(-60px, -30px) scale(1)',
|
||||
width: 420,
|
||||
});
|
||||
});
|
||||
|
||||
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
|
||||
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const getStackingCanvasAttachmentDiff = (
|
||||
canvasRendererModule as {
|
||||
getStackingCanvasAttachmentDiff: (params: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
wasAttached: boolean;
|
||||
shouldAttach: boolean;
|
||||
}) => {
|
||||
added: HTMLCanvasElement[];
|
||||
removed: HTMLCanvasElement[];
|
||||
};
|
||||
}
|
||||
).getStackingCanvasAttachmentDiff;
|
||||
|
||||
const canvasA = document.createElement('canvas');
|
||||
const canvasB = document.createElement('canvas');
|
||||
const canvases = [canvasA, canvasB];
|
||||
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: true,
|
||||
shouldAttach: false,
|
||||
})
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: canvases,
|
||||
});
|
||||
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: false,
|
||||
shouldAttach: true,
|
||||
})
|
||||
).toEqual({
|
||||
added: canvases,
|
||||
removed: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: true,
|
||||
shouldAttach: true,
|
||||
})
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
|
||||
const viewport = new Viewport();
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const viewportUpdated = vi.fn();
|
||||
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
|
||||
let lastCanvasBudgetZoom = viewport.zoom;
|
||||
let budgetSyncCount = 0;
|
||||
|
||||
viewport.viewportUpdated.subscribe(viewportUpdated);
|
||||
|
||||
expect('zoomUpdated' in viewport).toBe(true);
|
||||
const zoomUpdated = (
|
||||
viewport as unknown as {
|
||||
zoomUpdated: {
|
||||
subscribe: (
|
||||
callback: (update: { previousZoom: number; zoom: number }) => void
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
).zoomUpdated;
|
||||
|
||||
zoomUpdated.subscribe(update => {
|
||||
zoomUpdates.push(update);
|
||||
if (
|
||||
(
|
||||
canvasRendererModule as {
|
||||
shouldSyncCanvasBudgetOnViewportUpdate: (
|
||||
previousZoom: number,
|
||||
nextZoom: number,
|
||||
rawDpr?: number
|
||||
) => boolean;
|
||||
}
|
||||
).shouldSyncCanvasBudgetOnViewportUpdate(
|
||||
lastCanvasBudgetZoom,
|
||||
update.zoom,
|
||||
2
|
||||
)
|
||||
) {
|
||||
budgetSyncCount += 1;
|
||||
}
|
||||
lastCanvasBudgetZoom = update.zoom;
|
||||
});
|
||||
|
||||
viewport.panning$.next(true);
|
||||
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
|
||||
|
||||
expect(viewportUpdated).not.toHaveBeenCalled();
|
||||
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
|
||||
expect(budgetSyncCount).toBe(1);
|
||||
|
||||
viewport.dispose();
|
||||
});
|
||||
|
||||
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
|
||||
const viewport = new Viewport();
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const viewportUpdated = vi.fn();
|
||||
const zoomUpdated = vi.fn();
|
||||
|
||||
viewport.viewportUpdated.subscribe(viewportUpdated);
|
||||
viewport.zoomUpdated.subscribe(zoomUpdated);
|
||||
|
||||
viewport.setZoom(0.4, { x: 0, y: 0 });
|
||||
|
||||
expect(viewportUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
|
||||
expect(viewport.panning$.value).toBe(false);
|
||||
expect(viewport.zooming$.value).toBe(false);
|
||||
|
||||
viewport.dispose();
|
||||
});
|
||||
|
||||
test('enables low-zoom block survival only while the gesture is still active', () => {
|
||||
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const shouldUseLowZoomBlockSurvivalMode = (
|
||||
viewportElementModule as {
|
||||
shouldUseLowZoomBlockSurvivalMode: (params: {
|
||||
zoom: number;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
gestureActive: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldUseLowZoomBlockSurvivalMode;
|
||||
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
|
||||
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
|
||||
|
||||
const getLowZoomGestureActiveModels = (
|
||||
viewportElementModule as {
|
||||
getLowZoomGestureActiveModels: (params: {
|
||||
selectedModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportBounds: Bound;
|
||||
nearbyActiveBlockLimit: number;
|
||||
nearbyDistanceRatio: number;
|
||||
}) => Set<{ id: string; elementBound: Bound }>;
|
||||
}
|
||||
).getLowZoomGestureActiveModels;
|
||||
|
||||
const selected = createFakeBlockModel('selected', 10, 10);
|
||||
const nearby = createFakeBlockModel('nearby', 28, 12);
|
||||
const far = createFakeBlockModel('far', 78, 78);
|
||||
|
||||
const activeModels = getLowZoomGestureActiveModels({
|
||||
selectedModels: new Set([selected]),
|
||||
viewportModels: new Set([selected, nearby, far]),
|
||||
viewportBounds: new Bound(0, 0, 100, 100),
|
||||
nearbyActiveBlockLimit: 1,
|
||||
nearbyDistanceRatio: 0.35,
|
||||
});
|
||||
|
||||
expect([...activeModels].map(model => model.id).sort()).toEqual([
|
||||
'nearby',
|
||||
'selected',
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to the nearest viewport block when nothing is selected', () => {
|
||||
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
|
||||
|
||||
const getLowZoomGestureActiveModels = (
|
||||
viewportElementModule as {
|
||||
getLowZoomGestureActiveModels: (params: {
|
||||
selectedModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportBounds: Bound;
|
||||
nearbyActiveBlockLimit: number;
|
||||
nearbyDistanceRatio: number;
|
||||
}) => Set<{ id: string; elementBound: Bound }>;
|
||||
}
|
||||
).getLowZoomGestureActiveModels;
|
||||
|
||||
const nearest = createFakeBlockModel('nearest', 46, 46);
|
||||
const farther = createFakeBlockModel('farther', 78, 78);
|
||||
|
||||
const activeModels = getLowZoomGestureActiveModels({
|
||||
selectedModels: new Set(),
|
||||
viewportModels: new Set([nearest, farther]),
|
||||
viewportBounds: new Bound(0, 0, 100, 100),
|
||||
nearbyActiveBlockLimit: 1,
|
||||
nearbyDistanceRatio: 0.35,
|
||||
});
|
||||
|
||||
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
|
||||
});
|
||||
|
||||
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
|
||||
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
|
||||
|
||||
const getPostGestureRecoveryDelay = (
|
||||
viewportModule as {
|
||||
getPostGestureRecoveryDelay: (params: {
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
fallbackDelayMs: number;
|
||||
}) => number;
|
||||
}
|
||||
).getPostGestureRecoveryDelay;
|
||||
|
||||
expect(
|
||||
getPostGestureRecoveryDelay({
|
||||
isPanning: false,
|
||||
isZooming: false,
|
||||
fallbackDelayMs: 220,
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
|
||||
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
|
||||
|
||||
const getPostGestureRecoveryDelay = (
|
||||
viewportModule as {
|
||||
getPostGestureRecoveryDelay: (params: {
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
fallbackDelayMs: number;
|
||||
}) => number;
|
||||
}
|
||||
).getPostGestureRecoveryDelay;
|
||||
|
||||
expect(
|
||||
getPostGestureRecoveryDelay({
|
||||
isPanning: true,
|
||||
isZooming: false,
|
||||
fallbackDelayMs: 220,
|
||||
})
|
||||
).toBe(220);
|
||||
expect(
|
||||
getPostGestureRecoveryDelay({
|
||||
isPanning: false,
|
||||
isZooming: true,
|
||||
fallbackDelayMs: 220,
|
||||
})
|
||||
).toBe(220);
|
||||
});
|
||||
|
||||
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
setDevicePixelRatio(2);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const host = document.createElement('div');
|
||||
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
|
||||
createRect(200, 100)
|
||||
);
|
||||
|
||||
(
|
||||
syncCanvasSize as unknown as (
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom: number
|
||||
) => void
|
||||
)(canvas, host, 0.4);
|
||||
|
||||
expect(canvas.width).toBe(200);
|
||||
expect(canvas.height).toBe(100);
|
||||
|
||||
(
|
||||
syncCanvasSize as unknown as (
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom: number
|
||||
) => void
|
||||
)(canvas, host, 0.95);
|
||||
|
||||
expect(canvas.width).toBe(400);
|
||||
expect(canvas.height).toBe(200);
|
||||
});
|
||||
|
||||
test('paints turbo placeholders with effective dpr at low zoom', () => {
|
||||
const previousTheme = document.documentElement.dataset.theme;
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
|
||||
try {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
setDevicePixelRatio(2);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const fillRect = vi.fn();
|
||||
const strokeRect = vi.fn();
|
||||
let fillStyle = '';
|
||||
let strokeStyle = '';
|
||||
vi.spyOn(canvas, 'getContext').mockReturnValue({
|
||||
get fillStyle() {
|
||||
return fillStyle;
|
||||
},
|
||||
set fillStyle(value: string) {
|
||||
fillStyle = value;
|
||||
},
|
||||
get strokeStyle() {
|
||||
return strokeStyle;
|
||||
},
|
||||
set strokeStyle(value: string) {
|
||||
strokeStyle = value;
|
||||
},
|
||||
fillRect,
|
||||
strokeRect,
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
|
||||
const layout: ViewportLayoutTree = {
|
||||
roots: [
|
||||
{
|
||||
blockId: 'root',
|
||||
type: 'affine:page',
|
||||
layout: {
|
||||
blockId: 'root',
|
||||
type: 'affine:page',
|
||||
rect: { x: 0, y: 0, w: 50, h: 20 },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
overallRect: { x: 0, y: 0, w: 50, h: 20 },
|
||||
};
|
||||
|
||||
const paintPlaceholderForTest =
|
||||
paintPlaceholder as unknown as PaintPlaceholderForTest;
|
||||
|
||||
paintPlaceholderForTest(canvas, layout, {
|
||||
zoom: 0.4,
|
||||
toViewCoord: () => [0, 0],
|
||||
});
|
||||
|
||||
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
|
||||
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
|
||||
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
|
||||
|
||||
paintPlaceholderForTest(canvas, layout, {
|
||||
zoom: 0.95,
|
||||
toViewCoord: () => [0, 0],
|
||||
});
|
||||
|
||||
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
|
||||
} finally {
|
||||
document.documentElement.dataset.theme = previousTheme;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAffinePlaceholderFillColor,
|
||||
getAffinePlaceholderStrokeColor,
|
||||
inferColorSchemeFromThemeMode,
|
||||
} from '../../../../shared/src/theme/placeholder-style.js';
|
||||
|
||||
describe('affine placeholder style', () => {
|
||||
it('returns subtle light placeholder colors', () => {
|
||||
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
|
||||
'rgba(0, 0, 0, 0.04)'
|
||||
);
|
||||
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
|
||||
'rgba(0, 0, 0, 0.02)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns subtle dark placeholder colors', () => {
|
||||
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
|
||||
'rgba(255, 255, 255, 0.08)'
|
||||
);
|
||||
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
|
||||
'rgba(255, 255, 255, 0.04)'
|
||||
);
|
||||
});
|
||||
|
||||
it('infers color scheme from theme mode', () => {
|
||||
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
|
||||
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
|
||||
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
|
||||
|
||||
describe('viewport turbo renderer policy', () => {
|
||||
test.each([
|
||||
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
|
||||
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
|
||||
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
|
||||
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
|
||||
])(
|
||||
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
|
||||
({ isIOS, zoom, hasBitmap, expected }) => {
|
||||
expect(
|
||||
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
|
||||
).toBe(true);
|
||||
|
||||
const shouldPreferBitmapCacheDuringLowZoomGesture = (
|
||||
turboRendererModule as {
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
hasBitmap: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldPreferBitmapCacheDuringLowZoomGesture;
|
||||
|
||||
expect(
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture({
|
||||
isIOS,
|
||||
zoom,
|
||||
hasBitmap,
|
||||
})
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ isIOS: true, zoom: 0.4, expected: false },
|
||||
{ isIOS: true, zoom: 0.8, expected: true },
|
||||
{ isIOS: false, zoom: 0.4, expected: true },
|
||||
])(
|
||||
'idles turbo blocks outside iOS low-zoom survival mode %#',
|
||||
({ isIOS, zoom, expected }) => {
|
||||
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const shouldIdleTurboBlocksDuringZooming = (
|
||||
turboRendererModule as {
|
||||
shouldIdleTurboBlocksDuringZooming: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldIdleTurboBlocksDuringZooming;
|
||||
|
||||
expect(
|
||||
shouldIdleTurboBlocksDuringZooming({
|
||||
isIOS,
|
||||
zoom,
|
||||
})
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
HtmlAdapter,
|
||||
pasteMiddleware,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
LifeCycleWatcher,
|
||||
LifeCycleWatcherIdentifier,
|
||||
StdIdentifier,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
@@ -103,6 +105,30 @@ export class CodeBlockClipboardController extends LifeCycleWatcher {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
const textSelection = this.std.selection.find(TextSelection);
|
||||
const plainText = e.clipboardData
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
const selectedBlockId = textSelection?.from.blockId;
|
||||
const codeBlock = selectedBlockId
|
||||
? this.std.store.getBlock(selectedBlockId)?.model
|
||||
: null;
|
||||
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
|
||||
const richText = this.std.view
|
||||
.getBlock(selectedBlockId)
|
||||
?.querySelector<RichText>('rich-text');
|
||||
const inlineEditor = richText?.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (inlineEditor && inlineRange) {
|
||||
inlineEditor.insertText(inlineRange, plainText);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + plainText.length,
|
||||
length: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this.std.store.captureSync();
|
||||
this.std.command
|
||||
.chain()
|
||||
|
||||
@@ -54,9 +54,9 @@ type Cell = {
|
||||
value: string | { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: ColumnDataType[],
|
||||
children: BlockSnapshot[],
|
||||
cells: SerializedCells
|
||||
columns: ColumnDataType[] = [],
|
||||
children: BlockSnapshot[] = [],
|
||||
cells: SerializedCells = {}
|
||||
): Table => {
|
||||
const table: Table = {
|
||||
headers: columns,
|
||||
@@ -90,13 +90,17 @@ export const processTable = (
|
||||
return;
|
||||
}
|
||||
let value: string | { delta: DeltaInsert[] };
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
try {
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
value = '';
|
||||
}
|
||||
row.cells.push({
|
||||
value,
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||
isCalloutNode,
|
||||
type MarkdownAST,
|
||||
type MarkdownDeltaConverter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Heading } from 'mdast';
|
||||
import type { Blockquote, Heading, List, ListItem } from 'mdast';
|
||||
|
||||
/**
|
||||
* Extend the HeadingData type to include the collapsed property
|
||||
@@ -24,6 +25,131 @@ const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
|
||||
const isParagraphMDASTType = (node: MarkdownAST) =>
|
||||
PARAGRAPH_MDAST_TYPE.has(node.type);
|
||||
|
||||
const joinDeltaLines = (
|
||||
lines: DeltaInsert[][],
|
||||
prefix?: string
|
||||
): DeltaInsert[] => {
|
||||
const deltas: DeltaInsert[] = [];
|
||||
lines.forEach(line => {
|
||||
if (deltas.length) deltas.push({ insert: '\n' });
|
||||
if (prefix) deltas.push({ insert: prefix });
|
||||
deltas.push(...line);
|
||||
});
|
||||
return deltas;
|
||||
};
|
||||
|
||||
const flattenListItemToDelta = (
|
||||
node: ListItem,
|
||||
deltaConverter: MarkdownDeltaConverter,
|
||||
prefix: string,
|
||||
depth: number
|
||||
): DeltaInsert[] => {
|
||||
const firstParagraph = node.children[0];
|
||||
const lines: DeltaInsert[][] = [];
|
||||
if (firstParagraph?.type === 'paragraph') {
|
||||
lines.push([
|
||||
{ insert: prefix },
|
||||
...deltaConverter.astToDelta(firstParagraph),
|
||||
]);
|
||||
} else {
|
||||
lines.push([{ insert: prefix.trimEnd() }]);
|
||||
}
|
||||
node.children
|
||||
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
|
||||
.forEach(child => {
|
||||
const delta = flattenMarkdownBlockToDelta(
|
||||
child as MarkdownAST,
|
||||
deltaConverter,
|
||||
depth + 1
|
||||
);
|
||||
if (delta.length) {
|
||||
lines.push(delta);
|
||||
}
|
||||
});
|
||||
return joinDeltaLines(lines);
|
||||
};
|
||||
|
||||
const flattenMarkdownBlockToDelta = (
|
||||
node: MarkdownAST,
|
||||
deltaConverter: MarkdownDeltaConverter,
|
||||
depth = 0
|
||||
): DeltaInsert[] => {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
return deltaConverter.astToDelta(node);
|
||||
case 'list': {
|
||||
const list = node as List;
|
||||
return joinDeltaLines(
|
||||
list.children.map((item, index) => {
|
||||
const order = (list.start ?? 1) + index;
|
||||
const prefix =
|
||||
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
|
||||
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
|
||||
})
|
||||
);
|
||||
}
|
||||
case 'blockquote':
|
||||
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
|
||||
default:
|
||||
return 'children' in node
|
||||
? joinDeltaLines(
|
||||
(node.children as MarkdownAST[]).map(child =>
|
||||
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
};
|
||||
|
||||
const flattenBlockquoteToDelta = (
|
||||
node: Blockquote,
|
||||
deltaConverter: MarkdownDeltaConverter
|
||||
) =>
|
||||
joinDeltaLines(
|
||||
node.children.map(child =>
|
||||
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
|
||||
)
|
||||
);
|
||||
|
||||
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
|
||||
const text = (node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
return text.delta;
|
||||
};
|
||||
|
||||
const flattenSnapshotBlockToDelta = (
|
||||
node: BlockSnapshot,
|
||||
depth = 0
|
||||
): DeltaInsert[] => {
|
||||
if (node.flavour === 'affine:list') {
|
||||
const type = node.props.type;
|
||||
const order = (node.props.order as number | undefined) ?? 1;
|
||||
const prefix =
|
||||
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
|
||||
return joinDeltaLines([
|
||||
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
|
||||
...node.children.map(child =>
|
||||
flattenSnapshotBlockToDelta(child, depth + 1)
|
||||
),
|
||||
]);
|
||||
}
|
||||
return joinDeltaLines([
|
||||
getSnapshotTextDelta(node),
|
||||
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
|
||||
]);
|
||||
};
|
||||
|
||||
const flattenQuoteSnapshotToDelta = (
|
||||
text: DeltaInsert[],
|
||||
children: BlockSnapshot[]
|
||||
) =>
|
||||
joinDeltaLines([
|
||||
text,
|
||||
...children.map(child => flattenSnapshotBlockToDelta(child)),
|
||||
]);
|
||||
|
||||
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
@@ -93,7 +219,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
type: 'quote',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
delta: flattenBlockquoteToDelta(
|
||||
o.node as Blockquote,
|
||||
deltaConverter
|
||||
),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
@@ -160,6 +289,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
break;
|
||||
}
|
||||
case 'quote': {
|
||||
const quoteDelta = flattenQuoteSnapshotToDelta(
|
||||
text.delta,
|
||||
o.node.children
|
||||
);
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
@@ -171,12 +304,13 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: deltaConverter.deltaToAST(text.delta),
|
||||
children: deltaConverter.deltaToAST(quoteDelta),
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ export const ParagraphKeymapExtension = KeymapExtension(
|
||||
return true;
|
||||
},
|
||||
Enter: ctx => {
|
||||
const raw = ctx.get('keyboardState').raw;
|
||||
if (raw.isComposing) return;
|
||||
|
||||
const { store } = std;
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return;
|
||||
@@ -115,7 +118,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
|
||||
const raw = ctx.get('keyboardState').raw;
|
||||
const isEnd = model.props.text.length === inlineRange.index;
|
||||
|
||||
if (model.props.type === 'quote') {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
|
||||
@@ -212,7 +212,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
currentCenter.y
|
||||
);
|
||||
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY));
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), false, true, true);
|
||||
|
||||
return false;
|
||||
})
|
||||
@@ -351,7 +351,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
);
|
||||
|
||||
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), true);
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), true, true, true);
|
||||
e.stopPropagation();
|
||||
}
|
||||
// pan
|
||||
@@ -484,7 +484,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
.viewport=${this.gfx.viewport}
|
||||
.getModelsInViewport=${() => {
|
||||
const blocks = this.gfx.grid.search(
|
||||
this.gfx.viewport.viewportBounds,
|
||||
this.gfx.viewport.overscanBlockBounds,
|
||||
{
|
||||
useSet: true,
|
||||
filter: ['block'],
|
||||
|
||||
@@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockM
|
||||
.viewport=${this._gfx.viewport}
|
||||
.getModelsInViewport=${() => {
|
||||
const blocks = this._gfx.grid.search(
|
||||
this._gfx.viewport.viewportBounds,
|
||||
this._gfx.viewport.overscanBlockBounds,
|
||||
{
|
||||
useSet: true,
|
||||
filter: ['block'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_IOS } from '@blocksuite/global/env';
|
||||
import {
|
||||
Bound,
|
||||
getBoundWithRotation,
|
||||
@@ -18,7 +19,12 @@ import type {
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
getEffectiveDpr,
|
||||
getPostGestureRecoveryDelay,
|
||||
GfxControllerIdentifier,
|
||||
viewportRuntimeConfig,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import last from 'lodash-es/last';
|
||||
import { Subject } from 'rxjs';
|
||||
@@ -28,6 +34,7 @@ import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
|
||||
import { RoughCanvas } from '../utils/rough/canvas.js';
|
||||
import type { ElementRenderer } from './elements/index.js';
|
||||
import type { Overlay } from './overlay.js';
|
||||
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||
|
||||
type EnvProvider = {
|
||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||
@@ -116,6 +123,181 @@ type RefreshTarget =
|
||||
};
|
||||
|
||||
const STACKING_CANVAS_PADDING = 32;
|
||||
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
|
||||
|
||||
export function shouldSyncCanvasBudgetOnViewportUpdate(
|
||||
previousZoom: number,
|
||||
nextZoom: number,
|
||||
rawDpr = window.devicePixelRatio
|
||||
) {
|
||||
if (rawDpr <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
getEffectiveDpr(previousZoom, rawDpr) !== getEffectiveDpr(nextZoom, rawDpr)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldUseLowZoomSurvivalMode(
|
||||
isIOS: boolean,
|
||||
zoom: number,
|
||||
gestureActive: boolean
|
||||
) {
|
||||
return isIOS && gestureActive && zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD;
|
||||
}
|
||||
|
||||
export function getStackingCanvasBypassState(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) {
|
||||
const {
|
||||
isIOS,
|
||||
zoom,
|
||||
gestureActive,
|
||||
recoveryActive,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
} = params;
|
||||
|
||||
return (
|
||||
isIOS &&
|
||||
zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
(gestureActive || recoveryActive) &&
|
||||
viewportWidth > viewportHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldBypassStackingCanvasesDuringLowZoomGesture(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) {
|
||||
return getStackingCanvasBypassState(params);
|
||||
}
|
||||
|
||||
export function getStackingCanvasAttachmentDiff(params: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
wasAttached: boolean;
|
||||
shouldAttach: boolean;
|
||||
}) {
|
||||
const { canvases, wasAttached, shouldAttach } = params;
|
||||
|
||||
if (wasAttached === shouldAttach) {
|
||||
return {
|
||||
added: [],
|
||||
removed: [],
|
||||
};
|
||||
}
|
||||
|
||||
return shouldAttach
|
||||
? {
|
||||
added: canvases,
|
||||
removed: [],
|
||||
}
|
||||
: {
|
||||
added: [],
|
||||
removed: canvases,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMainCanvasFallbackBounds(params: {
|
||||
viewportBounds: Bound;
|
||||
overscanViewportBounds: Bound;
|
||||
}) {
|
||||
const { overscanViewportBounds } = params;
|
||||
|
||||
return {
|
||||
cullBound: overscanViewportBounds,
|
||||
renderBound: overscanViewportBounds,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCanvasViewportLayout(params: {
|
||||
bound: Bound;
|
||||
viewportBounds: Bound;
|
||||
zoom: number;
|
||||
viewScale: number;
|
||||
dpr: number;
|
||||
}) {
|
||||
const { bound, viewportBounds, zoom, viewScale, dpr } = params;
|
||||
const width = bound.w * zoom;
|
||||
const height = bound.h * zoom;
|
||||
const left = (bound.x - viewportBounds.x) * zoom;
|
||||
const top = (bound.y - viewportBounds.y) * zoom;
|
||||
|
||||
return {
|
||||
actualHeight: Math.max(0, Math.ceil(height * dpr)),
|
||||
actualWidth: Math.max(0, Math.ceil(width * dpr)),
|
||||
height,
|
||||
transform: `translate(${left}px, ${top}px) scale(${1 / viewScale})`,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCanvasViewportLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
layout: ReturnType<typeof getCanvasViewportLayout>
|
||||
) {
|
||||
const width = `${layout.width}px`;
|
||||
const height = `${layout.height}px`;
|
||||
|
||||
if (canvas.style.left !== '0px') {
|
||||
canvas.style.left = '0px';
|
||||
}
|
||||
if (canvas.style.top !== '0px') {
|
||||
canvas.style.top = '0px';
|
||||
}
|
||||
if (canvas.style.width !== width) {
|
||||
canvas.style.width = width;
|
||||
}
|
||||
if (canvas.style.height !== height) {
|
||||
canvas.style.height = height;
|
||||
}
|
||||
if (canvas.style.transform !== layout.transform) {
|
||||
canvas.style.transform = layout.transform;
|
||||
}
|
||||
if (canvas.style.transformOrigin !== 'top left') {
|
||||
canvas.style.transformOrigin = 'top left';
|
||||
}
|
||||
if (canvas.width !== layout.actualWidth) {
|
||||
canvas.width = layout.actualWidth;
|
||||
}
|
||||
if (canvas.height !== layout.actualHeight) {
|
||||
canvas.height = layout.actualHeight;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderCanvasPlaceholders(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
turboEnabled: boolean;
|
||||
}) {
|
||||
const {
|
||||
isIOS,
|
||||
zoom,
|
||||
isPanning,
|
||||
isZooming,
|
||||
skipRefreshDuringGesture,
|
||||
turboEnabled,
|
||||
} = params;
|
||||
|
||||
if (shouldUseLowZoomSurvivalMode(isIOS, zoom, isZooming)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !skipRefreshDuringGesture && turboEnabled && isZooming && !isPanning;
|
||||
}
|
||||
|
||||
export class CanvasRenderer {
|
||||
private _container!: HTMLElement;
|
||||
@@ -145,6 +327,19 @@ export class CanvasRenderer {
|
||||
|
||||
private _needsFullRender = true;
|
||||
|
||||
private _lastCanvasBudgetZoom = 1;
|
||||
|
||||
private _lastLowZoomSurvivalMode = false;
|
||||
|
||||
private _lastBypassStackingCanvases = false;
|
||||
|
||||
private _stackingCanvasesAttached = true;
|
||||
|
||||
private _stackingCanvasRecoveryUntil = 0;
|
||||
|
||||
private _stackingCanvasRecoveryTimerId: ReturnType<typeof setTimeout> | null =
|
||||
null;
|
||||
|
||||
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
|
||||
refreshCount: 0,
|
||||
coalescedRefreshCount: 0,
|
||||
@@ -189,6 +384,10 @@ export class CanvasRenderer {
|
||||
return this._stackingCanvas;
|
||||
}
|
||||
|
||||
get stackingCanvasesAttached() {
|
||||
return this._stackingCanvasesAttached;
|
||||
}
|
||||
|
||||
constructor(options: RendererOptions) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
@@ -196,6 +395,7 @@ export class CanvasRenderer {
|
||||
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
this.std = options.std;
|
||||
this.viewport = options.viewport;
|
||||
this._lastCanvasBudgetZoom = this.viewport.zoom;
|
||||
this.layerManager = options.layerManager;
|
||||
this.grid = options.gridManager;
|
||||
this.provider = options.provider ?? {};
|
||||
@@ -223,22 +423,28 @@ export class CanvasRenderer {
|
||||
*
|
||||
* It is not recommended to set width and height to 100%.
|
||||
*/
|
||||
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
|
||||
const { width, height, viewScale } = this.viewport;
|
||||
const actualWidth = Math.ceil(width * dpr);
|
||||
const actualHeight = Math.ceil(height * dpr);
|
||||
private _canvasSizeUpdater(
|
||||
bound = this.viewport.overscanViewportBounds,
|
||||
dpr = getEffectiveDpr(this.viewport.zoom)
|
||||
) {
|
||||
const layout = getCanvasViewportLayout({
|
||||
bound,
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
zoom: this.viewport.zoom,
|
||||
viewScale: this.viewport.viewScale,
|
||||
dpr,
|
||||
});
|
||||
|
||||
return {
|
||||
filter({ width, height }: HTMLCanvasElement) {
|
||||
return width !== actualWidth || height !== actualHeight;
|
||||
filter(canvas: HTMLCanvasElement) {
|
||||
return (
|
||||
canvas.width !== layout.actualWidth ||
|
||||
canvas.height !== layout.actualHeight ||
|
||||
canvas.style.transform !== layout.transform
|
||||
);
|
||||
},
|
||||
update(canvas: HTMLCanvasElement) {
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
canvas.style.transform = `scale(${1 / viewScale})`;
|
||||
canvas.style.transformOrigin = `top left`;
|
||||
canvas.width = actualWidth;
|
||||
canvas.height = actualHeight;
|
||||
applyCanvasViewportLayout(canvas, layout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -246,7 +452,7 @@ export class CanvasRenderer {
|
||||
private _applyStackingCanvasLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
bound: Bound | null,
|
||||
dpr = window.devicePixelRatio
|
||||
dpr = getEffectiveDpr(this.viewport.zoom)
|
||||
) {
|
||||
const state =
|
||||
this._stackingCanvasState.get(canvas) ??
|
||||
@@ -270,44 +476,18 @@ export class CanvasRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewportBounds, zoom, viewScale } = this.viewport;
|
||||
const width = bound.w * zoom;
|
||||
const height = bound.h * zoom;
|
||||
const left = (bound.x - viewportBounds.x) * zoom;
|
||||
const top = (bound.y - viewportBounds.y) * zoom;
|
||||
const actualWidth = Math.max(1, Math.ceil(width * dpr));
|
||||
const actualHeight = Math.max(1, Math.ceil(height * dpr));
|
||||
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
|
||||
const layout = getCanvasViewportLayout({
|
||||
bound,
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
zoom: this.viewport.zoom,
|
||||
viewScale: this.viewport.viewScale,
|
||||
dpr,
|
||||
});
|
||||
|
||||
if (canvas.style.display !== 'block') {
|
||||
canvas.style.display = 'block';
|
||||
}
|
||||
if (canvas.style.left !== '0px') {
|
||||
canvas.style.left = '0px';
|
||||
}
|
||||
if (canvas.style.top !== '0px') {
|
||||
canvas.style.top = '0px';
|
||||
}
|
||||
if (canvas.style.width !== `${width}px`) {
|
||||
canvas.style.width = `${width}px`;
|
||||
}
|
||||
if (canvas.style.height !== `${height}px`) {
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
if (canvas.style.transform !== transform) {
|
||||
canvas.style.transform = transform;
|
||||
}
|
||||
if (canvas.style.transformOrigin !== 'top left') {
|
||||
canvas.style.transformOrigin = 'top left';
|
||||
}
|
||||
|
||||
if (canvas.width !== actualWidth) {
|
||||
canvas.width = actualWidth;
|
||||
}
|
||||
|
||||
if (canvas.height !== actualHeight) {
|
||||
canvas.height = actualHeight;
|
||||
}
|
||||
applyCanvasViewportLayout(canvas, layout);
|
||||
|
||||
state.bound = bound;
|
||||
state.layerId = canvas.dataset.layerId ?? null;
|
||||
@@ -434,6 +614,125 @@ export class CanvasRenderer {
|
||||
this._applyStackingCanvasLayout(canvas, null);
|
||||
}
|
||||
|
||||
private _syncStackingCanvasAttachment(shouldAttach: boolean) {
|
||||
const payloadDiff = getStackingCanvasAttachmentDiff({
|
||||
canvases: this._stackingCanvas,
|
||||
wasAttached: this._stackingCanvasesAttached,
|
||||
shouldAttach,
|
||||
});
|
||||
|
||||
this._stackingCanvasesAttached = shouldAttach;
|
||||
|
||||
if (!payloadDiff.added.length && !payloadDiff.removed.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stackingCanvasUpdated.next({
|
||||
canvases: this._stackingCanvas,
|
||||
...payloadDiff,
|
||||
});
|
||||
}
|
||||
|
||||
private _isStackingCanvasRecoveryActive() {
|
||||
return this._stackingCanvasRecoveryUntil > performance.now();
|
||||
}
|
||||
|
||||
private _clearStackingCanvasRecoveryTimer() {
|
||||
if (this._stackingCanvasRecoveryTimerId !== null) {
|
||||
clearTimeout(this._stackingCanvasRecoveryTimerId);
|
||||
this._stackingCanvasRecoveryTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleStackingCanvasRecoveryWindow(
|
||||
delayMs = viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY
|
||||
) {
|
||||
this._clearStackingCanvasRecoveryTimer();
|
||||
this._stackingCanvasRecoveryUntil = performance.now() + delayMs;
|
||||
this._stackingCanvasRecoveryTimerId = setTimeout(() => {
|
||||
this._stackingCanvasRecoveryTimerId = null;
|
||||
this._stackingCanvasRecoveryUntil = 0;
|
||||
if (this._container) {
|
||||
this._updatePlaceholderMode();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private _syncCanvasBudgetForViewportZoom() {
|
||||
const nextZoom = this.viewport.zoom;
|
||||
|
||||
if (
|
||||
!shouldSyncCanvasBudgetOnViewportUpdate(
|
||||
this._lastCanvasBudgetZoom,
|
||||
nextZoom
|
||||
)
|
||||
) {
|
||||
this._lastCanvasBudgetZoom = nextZoom;
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastCanvasBudgetZoom = nextZoom;
|
||||
this._resetSize();
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _updatePlaceholderMode() {
|
||||
const gestureActive =
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value;
|
||||
const recoveryActive = this._isStackingCanvasRecoveryActive();
|
||||
const lowZoomSurvivalMode = shouldUseLowZoomSurvivalMode(
|
||||
IS_IOS,
|
||||
this.viewport.zoom,
|
||||
gestureActive
|
||||
);
|
||||
const shouldBypassStackingCanvases =
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
gestureActive,
|
||||
recoveryActive,
|
||||
viewportWidth: this.viewport.width,
|
||||
viewportHeight: this.viewport.height,
|
||||
});
|
||||
const shouldRenderPlaceholders = shouldRenderCanvasPlaceholders({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
isPanning: this.viewport.panning$.value,
|
||||
isZooming: this.viewport.zooming$.value,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
turboEnabled: this._turboEnabled(),
|
||||
});
|
||||
|
||||
const bypassModeChanged =
|
||||
this._lastBypassStackingCanvases !== shouldBypassStackingCanvases;
|
||||
|
||||
this._syncStackingCanvasAttachment(!shouldBypassStackingCanvases);
|
||||
|
||||
if (this.usePlaceholder === shouldRenderPlaceholders) {
|
||||
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
|
||||
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
|
||||
if (bypassModeChanged) {
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.usePlaceholder = shouldRenderPlaceholders;
|
||||
const survivalModeChanged =
|
||||
this._lastLowZoomSurvivalMode !== lowZoomSurvivalMode;
|
||||
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
|
||||
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
|
||||
|
||||
if (
|
||||
survivalModeChanged ||
|
||||
bypassModeChanged ||
|
||||
!this.viewport.SKIP_REFRESH_DURING_GESTURE ||
|
||||
!gestureActive
|
||||
) {
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
}
|
||||
|
||||
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
|
||||
const layer = this.layerManager;
|
||||
const updateStackingCanvas = () => {
|
||||
@@ -476,7 +775,9 @@ export class CanvasRenderer {
|
||||
};
|
||||
|
||||
if (diff > 0) {
|
||||
payload.added = canvases.slice(-diff);
|
||||
if (this._stackingCanvasesAttached) {
|
||||
payload.added = canvases.slice(-diff);
|
||||
}
|
||||
} else {
|
||||
payload.removed = currentCanvases.slice(diff);
|
||||
payload.removed.forEach(canvas => {
|
||||
@@ -485,7 +786,9 @@ export class CanvasRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
this.stackingCanvasUpdated.next(payload);
|
||||
if (payload.added.length || payload.removed.length) {
|
||||
this.stackingCanvasUpdated.next(payload);
|
||||
}
|
||||
}
|
||||
|
||||
this.refresh({ type: 'all' });
|
||||
@@ -503,41 +806,131 @@ export class CanvasRenderer {
|
||||
private _initViewport() {
|
||||
let sizeUpdatedRafId: number | null = null;
|
||||
|
||||
this._disposables.add({
|
||||
dispose: () => this._clearStackingCanvasRecoveryTimer(),
|
||||
});
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zoomUpdated.subscribe(() => {
|
||||
this._syncCanvasBudgetForViewportZoom();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
this._updatePlaceholderMode();
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.refresh({ type: 'all' });
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => {
|
||||
if (
|
||||
IS_IOS &&
|
||||
this.viewport.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
this.viewport.width > this.viewport.height
|
||||
) {
|
||||
this._scheduleStackingCanvasRecoveryWindow();
|
||||
if (this._container) {
|
||||
this._updatePlaceholderMode();
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeUpdatedRafId) return;
|
||||
sizeUpdatedRafId = requestConnectedFrame(() => {
|
||||
sizeUpdatedRafId = null;
|
||||
this._resetSize();
|
||||
this._render();
|
||||
// When SKIP_REFRESH_DURING_GESTURE is active, schedule the render
|
||||
// after a short delay to let the layout settle on orientation change,
|
||||
// avoiding a white-flash from resizing + rendering in the same frame.
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
setTimeout(() => this._render(), 16);
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
}, this._container);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(isZooming => {
|
||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||
|
||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||
this.usePlaceholder = shouldRenderPlaceholders;
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
this.viewport.zooming$.subscribe(() => {
|
||||
this._updatePlaceholderMode();
|
||||
})
|
||||
);
|
||||
|
||||
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer heavy canvas work
|
||||
// while the gesture is still in-flight, but start the first recovery frame
|
||||
// immediately once both gesture signals have fully settled.
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingCanvasTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelPendingCanvasRefresh = () => {
|
||||
if (pendingCanvasTimerId !== null) {
|
||||
clearTimeout(pendingCanvasTimerId);
|
||||
pendingCanvasTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleCanvasRefresh = () => {
|
||||
cancelPendingCanvasRefresh();
|
||||
const delayMs = getPostGestureRecoveryDelay({
|
||||
isPanning: this.viewport.panning$.value,
|
||||
isZooming: this.viewport.zooming$.value,
|
||||
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
|
||||
});
|
||||
pendingCanvasTimerId = setTimeout(() => {
|
||||
pendingCanvasTimerId = null;
|
||||
// If a gesture is still in-flight when the timer fires, reschedule
|
||||
// instead of dropping. Dropping here left connectors blank until a
|
||||
// tap forced a synchronous refresh.
|
||||
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||
scheduleCanvasRefresh();
|
||||
return;
|
||||
}
|
||||
this.refresh({ type: 'all' });
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
this._updatePlaceholderMode();
|
||||
if (panning) {
|
||||
cancelPendingCanvasRefresh();
|
||||
} else {
|
||||
scheduleCanvasRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
this._updatePlaceholderMode();
|
||||
if (zooming) {
|
||||
cancelPendingCanvasRefresh();
|
||||
} else {
|
||||
scheduleCanvasRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add({ dispose: cancelPendingCanvasRefresh });
|
||||
}
|
||||
|
||||
let wasDragging = false;
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
const isDragging = this._gfx.tool.dragging$.value;
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
this.refresh({ type: 'all' });
|
||||
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||
// Deferred refresh will handle it after gesture ends
|
||||
} else {
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
}
|
||||
|
||||
wasDragging = isDragging;
|
||||
@@ -572,16 +965,34 @@ export class CanvasRenderer {
|
||||
|
||||
private _render() {
|
||||
const renderStart = performance.now();
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
const { overscanViewportBounds, viewportBounds, zoom } = this.viewport;
|
||||
const {
|
||||
cullBound: mainCanvasCullBound,
|
||||
renderBound: mainCanvasRenderBound,
|
||||
} = getMainCanvasFallbackBounds({
|
||||
viewportBounds,
|
||||
overscanViewportBounds,
|
||||
});
|
||||
const { ctx } = this;
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(zoom);
|
||||
const scale = zoom * dpr;
|
||||
const matrix = new DOMMatrix().scaleSelf(scale);
|
||||
const renderStats = this._createRenderPassStats();
|
||||
const fullRender = this._needsFullRender;
|
||||
const stackingIndexesToRender = fullRender
|
||||
? this._stackingCanvas.map((_, idx) => idx)
|
||||
: [...this._dirtyStackingCanvasIndexes];
|
||||
const bypassStackingCanvases = getStackingCanvasBypassState({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
recoveryActive: this._isStackingCanvasRecoveryActive(),
|
||||
viewportWidth: this.viewport.width,
|
||||
viewportHeight: this.viewport.height,
|
||||
});
|
||||
const stackingIndexesToRender = bypassStackingCanvases
|
||||
? []
|
||||
: fullRender
|
||||
? this._stackingCanvas.map((_, idx) => idx)
|
||||
: [...this._dirtyStackingCanvasIndexes];
|
||||
/**
|
||||
* if a layer does not have a corresponding canvas
|
||||
* its element will be add to this array and drawing on the
|
||||
@@ -589,7 +1000,15 @@ export class CanvasRenderer {
|
||||
*/
|
||||
let fallbackElement: SurfaceElementModel[] = [];
|
||||
const allCanvasLayers = this.layerManager.getCanvasLayers();
|
||||
const viewportBound = Bound.from(viewportBounds);
|
||||
const stackingViewportBound = Bound.from(overscanViewportBounds);
|
||||
|
||||
this._canvasSizeUpdater(mainCanvasRenderBound, dpr).update(this.canvas);
|
||||
|
||||
if (bypassStackingCanvases) {
|
||||
this._stackingCanvas.forEach(canvas => {
|
||||
this._applyStackingCanvasLayout(canvas, null, dpr);
|
||||
});
|
||||
}
|
||||
|
||||
for (const idx of stackingIndexesToRender) {
|
||||
const layer = allCanvasLayers[idx];
|
||||
@@ -601,7 +1020,7 @@ export class CanvasRenderer {
|
||||
|
||||
const layerRenderBound = this._getLayerRenderBound(
|
||||
layer.elements,
|
||||
viewportBound
|
||||
stackingViewportBound
|
||||
);
|
||||
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
|
||||
canvas,
|
||||
@@ -638,7 +1057,12 @@ export class CanvasRenderer {
|
||||
|
||||
if (fullRender || this._mainCanvasDirty) {
|
||||
allCanvasLayers.forEach((layer, idx) => {
|
||||
if (!this._stackingCanvas[idx]) {
|
||||
if (
|
||||
bypassStackingCanvases ||
|
||||
!this._stackingCanvas[idx] ||
|
||||
this._stackingCanvas[idx].width === 0 ||
|
||||
this._stackingCanvas[idx].height === 0
|
||||
) {
|
||||
fallbackElement = fallbackElement.concat(layer.elements);
|
||||
}
|
||||
});
|
||||
@@ -651,10 +1075,11 @@ export class CanvasRenderer {
|
||||
ctx,
|
||||
matrix,
|
||||
new RoughCanvas(ctx.canvas),
|
||||
viewportBounds,
|
||||
mainCanvasRenderBound,
|
||||
fallbackElement,
|
||||
true,
|
||||
renderStats
|
||||
renderStats,
|
||||
mainCanvasCullBound
|
||||
);
|
||||
}
|
||||
|
||||
@@ -726,7 +1151,8 @@ export class CanvasRenderer {
|
||||
bound: IBound,
|
||||
surfaceElements?: SurfaceElementModel[],
|
||||
overLay: boolean = false,
|
||||
renderStats?: RenderPassStats
|
||||
renderStats?: RenderPassStats,
|
||||
cullBound: IBound = bound
|
||||
) {
|
||||
if (!ctx) return;
|
||||
|
||||
@@ -734,13 +1160,13 @@ export class CanvasRenderer {
|
||||
|
||||
const elements =
|
||||
surfaceElements ??
|
||||
(this.grid.search(bound, {
|
||||
(this.grid.search(cullBound, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[]);
|
||||
|
||||
for (const element of elements) {
|
||||
const display = (element.display ?? true) && !element.hidden;
|
||||
if (display && intersects(getBoundWithRotation(element), bound)) {
|
||||
if (display && intersects(getBoundWithRotation(element), cullBound)) {
|
||||
renderStats && (renderStats.visibleElementCount += 1);
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
@@ -748,7 +1174,7 @@ export class CanvasRenderer {
|
||||
) {
|
||||
renderStats && (renderStats.placeholderElementCount += 1);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||
ctx.fillStyle = resolveSurfacePlaceholderColor(this.getColorScheme());
|
||||
const drawX = element.x - bound.x;
|
||||
const drawY = element.y - bound.y;
|
||||
ctx.fillRect(drawX, drawY, element.w, element.h);
|
||||
@@ -785,9 +1211,12 @@ export class CanvasRenderer {
|
||||
}
|
||||
|
||||
private _resetSize() {
|
||||
const sizeUpdater = this._canvasSizeUpdater();
|
||||
const sizeUpdater = this._canvasSizeUpdater(
|
||||
this.viewport.overscanViewportBounds
|
||||
);
|
||||
|
||||
sizeUpdater.update(this.canvas);
|
||||
this._lastCanvasBudgetZoom = this.viewport.zoom;
|
||||
this._invalidate({ type: 'all' });
|
||||
}
|
||||
|
||||
@@ -838,6 +1267,7 @@ export class CanvasRenderer {
|
||||
this._container = container;
|
||||
container.append(this.canvas);
|
||||
|
||||
this._updatePlaceholderMode();
|
||||
this._resetSize();
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
@@ -864,8 +1294,11 @@ export class CanvasRenderer {
|
||||
canvas = canvas || document.createElement('canvas');
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
|
||||
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
|
||||
const actualWidth = Math.ceil(bound.w * dpr);
|
||||
const actualHeight = Math.ceil(bound.h * dpr);
|
||||
|
||||
if (canvas.width !== actualWidth) canvas.width = actualWidth;
|
||||
if (canvas.height !== actualHeight) canvas.height = actualHeight;
|
||||
|
||||
canvas.style.width = `${bound.w}px`;
|
||||
canvas.style.height = `${bound.h}px`;
|
||||
|
||||
@@ -19,12 +19,14 @@ import type {
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { SurfaceElementModel } from '../element-model/base.js';
|
||||
import type { DomElementRenderer } from './dom-elements/index.js';
|
||||
import { DomElementRendererIdentifier } from './dom-elements/index.js';
|
||||
import type { Overlay } from './overlay.js';
|
||||
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||
|
||||
type EnvProvider = {
|
||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||
@@ -222,6 +224,12 @@ export class DomRenderer {
|
||||
private _initViewport() {
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
@@ -242,6 +250,9 @@ export class DomRenderer {
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(isZooming => {
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
return;
|
||||
}
|
||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||
|
||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||
@@ -252,6 +263,43 @@ export class DomRenderer {
|
||||
})
|
||||
);
|
||||
|
||||
// Post-gesture refresh for SKIP mode
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
cancelRefresh();
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
}
|
||||
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) cancelRefresh();
|
||||
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) cancelRefresh();
|
||||
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add({ dispose: cancelRefresh });
|
||||
}
|
||||
|
||||
this.usePlaceholder = false;
|
||||
}
|
||||
|
||||
@@ -292,12 +340,15 @@ export class DomRenderer {
|
||||
domElement = document.createElement('div');
|
||||
domElement.dataset.elementId = elementModel.id;
|
||||
domElement.style.position = 'absolute';
|
||||
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
|
||||
this._elementsMap.set(elementModel.id, domElement);
|
||||
this.rootElement.append(domElement);
|
||||
addedElements.push(domElement);
|
||||
}
|
||||
|
||||
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
|
||||
this.getColorScheme()
|
||||
);
|
||||
|
||||
const geometricStyles = calculatePlaceholderRect(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ColorScheme } from '@blocksuite/affine-model';
|
||||
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||
|
||||
export function getSurfacePlaceholderFallback(colorScheme: ColorScheme) {
|
||||
return getAffinePlaceholderFillColor(colorScheme);
|
||||
}
|
||||
|
||||
export function resolveSurfacePlaceholderColor(colorScheme: ColorScheme) {
|
||||
return getSurfacePlaceholderFallback(colorScheme);
|
||||
}
|
||||
@@ -527,6 +527,9 @@ export class SelectionController implements ReactiveController {
|
||||
removeNativeSelection = true
|
||||
) {
|
||||
if (selection) {
|
||||
if (this.hasExternalNativeSelection()) {
|
||||
return;
|
||||
}
|
||||
const previous = this.getSelected();
|
||||
if (TableSelectionData.equals(previous, selection)) {
|
||||
return;
|
||||
@@ -551,4 +554,24 @@ export class SelectionController implements ReactiveController {
|
||||
);
|
||||
return selection?.is(TableSelection) ? selection.data : undefined;
|
||||
}
|
||||
|
||||
private hasExternalNativeSelection() {
|
||||
const selection = getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.intersectsNode(this.host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchorNode;
|
||||
const focusNode = selection.focusNode;
|
||||
return (
|
||||
!!anchorNode &&
|
||||
!!focusNode &&
|
||||
(!this.host.contains(anchorNode) || !this.host.contains(focusNode))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
const externalRangeSelectionSelector =
|
||||
'affine-table[data-external-range-selection]';
|
||||
const hiddenSelectionBackground = '#fff';
|
||||
|
||||
export const tableContainer = css({
|
||||
display: 'block',
|
||||
padding: '10px 0 18px 10px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
'& *': {
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
},
|
||||
[`${externalRangeSelectionSelector} &::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
[`${externalRangeSelectionSelector} & *::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
[`${externalRangeSelectionSelector} & rich-text::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
[`${externalRangeSelectionSelector} & rich-text *::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
'::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
|
||||
@@ -5,7 +5,10 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import {
|
||||
RANGE_QUERY_EXCLUDE_ATTR,
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
} from '@blocksuite/std/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
@@ -37,7 +40,80 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true');
|
||||
this.style.position = 'relative';
|
||||
const doc = this.ownerDocument;
|
||||
this.disposables.addFromEvent(doc, 'selectionchange', () => {
|
||||
const hasExternalNativeSelection = this.hasExternalNativeSelection();
|
||||
this.toggleAttribute(
|
||||
'data-external-range-selection',
|
||||
hasExternalNativeSelection
|
||||
);
|
||||
if (hasExternalNativeSelection) {
|
||||
delete this.dataset.internalRangeSelection;
|
||||
}
|
||||
this.setInternalEditablesEnabled(!hasExternalNativeSelection);
|
||||
});
|
||||
this.disposables.addFromEvent(
|
||||
doc,
|
||||
'pointerdown',
|
||||
event => {
|
||||
const target = event.target;
|
||||
const NodeConstructor = this.ownerDocument.defaultView?.Node;
|
||||
if (
|
||||
NodeConstructor &&
|
||||
target instanceof NodeConstructor &&
|
||||
this.contains(target)
|
||||
) {
|
||||
this.setInternalEditablesEnabled(true);
|
||||
if (this.hasExternalNativeSelection()) {
|
||||
this.ownerDocument.getSelection()?.removeAllRanges();
|
||||
}
|
||||
delete this.dataset.externalRangeSelection;
|
||||
this.dataset.internalRangeSelection = 'true';
|
||||
} else {
|
||||
delete this.dataset.internalRangeSelection;
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
|
||||
private setInternalEditablesEnabled(enabled: boolean) {
|
||||
this.querySelectorAll<HTMLElement>('.inline-editor').forEach(editor => {
|
||||
if (enabled) {
|
||||
if (editor.dataset.tableExternalSelectionDisabled === 'true') {
|
||||
editor.contentEditable = 'true';
|
||||
delete editor.dataset.tableExternalSelectionDisabled;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.contentEditable === 'true') {
|
||||
editor.contentEditable = 'false';
|
||||
editor.dataset.tableExternalSelectionDisabled = 'true';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasExternalNativeSelection() {
|
||||
const selection = this.ownerDocument.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.intersectsNode(this)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchorNode;
|
||||
const focusNode = selection.focusNode;
|
||||
return (
|
||||
!!anchorNode &&
|
||||
!!focusNode &&
|
||||
(!this.contains(anchorNode) || !this.contains(focusNode))
|
||||
);
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
|
||||
@@ -10,6 +10,18 @@ export const cellContainerStyle = css({
|
||||
isolation: 'auto',
|
||||
textAlign: 'start',
|
||||
verticalAlign: 'top',
|
||||
'affine-table[data-internal-range-selection="true"] &': {
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
'affine-table[data-internal-range-selection="true"] & rich-text': {
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
'affine-table[data-internal-range-selection="true"] & rich-text *': {
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
export const columnOptionsCellStyle = css({
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
sortByManually,
|
||||
} from '../../core/group-by/trait.js';
|
||||
import { fromJson } from '../../core/property/utils';
|
||||
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||
import type { Row } from '../../core/view-manager/row.js';
|
||||
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
||||
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
||||
@@ -92,6 +94,19 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
return this.data$.value?.filter ?? emptyFilterGroup;
|
||||
});
|
||||
|
||||
private readonly sortList$ = computed(() => {
|
||||
return this.data$.value?.sort;
|
||||
});
|
||||
|
||||
private readonly sortManager = this.traitSet(
|
||||
sortTraitKey,
|
||||
new SortManager(this.sortList$, this, {
|
||||
setSortList: sortList => {
|
||||
this.dataUpdate(data => ({ sort: { ...data.sort, ...sortList } }));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
filterTrait = this.traitSet(
|
||||
filterTraitKey,
|
||||
new FilterTrait(this.filter$, this, {
|
||||
@@ -140,6 +155,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
return asc === false ? sorted.reverse() : sorted;
|
||||
},
|
||||
sortRow: (key, rows) => {
|
||||
if (this.sortManager.hasSort$.value) return rows;
|
||||
const property = this.view?.groupProperties.find(v => v.key === key);
|
||||
return sortByManually(
|
||||
rows,
|
||||
@@ -359,6 +375,10 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override rowsMapping(rows: Row[]): Row[] {
|
||||
return this.sortManager.sort(super.rowsMapping(rows));
|
||||
}
|
||||
|
||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||
return new KanbanColumn(this, columnId);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type LocalConnectorElementModel,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
getBezierParameters,
|
||||
type PointLocation,
|
||||
@@ -253,7 +254,7 @@ function renderLabel(
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
if (renderer.usePlaceholder) {
|
||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return; // Skip actual label rendering
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
getAffinePlaceholderFillColor,
|
||||
getAffinePlaceholderStrokeColor,
|
||||
inferColorSchemeFromThemeMode,
|
||||
} from '@blocksuite/affine-shared/theme';
|
||||
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
|
||||
import { type Viewport } from '@blocksuite/std/gfx';
|
||||
import { getEffectiveDpr, type Viewport } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
|
||||
@@ -10,9 +15,13 @@ import type {
|
||||
ViewportLayoutTree,
|
||||
} from './types';
|
||||
|
||||
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
|
||||
export function syncCanvasSize(
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom = 1
|
||||
) {
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(zoom);
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.left = '0px';
|
||||
canvas.style.top = '0px';
|
||||
@@ -186,21 +195,21 @@ export function paintPlaceholder(
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || !layout) return;
|
||||
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(viewport.zoom);
|
||||
const { overallRect } = layout;
|
||||
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
|
||||
|
||||
const offsetX = layoutViewCoord[0];
|
||||
const offsetY = layoutViewCoord[1];
|
||||
const colors = [
|
||||
'rgba(200, 200, 200, 0.7)',
|
||||
'rgba(180, 180, 180, 0.7)',
|
||||
'rgba(160, 160, 160, 0.7)',
|
||||
];
|
||||
const colorScheme = inferColorSchemeFromThemeMode(
|
||||
document.documentElement.dataset.theme
|
||||
);
|
||||
const fillColor = getAffinePlaceholderFillColor(colorScheme);
|
||||
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
|
||||
|
||||
const paintNode = (node: BlockLayoutTreeNode, depth: number = 0) => {
|
||||
const paintNode = (node: BlockLayoutTreeNode) => {
|
||||
const { layout: nodeLayout } = node;
|
||||
ctx.fillStyle = colors[depth % colors.length];
|
||||
ctx.fillStyle = fillColor;
|
||||
const rect = nodeLayout.rect;
|
||||
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
|
||||
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
|
||||
@@ -209,12 +218,12 @@ export function paintPlaceholder(
|
||||
|
||||
ctx.fillRect(x, y, width, height);
|
||||
if (width > 10 && height > 5) {
|
||||
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
node.children.forEach(childNode => paintNode(childNode, depth + 1));
|
||||
node.children.forEach(childNode => paintNode(childNode));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_IOS } from '@blocksuite/global/env';
|
||||
import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
import {
|
||||
getEffectiveDpr,
|
||||
type GfxController,
|
||||
GfxExtension,
|
||||
GfxExtensionIdentifier,
|
||||
type GfxViewportElement,
|
||||
viewportRuntimeConfig,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -34,6 +37,26 @@ import type {
|
||||
} from './types';
|
||||
|
||||
const debug = false; // Toggle for debug logs
|
||||
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
|
||||
|
||||
export function shouldPreferBitmapCacheDuringLowZoomGesture(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
hasBitmap: boolean;
|
||||
}) {
|
||||
return (
|
||||
params.isIOS &&
|
||||
params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
params.hasBitmap
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldIdleTurboBlocksDuringZooming(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
}) {
|
||||
return !(params.isIOS && params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD);
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
|
||||
@@ -147,7 +170,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
|
||||
this.viewportElement = element;
|
||||
syncCanvasSize(this.canvas, this.std.host);
|
||||
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||
this.state$.next('pending');
|
||||
|
||||
this.disposables.add(
|
||||
@@ -156,6 +179,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.refresh().catch(console.error);
|
||||
})
|
||||
);
|
||||
@@ -166,7 +195,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
tap(isZooming => {
|
||||
this.debugLog(`Zooming signal changed: ${isZooming}`);
|
||||
if (isZooming) {
|
||||
this.state$.next('zooming');
|
||||
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
this.state$.next('zooming');
|
||||
}
|
||||
} else if (this.state$.value === 'zooming') {
|
||||
this.clearOptimizedBlocks();
|
||||
this.isRecentlyZoomed$.next(true);
|
||||
@@ -183,6 +214,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
)
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
// Post-gesture refresh for SKIP mode
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
cancelRefresh();
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
if (
|
||||
!this.viewport.panning$.value &&
|
||||
!this.viewport.zooming$.value
|
||||
) {
|
||||
this.refresh().catch(console.error);
|
||||
}
|
||||
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||
};
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) cancelRefresh();
|
||||
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) cancelRefresh();
|
||||
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this.disposables.add({ dispose: cancelRefresh });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle selection and block updates
|
||||
@@ -235,10 +305,22 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
nextState = 'pending';
|
||||
this.clearOptimizedBlocks();
|
||||
} else if (this.isZooming()) {
|
||||
this.debugLog('Currently zooming, using placeholder rendering');
|
||||
nextState = 'zooming';
|
||||
this.paintPlaceholder();
|
||||
this.updateOptimizedBlocks();
|
||||
if (
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
hasBitmap: !!this.bitmap,
|
||||
})
|
||||
) {
|
||||
this.debugLog('Currently zooming, reusing cached bitmap');
|
||||
this.clearOptimizedBlocks();
|
||||
this.drawCachedBitmap();
|
||||
} else {
|
||||
this.debugLog('Currently zooming, using placeholder rendering');
|
||||
this.paintPlaceholder();
|
||||
this.updateOptimizedBlocks();
|
||||
}
|
||||
} else if (this.canUseBitmapCache()) {
|
||||
this.debugLog('Using cached bitmap');
|
||||
nextState = 'ready';
|
||||
@@ -286,7 +368,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
}
|
||||
|
||||
const layout = this.layoutCache;
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||
const currentVersion = this.layoutVersion;
|
||||
|
||||
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
||||
@@ -368,12 +450,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
layout.overallRect.y
|
||||
);
|
||||
|
||||
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
layoutViewCoord[0] * window.devicePixelRatio,
|
||||
layoutViewCoord[1] * window.devicePixelRatio,
|
||||
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
|
||||
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
|
||||
layoutViewCoord[0] * dpr,
|
||||
layoutViewCoord[1] * dpr,
|
||||
layout.overallRect.w * dpr * this.viewport.zoom,
|
||||
layout.overallRect.h * dpr * this.viewport.zoom
|
||||
);
|
||||
|
||||
this.debugLog('Bitmap drawn to canvas');
|
||||
@@ -389,6 +473,16 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
private updateOptimizedBlocks() {
|
||||
if (!this.canOptimize()) return;
|
||||
if (
|
||||
!shouldIdleTurboBlocksDuringZooming({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
})
|
||||
) {
|
||||
this.clearOptimizedBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.viewportElement || !this.layoutCache) return;
|
||||
const blockElements = this.viewportElement.getModelsInViewport();
|
||||
@@ -416,7 +510,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
private handleResize() {
|
||||
this.debugLog('Container resized, syncing canvas size');
|
||||
syncCanvasSize(this.canvas, this.std.host);
|
||||
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||
this.invalidate();
|
||||
this.refresh$.next();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
|
||||
@@ -160,7 +160,6 @@ export class AffineLink extends WithDisposable(ShadowlessElement) {
|
||||
const linkStyle = {
|
||||
color: 'var(--affine-link-color)',
|
||||
fill: 'var(--affine-link-color)',
|
||||
'text-decoration': 'none',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"bytes": "^3.1.2",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
@@ -46,6 +46,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tldts": "^7.0.19",
|
||||
"ts-pattern": "^5.1.0",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { sanitizeSvg } from '../../utils/svg.js';
|
||||
|
||||
type HappyDOMWindow = Window & {
|
||||
happyDOM: {
|
||||
setURL: (url: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function setLocation(url: string) {
|
||||
(window as unknown as HappyDOMWindow).happyDOM.setURL(url);
|
||||
}
|
||||
|
||||
function svgDataUrl(svg: string) {
|
||||
const bytes = new TextEncoder().encode(svg);
|
||||
let binary = '';
|
||||
bytes.forEach(byte => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return `data:image/svg+xml;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
function decodeSvgDataUrl(dataUrl: string) {
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(base64), char => char.charCodeAt(0))
|
||||
);
|
||||
}
|
||||
|
||||
describe('sanitizeSvg', () => {
|
||||
test('wraps DOMPurify svg fragments back into an svg root', () => {
|
||||
const sanitized = sanitizeSvg(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
|
||||
);
|
||||
|
||||
expect(sanitized).toContain('<svg');
|
||||
expect(sanitized).toContain('width="100"');
|
||||
expect(sanitized).toContain('<rect');
|
||||
});
|
||||
|
||||
test('accepts svg documents with xml and doctype prefixes', () => {
|
||||
const sanitized = sanitizeSvg(`<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<rect width="100" height="100"></rect>
|
||||
</svg>`);
|
||||
|
||||
expect(sanitized).toContain('<svg');
|
||||
expect(sanitized).toContain('width="100"');
|
||||
expect(sanitized).toContain('<rect');
|
||||
expect(sanitized).not.toContain('<!DOCTYPE');
|
||||
});
|
||||
|
||||
test('rejects non-svg roots', () => {
|
||||
expect(sanitizeSvg('<div><svg></svg></div>')).toBe('');
|
||||
});
|
||||
|
||||
test('rejects malformed doctype prefixes without regexp backtracking', () => {
|
||||
const maliciousPrefix = '<!doctype' + '?><!doctype'.repeat(10_000);
|
||||
|
||||
expect(sanitizeSvg(`${maliciousPrefix}<div></div>`)).toBe('');
|
||||
});
|
||||
|
||||
test('keeps internal glyph references and safe image data urls', () => {
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<defs><path id="glyph-a" d="M0 0h10v10z"></path></defs>
|
||||
<use href="#glyph-a"></use>
|
||||
<use xlink:href="#glyph-a"></use>
|
||||
<a xlink:href="https://typst.app/docs/tutorial"><path d="M0 0h10v10z"></path></a>
|
||||
<image href="data:image/png;base64,AAAA" width="10" height="10"></image>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
expect(sanitized).toContain('href="#glyph-a"');
|
||||
expect(sanitized).toContain('xlink:href="#glyph-a"');
|
||||
expect(sanitized).toContain('xlink:href="https://typst.app/docs/tutorial"');
|
||||
expect(sanitized).toContain('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
test('removes external glyph references and unsafe css', () => {
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<style>@import "https://example.com/style.css"; .a { fill: #000; }</style>
|
||||
<use href="https://example.com/glyph.svg#x"></use>
|
||||
<use xlink:href="https://example.com/glyph.svg#x"></use>
|
||||
<a xlink:href="javascript:alert(1)"><path d="M0 0h10v10z"></path></a>
|
||||
<image href="https://example.com/image.png" width="10" height="10"></image>
|
||||
<path style="fill: url(https://example.com/pattern.svg#x)" d="M0 0h10v10z"></path>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
expect(sanitized).not.toContain('https://example.com');
|
||||
expect(sanitized).not.toContain('javascript:');
|
||||
expect(sanitized).not.toContain('@import');
|
||||
expect(sanitized).not.toContain('url(');
|
||||
});
|
||||
|
||||
test('removes links sharing the current registrable domain', () => {
|
||||
setLocation('https://sub.example.co.uk/workspace');
|
||||
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a xlink:href="https://sub.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
|
||||
<a href="https://other.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
|
||||
<a xlink:href="https://example.com/docs"><path d="M0 0h10v10z"></path></a>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
expect(sanitized).not.toContain('https://sub.example.co.uk/docs');
|
||||
expect(sanitized).not.toContain('https://other.example.co.uk/docs');
|
||||
expect(sanitized).toContain('https://example.com/docs');
|
||||
});
|
||||
|
||||
test('keeps private suffix sibling domains separate', () => {
|
||||
setLocation('https://foo.github.io/workspace');
|
||||
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a xlink:href="https://foo.github.io/docs"><path d="M0 0h10v10z"></path></a>
|
||||
<a href="https://bar.github.io/docs"><path d="M0 0h10v10z"></path></a>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
expect(sanitized).not.toContain('https://foo.github.io/docs');
|
||||
expect(sanitized).toContain('https://bar.github.io/docs');
|
||||
});
|
||||
|
||||
test('handles local hostnames by exact hostname', () => {
|
||||
setLocation('http://localhost:3000/workspace');
|
||||
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a xlink:href="http://localhost:8080/docs"><path d="M0 0h10v10z"></path></a>
|
||||
<a href="http://share.localhost/docs"><path d="M0 0h10v10z"></path></a>
|
||||
<a href="http://127.0.0.1/docs"><path d="M0 0h10v10z"></path></a>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
expect(sanitized).not.toContain('http://localhost:8080/docs');
|
||||
expect(sanitized).toContain('http://share.localhost/docs');
|
||||
expect(sanitized).toContain('http://127.0.0.1/docs');
|
||||
});
|
||||
|
||||
test('recursively sanitizes svg images', () => {
|
||||
const nestedSvg = svgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="glyph-a" d="M0 0h10v10z"></path></defs><use href="#glyph-a"></use><use href="https://example.com/glyph.svg#x"></use></svg>'
|
||||
);
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<image href="${nestedSvg}" width="10" height="10"></image>
|
||||
</svg>
|
||||
`);
|
||||
const sanitizedImageHref = sanitized.match(/href="([^"]+)"/)?.[1];
|
||||
|
||||
expect(sanitizedImageHref).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('<svg');
|
||||
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('#glyph-a');
|
||||
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).not.toContain(
|
||||
'https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('removes svg images nested deeper than two levels', () => {
|
||||
const thirdLevelSvg = svgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"></rect></svg>'
|
||||
);
|
||||
const secondLevelSvg = svgDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${thirdLevelSvg}"></image></svg>`
|
||||
);
|
||||
const firstLevelSvg = svgDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${secondLevelSvg}"></image></svg>`
|
||||
);
|
||||
const sanitized = sanitizeSvg(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<image href="${firstLevelSvg}"></image>
|
||||
</svg>
|
||||
`);
|
||||
const firstLevelHref = sanitized.match(/href="([^"]+)"/)?.[1];
|
||||
const firstLevelSanitizedSvg = decodeSvgDataUrl(firstLevelHref ?? '');
|
||||
const secondLevelHref = firstLevelSanitizedSvg.match(/href="([^"]+)"/)?.[1];
|
||||
const secondLevelSanitizedSvg = decodeSvgDataUrl(secondLevelHref ?? '');
|
||||
|
||||
expect(firstLevelSanitizedSvg).toContain('<image');
|
||||
expect(secondLevelSanitizedSvg).not.toContain('<image');
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type ToSliceSnapshotPayload,
|
||||
type Transformer,
|
||||
} from '@blocksuite/store';
|
||||
import DOMPurify from 'dompurify';
|
||||
import pdfMake from 'pdfmake/build/pdfmake';
|
||||
import type {
|
||||
Content,
|
||||
@@ -29,6 +28,7 @@ import type {
|
||||
} from 'pdfmake/interfaces';
|
||||
|
||||
import { getNumberPrefix } from '../../utils';
|
||||
import { sanitizeSvg } from '../../utils/svg.js';
|
||||
import { resolveCssVariable } from './css-utils.js';
|
||||
import { extractTextWithInline } from './delta-converter.js';
|
||||
import {
|
||||
@@ -746,9 +746,8 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (trimmedText.startsWith('<svg')) {
|
||||
const svgContent = DOMPurify.sanitize(trimmedText, {
|
||||
USE_PROFILES: { svg: true },
|
||||
});
|
||||
const svgContent = sanitizeSvg(trimmedText);
|
||||
if (!svgContent) throw new Error('Invalid SVG image asset');
|
||||
const svgDimensions = extractSvgDimensions(svgContent);
|
||||
const dimensions = calculateImageDimensions(
|
||||
blockWidth,
|
||||
|
||||
@@ -129,32 +129,35 @@ export const getSelectedBlocksCommand: Command<
|
||||
dirtyResult = dirtyResult.filter(ctx.filter);
|
||||
}
|
||||
|
||||
const getModelPath = (el: BlockComponent) => {
|
||||
const path: number[] = [];
|
||||
let model = el.model;
|
||||
while (model) {
|
||||
const parent = ctx.std.store.getParent(model.id);
|
||||
if (!parent) break;
|
||||
path.unshift(parent.children.findIndex(child => child.id === model.id));
|
||||
model = parent;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const compareByModelPath = (a: BlockComponent, b: BlockComponent) => {
|
||||
if (a === b) return 0;
|
||||
const aPath = getModelPath(a);
|
||||
const bPath = getModelPath(b);
|
||||
const length = Math.min(aPath.length, bPath.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = aPath[i] - bPath[i];
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return aPath.length - bPath.length;
|
||||
};
|
||||
|
||||
// remove duplicate elements
|
||||
const result: BlockComponent[] = dirtyResult
|
||||
.filter((el, index) => dirtyResult.indexOf(el) === index)
|
||||
// sort by document position
|
||||
.sort((a, b) => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = a.compareDocumentPosition(b);
|
||||
if (
|
||||
position & Node.DOCUMENT_POSITION_FOLLOWING ||
|
||||
position & Node.DOCUMENT_POSITION_CONTAINED_BY
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
position & Node.DOCUMENT_POSITION_PRECEDING ||
|
||||
position & Node.DOCUMENT_POSITION_CONTAINS
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
// sort by model tree position, which is the order used for paste/export
|
||||
.sort(compareByModelPath);
|
||||
|
||||
if (result.length === 0) return;
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './css-variables.js';
|
||||
export * from './placeholder-style.js';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
|
||||
export function inferColorSchemeFromThemeMode(
|
||||
themeMode?: string | null
|
||||
): ColorScheme {
|
||||
return themeMode === 'dark' ? ColorScheme.Dark : ColorScheme.Light;
|
||||
}
|
||||
|
||||
export function getAffinePlaceholderFillColor(colorScheme: ColorScheme) {
|
||||
return colorScheme === ColorScheme.Dark
|
||||
? 'rgba(255, 255, 255, 0.08)'
|
||||
: 'rgba(0, 0, 0, 0.04)';
|
||||
}
|
||||
|
||||
export function getAffinePlaceholderStrokeColor(colorScheme: ColorScheme) {
|
||||
return colorScheme === ColorScheme.Dark
|
||||
? 'rgba(255, 255, 255, 0.04)'
|
||||
: 'rgba(0, 0, 0, 0.02)';
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export * from './reordering';
|
||||
export * from './safe-html';
|
||||
export * from './signal';
|
||||
export * from './string';
|
||||
export * from './svg';
|
||||
export * from './title';
|
||||
export * from './url';
|
||||
export * from './virtual-padding';
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { Config } from 'dompurify';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { parse } from 'tldts';
|
||||
|
||||
type SanitizeSvgOptions = {
|
||||
svg?: Config;
|
||||
foreignObjectHtml?: Config;
|
||||
};
|
||||
|
||||
const MAX_NESTED_SVG_IMAGE_DEPTH = 2;
|
||||
|
||||
const DEFAULT_SVG_SANITIZE_CONFIG: Config = {
|
||||
USE_PROFILES: { svg: true },
|
||||
ADD_TAGS: ['use'],
|
||||
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
|
||||
};
|
||||
|
||||
const DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
|
||||
USE_PROFILES: { html: true },
|
||||
};
|
||||
|
||||
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
||||
const SVG_DATA_URL_PATTERN =
|
||||
/^data:image\/svg\+xml(?:;charset=[^;,]+)?(?<base64>;base64)?,(?<data>[\s\S]*)$/i;
|
||||
const SAFE_IMAGE_DATA_URL_PATTERN =
|
||||
/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[a-z0-9+/=]+$/i;
|
||||
const UNSAFE_CSS_PATTERN =
|
||||
/(?:url\s*\(|@import|javascript\s*:|expression\s*\(|-moz-binding)/i;
|
||||
|
||||
const SVG_ROOT_ATTRIBUTES = [
|
||||
'class',
|
||||
'data-height',
|
||||
'data-width',
|
||||
'height',
|
||||
'preserveAspectRatio',
|
||||
'viewBox',
|
||||
'width',
|
||||
'xmlns',
|
||||
'xmlns:h5',
|
||||
'xmlns:xlink',
|
||||
];
|
||||
|
||||
function getAttribute(element: Element, attribute: string) {
|
||||
return (
|
||||
element.getAttribute(attribute) ??
|
||||
element.getAttribute(attribute.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function getSvgSanitizeConfig(options?: SanitizeSvgOptions) {
|
||||
return {
|
||||
...DEFAULT_SVG_SANITIZE_CONFIG,
|
||||
...options?.svg,
|
||||
};
|
||||
}
|
||||
|
||||
function getForeignObjectHtmlSanitizeConfig(options?: SanitizeSvgOptions) {
|
||||
return {
|
||||
...DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG,
|
||||
...options?.foreignObjectHtml,
|
||||
};
|
||||
}
|
||||
|
||||
function isXmlWhitespace(char: string) {
|
||||
return (
|
||||
char === ' ' ||
|
||||
char === '\n' ||
|
||||
char === '\r' ||
|
||||
char === '\t' ||
|
||||
char === '\f'
|
||||
);
|
||||
}
|
||||
|
||||
function skipXmlWhitespace(value: string, index: number) {
|
||||
while (index < value.length && isXmlWhitespace(value[index])) {
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function startsWithIgnoreCase(value: string, search: string, index: number) {
|
||||
return value.slice(index, index + search.length).toLowerCase() === search;
|
||||
}
|
||||
|
||||
function getSvgRootStartIndex(value: string) {
|
||||
let index = skipXmlWhitespace(value, 0);
|
||||
|
||||
if (startsWithIgnoreCase(value, '<?xml', index)) {
|
||||
const declarationEnd = value.indexOf('?>', index + 5);
|
||||
if (declarationEnd === -1) return -1;
|
||||
index = skipXmlWhitespace(value, declarationEnd + 2);
|
||||
}
|
||||
|
||||
if (startsWithIgnoreCase(value, '<!doctype', index)) {
|
||||
const doctypeEnd = value.indexOf('>', index + 9);
|
||||
if (doctypeEnd === -1) return -1;
|
||||
index = skipXmlWhitespace(value, doctypeEnd + 1);
|
||||
}
|
||||
|
||||
if (!startsWithIgnoreCase(value, '<svg', index)) return -1;
|
||||
|
||||
const next = value[index + 4];
|
||||
return next === '>' || (next !== undefined && isXmlWhitespace(next))
|
||||
? index
|
||||
: -1;
|
||||
}
|
||||
|
||||
function hasSvgRoot(value: string) {
|
||||
return getSvgRootStartIndex(value) !== -1;
|
||||
}
|
||||
|
||||
function getOriginalSvgRoot(svg: string, parser: DOMParser) {
|
||||
const root = parser.parseFromString(svg, 'image/svg+xml').documentElement;
|
||||
if (root?.tagName.toLowerCase() === 'svg') {
|
||||
return root;
|
||||
}
|
||||
if (!hasSvgRoot(svg)) {
|
||||
return null;
|
||||
}
|
||||
return parser.parseFromString(svg, 'text/html').querySelector('svg');
|
||||
}
|
||||
|
||||
function ensureSvgRoot(
|
||||
originalRoot: Element | null,
|
||||
sanitized: string,
|
||||
parser: DOMParser
|
||||
) {
|
||||
if (hasSvgRoot(sanitized)) {
|
||||
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
|
||||
const sanitizedRoot = sanitizedDoc.documentElement;
|
||||
return sanitizedRoot?.tagName.toLowerCase() === 'svg'
|
||||
? sanitizedRoot
|
||||
: null;
|
||||
}
|
||||
|
||||
const svgDoc = parser.parseFromString('<svg></svg>', 'image/svg+xml');
|
||||
const svgRoot = svgDoc.documentElement;
|
||||
SVG_ROOT_ATTRIBUTES.forEach(attribute => {
|
||||
const value = originalRoot ? getAttribute(originalRoot, attribute) : null;
|
||||
if (value) {
|
||||
svgRoot.setAttribute(attribute, value);
|
||||
}
|
||||
});
|
||||
svgRoot.innerHTML = sanitized;
|
||||
return svgRoot;
|
||||
}
|
||||
|
||||
function sanitizeForeignObjects(
|
||||
root: ParentNode,
|
||||
options?: SanitizeSvgOptions
|
||||
) {
|
||||
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
|
||||
element.innerHTML = DOMPurify.sanitize(
|
||||
element.innerHTML,
|
||||
getForeignObjectHtmlSanitizeConfig(options)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getSiteDomain(hostname: string) {
|
||||
return (
|
||||
parse(hostname, { allowPrivateDomains: true }).domain ??
|
||||
hostname.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function isSameSiteDomain(url: URL) {
|
||||
if (typeof location === 'undefined') return false;
|
||||
return getSiteDomain(url.hostname) === getSiteDomain(location.hostname);
|
||||
}
|
||||
|
||||
function isSafeLinkUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return SAFE_LINK_PROTOCOLS.has(url.protocol) && !isSameSiteDomain(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeHref(element: Element, value: string) {
|
||||
if (value.startsWith('#')) return true;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === 'use') return false;
|
||||
if (tagName === 'image') return SAFE_IMAGE_DATA_URL_PATTERN.test(value);
|
||||
if (tagName === 'a') return isSafeLinkUrl(value);
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeSvgDataUrl(value: string) {
|
||||
const groups = value.match(SVG_DATA_URL_PATTERN)?.groups;
|
||||
if (!groups) return null;
|
||||
|
||||
try {
|
||||
if (groups.base64) {
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(groups.data), char => char.charCodeAt(0))
|
||||
);
|
||||
}
|
||||
return decodeURIComponent(groups.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeSvgDataUrl(svg: string) {
|
||||
const binary = Array.from(new TextEncoder().encode(svg), byte =>
|
||||
String.fromCharCode(byte)
|
||||
).join('');
|
||||
return `data:image/svg+xml;base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
function getHrefAttributes(element: Element) {
|
||||
return Array.from(element.attributes).filter(
|
||||
attribute => attribute.name === 'href' || attribute.name === 'xlink:href'
|
||||
);
|
||||
}
|
||||
|
||||
function tightenSvgTree(
|
||||
root: ParentNode,
|
||||
options: SanitizeSvgOptions | undefined,
|
||||
depth: number
|
||||
) {
|
||||
root.querySelectorAll('*').forEach(element => {
|
||||
getHrefAttributes(element).forEach(attribute => {
|
||||
const href = attribute.value.trim();
|
||||
const nestedSvg =
|
||||
element.tagName.toLowerCase() === 'image'
|
||||
? decodeSvgDataUrl(href)
|
||||
: null;
|
||||
|
||||
if (nestedSvg !== null) {
|
||||
if (depth < MAX_NESTED_SVG_IMAGE_DEPTH) {
|
||||
const sanitized = sanitizeSvgWithDepth(nestedSvg, options, depth + 1);
|
||||
if (sanitized) {
|
||||
element.setAttribute(attribute.name, encodeSvgDataUrl(sanitized));
|
||||
return;
|
||||
}
|
||||
}
|
||||
element.remove();
|
||||
} else if (!isSafeHref(element, href)) {
|
||||
element.removeAttribute(attribute.name);
|
||||
}
|
||||
});
|
||||
|
||||
const style = element.getAttribute('style');
|
||||
if (style && UNSAFE_CSS_PATTERN.test(style)) {
|
||||
element.removeAttribute('style');
|
||||
}
|
||||
|
||||
if (
|
||||
element.tagName.toLowerCase() === 'style' &&
|
||||
UNSAFE_CSS_PATTERN.test(element.textContent ?? '')
|
||||
) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeSvg(svg: string, options?: SanitizeSvgOptions): string {
|
||||
return sanitizeSvgWithDepth(svg, options, 0);
|
||||
}
|
||||
|
||||
function sanitizeSvgWithDepth(
|
||||
svg: string,
|
||||
options: SanitizeSvgOptions | undefined,
|
||||
depth: number
|
||||
): string {
|
||||
const svgConfig = getSvgSanitizeConfig(options);
|
||||
|
||||
if (
|
||||
typeof DOMParser === 'undefined' ||
|
||||
typeof XMLSerializer === 'undefined'
|
||||
) {
|
||||
const sanitized = DOMPurify.sanitize(svg, svgConfig);
|
||||
|
||||
if (typeof sanitized !== 'string' || !hasSvgRoot(sanitized)) {
|
||||
return '';
|
||||
}
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const originalRoot = getOriginalSvgRoot(svg, parser);
|
||||
if (!originalRoot) return '';
|
||||
|
||||
const sanitized = DOMPurify.sanitize(svg, svgConfig);
|
||||
if (typeof sanitized !== 'string') return '';
|
||||
const sanitizedRoot = ensureSvgRoot(originalRoot, sanitized, parser);
|
||||
if (!sanitizedRoot) return '';
|
||||
sanitizeForeignObjects(sanitizedRoot, options);
|
||||
tightenSvgTree(sanitizedRoot, options, depth);
|
||||
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import {
|
||||
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
||||
AffineEdgelessZoomToolbarWidget,
|
||||
} from '.';
|
||||
import { MobileZoomRuler } from './mobile-zoom-ruler';
|
||||
import { ZoomBarToggleButton } from './zoom-bar-toggle-button';
|
||||
import { EdgelessZoomToolbar } from './zoom-toolbar';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
|
||||
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
|
||||
customElements.define('mobile-zoom-ruler', MobileZoomRuler);
|
||||
customElements.define(
|
||||
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
||||
AffineEdgelessZoomToolbarWidget
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
@@ -14,15 +15,20 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
bottom: var(--affine-edgeless-zoom-toolbar-bottom, 20px);
|
||||
left: 12px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
mobile-zoom-ruler {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@container viewport (width <= 1200px) {
|
||||
edgeless-zoom-toolbar {
|
||||
display: none;
|
||||
@@ -73,10 +79,14 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this._hide || !this.edgeless) {
|
||||
if (this._hide) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (IS_MOBILE) {
|
||||
return html`<mobile-zoom-ruler .std=${this.std}></mobile-zoom-ruler>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
|
||||
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ViewBarIcon } from '@blocksuite/icons/lit';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
/**
|
||||
* Compact zoom indicator for narrow / mobile edgeless viewports.
|
||||
* Shows the live zoom percentage and a fit-to-screen action in a pill HUD
|
||||
* anchored to the bottom-left of the canvas.
|
||||
*/
|
||||
export class MobileZoomRuler extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
pointer-events: auto;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
}
|
||||
|
||||
.zoom-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zoom-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--affine-text-secondary-color);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--affine-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--affine-icon-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fit-button:hover:not(:disabled) {
|
||||
background: var(--affine-hover-color);
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.fit-button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
|
||||
.fit-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get viewport() {
|
||||
return this.gfx.viewport;
|
||||
}
|
||||
|
||||
get zoom() {
|
||||
if (!this.viewport) {
|
||||
return 1;
|
||||
}
|
||||
return this.viewport.zoom;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { disposables } = this;
|
||||
const viewport = this.viewport;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
disposables.add(
|
||||
viewport.viewportUpdated.subscribe(() => this.requestUpdate())
|
||||
);
|
||||
disposables.add(viewport.zoomUpdated.subscribe(() => this.requestUpdate()));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
|
||||
const locked = this.viewport?.locked || this.std.store.readonly;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="zoom-pill"
|
||||
@dblclick=${stopPropagation}
|
||||
@mousedown=${stopPropagation}
|
||||
@mouseup=${stopPropagation}
|
||||
@pointerdown=${stopPropagation}
|
||||
>
|
||||
<span class="zoom-label">${formattedZoom}</span>
|
||||
<span class="divider"></span>
|
||||
<button
|
||||
class="fit-button"
|
||||
aria-label="Fit to screen"
|
||||
?disabled=${locked}
|
||||
@click=${() => this.gfx.fitToScreen()}
|
||||
>
|
||||
${ViewBarIcon()}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
- [gfxGroupCompatibleSymbol](variables/gfxGroupCompatibleSymbol.md)
|
||||
- [SURFACE\_TEXT\_UNIQ\_IDENTIFIER](variables/SURFACE_TEXT_UNIQ_IDENTIFIER.md)
|
||||
- [SURFACE\_YMAP\_UNIQ\_IDENTIFIER](variables/SURFACE_YMAP_UNIQ_IDENTIFIER.md)
|
||||
- [viewportRuntimeConfig](variables/viewportRuntimeConfig.md)
|
||||
|
||||
## Functions
|
||||
|
||||
@@ -39,6 +40,7 @@
|
||||
- [generateKeyBetween](functions/generateKeyBetween.md)
|
||||
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
||||
- [generateNKeysBetween](functions/generateNKeysBetween.md)
|
||||
- [getEffectiveDpr](functions/getEffectiveDpr.md)
|
||||
- [getTopElements](functions/getTopElements.md)
|
||||
- [GfxCompatible](functions/GfxCompatible.md)
|
||||
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[**BlockSuite API Documentation**](../../../../README.md)
|
||||
|
||||
***
|
||||
|
||||
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / getEffectiveDpr
|
||||
|
||||
# Function: getEffectiveDpr()
|
||||
|
||||
> **getEffectiveDpr**(`zoom`, `rawDpr`): `number`
|
||||
|
||||
Resolves the effective device-pixel-ratio for canvas backing stores at the
|
||||
given zoom, honoring [viewportRuntimeConfig.CANVAS\_DPR\_CAP\_BY\_ZOOM](../variables/viewportRuntimeConfig.md#canvas_dpr_cap_by_zoom).
|
||||
|
||||
Returns the raw `window.devicePixelRatio` when no cap applies.
|
||||
|
||||
## Parameters
|
||||
|
||||
### zoom
|
||||
|
||||
`number`
|
||||
|
||||
### rawDpr
|
||||
|
||||
`number` = `window.devicePixelRatio`
|
||||
|
||||
## Returns
|
||||
|
||||
`number`
|
||||
@@ -0,0 +1,117 @@
|
||||
[**BlockSuite API Documentation**](../../../../README.md)
|
||||
|
||||
***
|
||||
|
||||
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / viewportRuntimeConfig
|
||||
|
||||
# Variable: viewportRuntimeConfig
|
||||
|
||||
> `const` **viewportRuntimeConfig**: `object`
|
||||
|
||||
Process-wide defaults applied to every Viewport at construction.
|
||||
|
||||
Platforms that need different behavior (e.g. mobile/iOS, which must clamp the
|
||||
zoom floor and defer DOM mutations during gestures to avoid WKWebView process
|
||||
termination) override these once at startup, before any editor mounts. This
|
||||
guarantees both the editor and the readonly preview viewports are born with
|
||||
the same limits — avoiding the race and wrong-instance problems of patching a
|
||||
single Viewport asynchronously after it has already mounted.
|
||||
|
||||
Desktop leaves these untouched, so its behavior is unchanged.
|
||||
|
||||
## Type Declaration
|
||||
|
||||
### CANVAS\_DPR\_CAP\_BY\_ZOOM
|
||||
|
||||
> **CANVAS\_DPR\_CAP\_BY\_ZOOM**: \[`number`, `number`\][]
|
||||
|
||||
Caps the canvas backing-store device-pixel-ratio at low zoom.
|
||||
|
||||
Each entry is `[zoomThreshold, dprCap]`, sorted ascending by threshold.
|
||||
When the live zoom is below a threshold, the corresponding cap bounds the
|
||||
effective dpr used to size canvases. Far-out zoom makes content tiny on
|
||||
screen, so a full retina backing store is wasted memory — on iOS that waste
|
||||
is what pushes WKWebView past its compositing budget and crashes the web
|
||||
content process during pan/zoom.
|
||||
|
||||
Empty (the desktop default) means no cap: canvases always use the raw
|
||||
`window.devicePixelRatio`, so desktop behavior is unchanged.
|
||||
|
||||
### LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT
|
||||
|
||||
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT**: `number` = `0`
|
||||
|
||||
During low-zoom gesture survival mode, keep only a tiny subset of DOM blocks
|
||||
as real active DOM (selected + a few nearby blocks). `0` keeps the legacy
|
||||
behavior where every viewport block remains visually mounted as `survival`.
|
||||
|
||||
### LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO
|
||||
|
||||
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO**: `number` = `0.35`
|
||||
|
||||
Distance threshold (as a fraction of the viewport's shorter side) used to
|
||||
decide whether an unselected viewport block counts as "nearby" to the
|
||||
current selection during low-zoom gesture survival mode.
|
||||
|
||||
### OVERSCAN\_RATIO
|
||||
|
||||
> **OVERSCAN\_RATIO**: `number` = `0`
|
||||
|
||||
Fraction by which the *render/activation* viewport bound is enlarged on
|
||||
every side (see Viewport.overscanViewportBounds). Pre-painting a
|
||||
margin around the visible area means moderate pan/zoom gestures move into
|
||||
content that is already mounted and rasterized, so it does not blank out
|
||||
and wait for the post-gesture refresh.
|
||||
|
||||
Memory grows by roughly `(1 + 2 * ratio) ** 2`, so this must stay modest
|
||||
and be paired with a zoom floor + dpr cap on mobile. `0` (desktop default)
|
||||
makes Viewport.overscanViewportBounds identical to
|
||||
Viewport.viewportBounds, leaving desktop behavior unchanged.
|
||||
|
||||
This governs the *canvas* render bound only (see
|
||||
Viewport.overscanViewportBounds). It enlarges the canvas backing
|
||||
stores, so memory grows with the overscan area. Keep it modest and pair it
|
||||
with the mobile zoom floor + dpr cap so connectors/elements stay painted
|
||||
through a gesture without pushing WKWebView over budget.
|
||||
|
||||
### OVERSCAN\_RATIO\_BLOCK
|
||||
|
||||
> **OVERSCAN\_RATIO\_BLOCK**: `number` = `0`
|
||||
|
||||
Like [OVERSCAN\_RATIO](#overscan_ratio) but for the *DOM block mounting* bound (see
|
||||
Viewport.overscanBlockBounds). This one is expensive: every
|
||||
mounted block becomes its own composited layer subtree in the WebContent
|
||||
process, so enlarging it multiplies resident memory and is what pushes the
|
||||
process toward an iOS jetsam kill. Keep this small (or `0`) even when
|
||||
[OVERSCAN\_RATIO](#overscan_ratio) is generous. `0` (desktop default) leaves block
|
||||
mounting on the exact visible bound, unchanged from upstream.
|
||||
|
||||
### POST\_GESTURE\_REFRESH\_DELAY
|
||||
|
||||
> **POST\_GESTURE\_REFRESH\_DELAY**: `number` = `800`
|
||||
|
||||
Delay (ms) before the post-gesture refresh repaints canvases and reactivates
|
||||
blocks, used only when [SKIP\_REFRESH\_DURING\_GESTURE](#skip_refresh_during_gesture) is true. The same
|
||||
value drives both the canvas and block refresh timers so they fire together
|
||||
(avoiding the "blocks appear, then connectors" staggered reveal). Desktop
|
||||
never enters that code path, so this is mobile-only.
|
||||
|
||||
### SKIP\_REFRESH\_DURING\_GESTURE
|
||||
|
||||
> **SKIP\_REFRESH\_DURING\_GESTURE**: `boolean` = `false`
|
||||
|
||||
### VIEWPORT\_REFRESH\_MAX\_INTERVAL
|
||||
|
||||
> **VIEWPORT\_REFRESH\_MAX\_INTERVAL**: `number` = `120`
|
||||
|
||||
### VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD
|
||||
|
||||
> **VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD**: `number` = `18`
|
||||
|
||||
### ZOOM\_MAX
|
||||
|
||||
> **ZOOM\_MAX**: `number`
|
||||
|
||||
### ZOOM\_MIN
|
||||
|
||||
> **ZOOM\_MIN**: `number`
|
||||
@@ -19,7 +19,7 @@
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lib0": "^0.2.114",
|
||||
"lit": "^3.2.0",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { SerializedXYWH } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
createAutoIncrementIdGenerator,
|
||||
TestWorkspace,
|
||||
} from '@blocksuite/store/test';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { effects } from '../../effects.js';
|
||||
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
|
||||
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
|
||||
import { getPostGestureRecoveryDelay } from '../../gfx/viewport.js';
|
||||
import {
|
||||
GfxViewportElement,
|
||||
shouldUseLowZoomBlockSurvivalMode,
|
||||
} from '../../gfx/viewport-element.js';
|
||||
import type { GfxBlockComponent } from '../../view/element/gfx-block-component.js';
|
||||
import { TestEditorContainer } from '../test-editor.js';
|
||||
import { TestLocalElement } from '../test-gfx-element.js';
|
||||
import {
|
||||
@@ -52,6 +60,7 @@ const commonSetup = async () => {
|
||||
const gfx = editorContainer.std.get(GfxControllerIdentifier);
|
||||
|
||||
return {
|
||||
editorContainer,
|
||||
gfx,
|
||||
surfaceId,
|
||||
rootId,
|
||||
@@ -59,6 +68,74 @@ const commonSetup = async () => {
|
||||
};
|
||||
};
|
||||
|
||||
const waitGfxViewConnected = (gfx: {
|
||||
std: {
|
||||
view: {
|
||||
viewUpdated: {
|
||||
subscribe: (
|
||||
callback: (payload: {
|
||||
id: string;
|
||||
type: string;
|
||||
method: string;
|
||||
}) => void
|
||||
) => { unsubscribe: () => void };
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
return (id: string) => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.id === id &&
|
||||
payload.type === 'block' &&
|
||||
payload.method === 'add'
|
||||
) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
};
|
||||
|
||||
const getTestGfxBlockModel = (
|
||||
gfx: { getElementById: (id: string) => unknown },
|
||||
id: string
|
||||
) => {
|
||||
const model = gfx.getElementById(id) as GfxBlockElementModel | null;
|
||||
if (!model) {
|
||||
throw new Error(`Missing gfx model for block ${id}`);
|
||||
}
|
||||
return model;
|
||||
};
|
||||
|
||||
const getTestGfxBlockView = (
|
||||
gfx: { view: { get: (id: string) => unknown } },
|
||||
id: string
|
||||
) => {
|
||||
const view = gfx.view.get(id) as GfxBlockComponent | null;
|
||||
if (!view) {
|
||||
throw new Error(`Missing gfx view for block ${id}`);
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const getViewportChildBlockIds = (viewportElement: GfxViewportElement) =>
|
||||
[...viewportElement.children].map(
|
||||
child => (child as HTMLElement).dataset.blockId
|
||||
);
|
||||
|
||||
const setBlockXYWH = (
|
||||
gfx: { getElementById: (id: string) => unknown },
|
||||
id: string,
|
||||
xywh: SerializedXYWH
|
||||
) => {
|
||||
const model = getTestGfxBlockModel(gfx, id);
|
||||
model.xywh = xywh;
|
||||
};
|
||||
|
||||
describe('gfx element view basic', () => {
|
||||
test('view should be created', async () => {
|
||||
const { gfx, surfaceModel } = await commonSetup();
|
||||
@@ -91,24 +168,10 @@ describe('gfx element view basic', () => {
|
||||
|
||||
test('query gfx block view should work', async () => {
|
||||
const { gfx, surfaceId, rootId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const waitGfxViewConnected = (id: string) => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
|
||||
if (
|
||||
payload.id === id &&
|
||||
payload.type === 'block' &&
|
||||
payload.method === 'add'
|
||||
) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId);
|
||||
await waitGfxViewConnected(id);
|
||||
await waitViewConnected(id);
|
||||
const gfxBlockView = gfx.view.get(id);
|
||||
expect(gfxBlockView).not.toBeNull();
|
||||
|
||||
@@ -117,6 +180,824 @@ describe('gfx element view basic', () => {
|
||||
expect(rootView).toBeNull();
|
||||
});
|
||||
|
||||
test('detects low-zoom DOM survival mode only during active gestures for gesture-safe viewport configs', () => {
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: false,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.6,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: true,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: false,
|
||||
gestureActive: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps selected block active while degrading unselected low-zoom viewport blocks', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const inViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(selectedId),
|
||||
waitViewConnected(inViewportId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(inViewportModel).not.toBeNull();
|
||||
expect(outOfViewportModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
expect(inViewportView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([selectedModel, inViewportModel]);
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_lastVisibleModels: Set<unknown>;
|
||||
}
|
||||
)._lastVisibleModels = new Set([
|
||||
selectedModel,
|
||||
inViewportModel,
|
||||
outOfViewportModel,
|
||||
]);
|
||||
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_hideOutsideAndNoSelectedBlock: () => void;
|
||||
}
|
||||
)._hideOutsideAndNoSelectedBlock();
|
||||
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('survival');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
});
|
||||
|
||||
test('parks non-active low-zoom gesture blocks outside viewport DOM while gesture is running', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const nearbyId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const farVisibleId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(selectedId),
|
||||
waitViewConnected(nearbyId),
|
||||
waitViewConnected(farVisibleId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, nearbyId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, farVisibleId, '[120,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const nearbyModel = getTestGfxBlockModel(gfx, nearbyId);
|
||||
const farVisibleModel = getTestGfxBlockModel(gfx, farVisibleId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
const nearbyView = getTestGfxBlockView(gfx, nearbyId);
|
||||
const farVisibleView = getTestGfxBlockView(gfx, farVisibleId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(nearbyModel).not.toBeNull();
|
||||
expect(farVisibleModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
expect(nearbyView).not.toBeNull();
|
||||
expect(farVisibleView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||
|
||||
const shell = document.createElement('div');
|
||||
Object.defineProperty(shell, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get: () => 844,
|
||||
});
|
||||
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._shell = shell;
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedOffsetWidth = 844;
|
||||
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
gfx.viewport.panning$.next(true);
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([selectedModel, nearbyModel, farVisibleModel]);
|
||||
document.body.append(viewportElement);
|
||||
viewportElement.append(
|
||||
selectedView,
|
||||
nearbyView,
|
||||
farVisibleView,
|
||||
outOfViewportView
|
||||
);
|
||||
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_hideOutsideAndNoSelectedBlock: () => void;
|
||||
}
|
||||
)._hideOutsideAndNoSelectedBlock();
|
||||
|
||||
expect(getViewportChildBlockIds(viewportElement)).toEqual([
|
||||
selectedId,
|
||||
nearbyId,
|
||||
]);
|
||||
expect(farVisibleView.isConnected).toBe(false);
|
||||
expect(outOfViewportView.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
test('restores parked low-zoom blocks after gesture recovery completes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const firstId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const secondId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const thirdId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(firstId),
|
||||
waitViewConnected(secondId),
|
||||
waitViewConnected(thirdId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, firstId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, secondId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, thirdId, '[40,0,10,10]');
|
||||
|
||||
const firstModel = getTestGfxBlockModel(gfx, firstId);
|
||||
const secondModel = getTestGfxBlockModel(gfx, secondId);
|
||||
const thirdModel = getTestGfxBlockModel(gfx, thirdId);
|
||||
const firstView = getTestGfxBlockView(gfx, firstId);
|
||||
const secondView = getTestGfxBlockView(gfx, secondId);
|
||||
const thirdView = getTestGfxBlockView(gfx, thirdId);
|
||||
|
||||
expect(firstModel).not.toBeNull();
|
||||
expect(secondModel).not.toBeNull();
|
||||
expect(thirdModel).not.toBeNull();
|
||||
expect(firstView).not.toBeNull();
|
||||
expect(secondView).not.toBeNull();
|
||||
expect(thirdView).not.toBeNull();
|
||||
|
||||
gfx.selection.clear();
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||
|
||||
const shell = document.createElement('div');
|
||||
Object.defineProperty(shell, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get: () => 844,
|
||||
});
|
||||
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._shell = shell;
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedOffsetWidth = 844;
|
||||
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
gfx.viewport.panning$.next(true);
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([firstModel, secondModel, thirdModel]);
|
||||
document.body.append(viewportElement);
|
||||
viewportElement.append(firstView, secondView, thirdView);
|
||||
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_hideOutsideAndNoSelectedBlock: () => void;
|
||||
}
|
||||
)._hideOutsideAndNoSelectedBlock();
|
||||
|
||||
expect(viewportElement.children).toHaveLength(1);
|
||||
|
||||
gfx.viewport.panning$.next(false);
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
|
||||
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
|
||||
new Set([firstId, secondId, thirdId])
|
||||
);
|
||||
expect(firstView.transformState$.value).toBe('active');
|
||||
expect(secondView.transformState$.value).toBe('active');
|
||||
expect(thirdView.transformState$.value).toBe('active');
|
||||
|
||||
gfx.viewport.panning$.next(true);
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_hideOutsideAndNoSelectedBlock: () => void;
|
||||
}
|
||||
)._hideOutsideAndNoSelectedBlock();
|
||||
expect(viewportElement.children).toHaveLength(1);
|
||||
|
||||
gfx.viewport.panning$.next(false);
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
|
||||
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
|
||||
new Set([firstId, secondId, thirdId])
|
||||
);
|
||||
expect(firstView.transformState$.value).toBe('active');
|
||||
expect(secondView.transformState$.value).toBe('active');
|
||||
expect(thirdView.transformState$.value).toBe('active');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('programmatic low-zoom viewport changes do not arm gesture signals', async () => {
|
||||
const { Viewport } = await import('../../gfx/index.js');
|
||||
|
||||
const viewport = new Viewport();
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||
|
||||
viewport.setViewport(0.4, [20, 0]);
|
||||
|
||||
expect(viewport.panning$.value).toBe(false);
|
||||
expect(viewport.zooming$.value).toBe(false);
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: viewport.zoom,
|
||||
skipRefreshDuringGesture: viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive: viewport.panning$.value || viewport.zooming$.value,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('programmatic low-zoom viewport changes still emit viewport updates', async () => {
|
||||
const { Viewport } = await import('../../gfx/index.js');
|
||||
|
||||
const viewport = new Viewport();
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const updates: Array<{ zoom: number; center: [number, number] }> = [];
|
||||
const subscription = viewport.viewportUpdated.subscribe(
|
||||
({ zoom, center }) => {
|
||||
updates.push({ zoom, center: [center[0], center[1]] });
|
||||
}
|
||||
);
|
||||
|
||||
viewport.setViewport(0.4, [20, 10]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
zoom: 0.4,
|
||||
center: [20, 10],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('idles out-of-viewport blocks on the first visibility refresh', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const inViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(selectedId),
|
||||
waitViewConnected(inViewportId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(inViewportModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
expect(inViewportView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([selectedModel, inViewportModel]);
|
||||
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_hideOutsideAndNoSelectedBlock: () => void;
|
||||
}
|
||||
)._hideOutsideAndNoSelectedBlock();
|
||||
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('active');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
});
|
||||
|
||||
test('demotes visible unselected blocks immediately when zoom crosses into survival mode', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const inViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(selectedId),
|
||||
waitViewConnected(inViewportId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(inViewportModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
expect(inViewportView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([selectedModel, inViewportModel]);
|
||||
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_hideOutsideAndNoSelectedBlock: () => void;
|
||||
}
|
||||
)._hideOutsideAndNoSelectedBlock();
|
||||
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('active');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
|
||||
document.body.append(viewportElement);
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('survival');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
});
|
||||
|
||||
test('chunked low-zoom refresh idles out-of-viewport blocks on the first pass', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const inViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(selectedId),
|
||||
waitViewConnected(inViewportId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(inViewportModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
expect(inViewportView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([selectedModel, inViewportModel]);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
(
|
||||
viewportElement as unknown as {
|
||||
_chunkedHideOutsideAndNoSelectedBlock: (
|
||||
onComplete?: () => void
|
||||
) => () => void;
|
||||
}
|
||||
)._chunkedHideOutsideAndNoSelectedBlock(resolve);
|
||||
});
|
||||
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('survival');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
});
|
||||
|
||||
test('newly mounted blocks inherit the current low-zoom visibility state', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
await waitViewConnected(selectedId);
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
|
||||
const viewportModels = new Set([selectedModel]);
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () => viewportModels;
|
||||
document.body.append(viewportElement);
|
||||
|
||||
const inViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
|
||||
|
||||
expect(inViewportModel).not.toBeNull();
|
||||
expect(outOfViewportModel).not.toBeNull();
|
||||
|
||||
viewportModels.add(inViewportModel);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(inViewportId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(inViewportView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('survival');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
});
|
||||
|
||||
test('demotes stale active blocks immediately when low-zoom resize starts', async () => {
|
||||
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||
|
||||
const selectedId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const inViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
const outOfViewportId = gfx.std.store.addBlock(
|
||||
'test:gfx-block',
|
||||
undefined,
|
||||
surfaceId
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
waitViewConnected(selectedId),
|
||||
waitViewConnected(inViewportId),
|
||||
waitViewConnected(outOfViewportId),
|
||||
]);
|
||||
|
||||
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||
|
||||
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||
|
||||
expect(selectedModel).not.toBeNull();
|
||||
expect(inViewportModel).not.toBeNull();
|
||||
expect(selectedView).not.toBeNull();
|
||||
expect(inViewportView).not.toBeNull();
|
||||
expect(outOfViewportView).not.toBeNull();
|
||||
|
||||
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
|
||||
const viewportElement = new GfxViewportElement();
|
||||
viewportElement.host = editorContainer.std.host;
|
||||
viewportElement.viewport = gfx.viewport;
|
||||
viewportElement.getModelsInViewport = () =>
|
||||
new Set([selectedModel, inViewportModel]);
|
||||
document.body.append(viewportElement);
|
||||
|
||||
const shell = document.createElement('div');
|
||||
Object.defineProperty(shell, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get: () => 844,
|
||||
});
|
||||
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._shell = shell;
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedOffsetWidth = 844;
|
||||
|
||||
selectedView.transformState$.value = 'active';
|
||||
inViewportView.transformState$.value = 'active';
|
||||
outOfViewportView.transformState$.value = 'active';
|
||||
|
||||
gfx.viewport.onResize();
|
||||
|
||||
expect(selectedView.transformState$.value).toBe('active');
|
||||
expect(inViewportView.transformState$.value).toBe('survival');
|
||||
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||
});
|
||||
|
||||
test('resize completion clears low-zoom gesture recovery before sizeUpdated subscribers run', async () => {
|
||||
const { gfx } = await commonSetup();
|
||||
|
||||
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const shell = document.createElement('div');
|
||||
Object.defineProperty(shell, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get: () => 844,
|
||||
});
|
||||
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._shell = shell;
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||
(
|
||||
gfx.viewport as unknown as {
|
||||
_shell: HTMLElement;
|
||||
_cachedBoundingClientRect: DOMRect;
|
||||
_cachedOffsetWidth: number;
|
||||
}
|
||||
)._cachedOffsetWidth = 844;
|
||||
|
||||
let panningAtSizeUpdated: boolean | null = null;
|
||||
let zoomingAtSizeUpdated: boolean | null = null;
|
||||
let blockSurvivalAtSizeUpdated: boolean | null = null;
|
||||
let canvasRecoveryDelayAtSizeUpdated: number | null = null;
|
||||
|
||||
const subscription = gfx.viewport.sizeUpdated.subscribe(() => {
|
||||
const gestureActive =
|
||||
gfx.viewport.panning$.value || gfx.viewport.zooming$.value;
|
||||
|
||||
panningAtSizeUpdated = gfx.viewport.panning$.value;
|
||||
zoomingAtSizeUpdated = gfx.viewport.zooming$.value;
|
||||
blockSurvivalAtSizeUpdated = shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: gfx.viewport.zoom,
|
||||
skipRefreshDuringGesture: gfx.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive,
|
||||
});
|
||||
canvasRecoveryDelayAtSizeUpdated = getPostGestureRecoveryDelay({
|
||||
isPanning: gfx.viewport.panning$.value,
|
||||
isZooming: gfx.viewport.zooming$.value,
|
||||
fallbackDelayMs: 800,
|
||||
});
|
||||
});
|
||||
|
||||
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||
gfx.viewport.onResize();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(panningAtSizeUpdated).toBe(false);
|
||||
expect(zoomingAtSizeUpdated).toBe(false);
|
||||
expect(blockSurvivalAtSizeUpdated).toBe(false);
|
||||
expect(canvasRecoveryDelayAtSizeUpdated).toBe(0);
|
||||
});
|
||||
|
||||
test('local element view should be created', async () => {
|
||||
const { gfx, surfaceModel } = await commonSetup();
|
||||
const localElement = new TestLocalElement(surfaceModel);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { batch } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
import { PropTypes, requiredProperties } from '../view/decorators/required';
|
||||
import { GfxControllerIdentifier } from './identifiers';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model';
|
||||
import { Viewport } from './viewport';
|
||||
import {
|
||||
getPostGestureRecoveryDelay,
|
||||
Viewport,
|
||||
viewportRuntimeConfig,
|
||||
} from './viewport';
|
||||
|
||||
/**
|
||||
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
|
||||
@@ -37,6 +42,123 @@ export function requestThrottledConnectedFrame<
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function getGestureTransformMinInterval({
|
||||
isPureTranslate,
|
||||
zoom,
|
||||
}: {
|
||||
isPureTranslate: boolean;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (!isPureTranslate) {
|
||||
return 32;
|
||||
}
|
||||
|
||||
return zoom <= 0.5 ? 32 : 0;
|
||||
}
|
||||
|
||||
export function shouldSkipGestureTransformWrite({
|
||||
isPureTranslate,
|
||||
zoom,
|
||||
elapsedMs,
|
||||
}: {
|
||||
isPureTranslate: boolean;
|
||||
zoom: number;
|
||||
elapsedMs: number;
|
||||
}) {
|
||||
const minInterval = getGestureTransformMinInterval({
|
||||
isPureTranslate,
|
||||
zoom,
|
||||
});
|
||||
|
||||
return minInterval > 0 && elapsedMs < minInterval;
|
||||
}
|
||||
|
||||
const LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD = 0.5;
|
||||
|
||||
export function shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom,
|
||||
skipRefreshDuringGesture,
|
||||
gestureActive,
|
||||
}: {
|
||||
zoom: number;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
gestureActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
skipRefreshDuringGesture &&
|
||||
gestureActive &&
|
||||
zoom <= LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
export function getLowZoomGestureActiveModels<
|
||||
T extends { elementBound: Bound; id: string },
|
||||
>({
|
||||
selectedModels,
|
||||
viewportModels,
|
||||
viewportBounds,
|
||||
nearbyActiveBlockLimit,
|
||||
nearbyDistanceRatio,
|
||||
}: {
|
||||
selectedModels: Set<T>;
|
||||
viewportModels: Set<T>;
|
||||
viewportBounds: Bound;
|
||||
nearbyActiveBlockLimit: number;
|
||||
nearbyDistanceRatio: number;
|
||||
}): Set<T> {
|
||||
const activeModels = new Set<T>(selectedModels);
|
||||
if (nearbyActiveBlockLimit <= 0) {
|
||||
return activeModels;
|
||||
}
|
||||
|
||||
const viewportCenter = viewportBounds.center;
|
||||
const maxNearbyDistance =
|
||||
Math.min(viewportBounds.w, viewportBounds.h) * nearbyDistanceRatio;
|
||||
|
||||
if (selectedModels.size === 0) {
|
||||
const fallback = [...viewportModels]
|
||||
.sort((left, right) => {
|
||||
const [leftX, leftY] = left.elementBound.center;
|
||||
const [rightX, rightY] = right.elementBound.center;
|
||||
const leftDistance = Math.hypot(
|
||||
leftX - viewportCenter[0],
|
||||
leftY - viewportCenter[1]
|
||||
);
|
||||
const rightDistance = Math.hypot(
|
||||
rightX - viewportCenter[0],
|
||||
rightY - viewportCenter[1]
|
||||
);
|
||||
return leftDistance - rightDistance;
|
||||
})
|
||||
.slice(0, nearbyActiveBlockLimit);
|
||||
|
||||
fallback.forEach(model => activeModels.add(model));
|
||||
return activeModels;
|
||||
}
|
||||
|
||||
const selectedCenters = [...selectedModels].map(
|
||||
model => model.elementBound.center
|
||||
);
|
||||
|
||||
const nearbyCandidates = [...viewportModels]
|
||||
.filter(model => !selectedModels.has(model))
|
||||
.map(model => {
|
||||
const [x, y] = model.elementBound.center;
|
||||
const distance = Math.min(
|
||||
...selectedCenters.map(([selectedX, selectedY]) =>
|
||||
Math.hypot(x - selectedX, y - selectedY)
|
||||
)
|
||||
);
|
||||
return { distance, model };
|
||||
})
|
||||
.filter(candidate => candidate.distance <= maxNearbyDistance)
|
||||
.sort((left, right) => left.distance - right.distance)
|
||||
.slice(0, nearbyActiveBlockLimit);
|
||||
|
||||
nearbyCandidates.forEach(candidate => activeModels.add(candidate.model));
|
||||
return activeModels;
|
||||
}
|
||||
|
||||
@requiredProperties({
|
||||
viewport: PropTypes.instanceOf(Viewport),
|
||||
})
|
||||
@@ -45,6 +167,20 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
|
||||
|
||||
private get _pixelThreshold() {
|
||||
return (
|
||||
this.viewport?.VIEWPORT_REFRESH_PIXEL_THRESHOLD ??
|
||||
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
private get _maxInterval() {
|
||||
return (
|
||||
this.viewport?.VIEWPORT_REFRESH_MAX_INTERVAL ??
|
||||
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL
|
||||
);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
gfx-viewport {
|
||||
position: absolute;
|
||||
@@ -63,38 +199,163 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
contain: size layout style;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mobile (SKIP_REFRESH_DURING_GESTURE) drives gestures with a single
|
||||
* container-level transform on <gfx-viewport>; the idle blocks never
|
||||
* change their own transform during the gesture. In that mode
|
||||
* 'will-change: transform' is actively harmful: WKWebView promotes every
|
||||
* hidden idle block (100+) to its own compositing layer and re-transforms
|
||||
* all of them each frame, producing a ~100ms main-thread/compositor stall
|
||||
* that terminates the web content process. Releasing the hint lets them
|
||||
* ride along as raster content of the single container layer.
|
||||
* Desktop (no attribute) keeps will-change because it transforms blocks
|
||||
* individually per frame, where the hint is a real win.
|
||||
*/
|
||||
gfx-viewport[data-skip-gesture-refresh] .block-idle {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* CSS for active blocks participating in viewport transformations */
|
||||
.block-active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Survival blocks stay visually mounted but stop participating in input. */
|
||||
.block-survival {
|
||||
visibility: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _parkedBlockViews = new Map<
|
||||
string,
|
||||
{ placeholder: Comment; view: HTMLElement }
|
||||
>();
|
||||
|
||||
private readonly _parkedBlockFragment = document.createDocumentFragment();
|
||||
|
||||
private _shouldParkIdleBlocks() {
|
||||
return (
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: this.viewport.zoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
}) && this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0
|
||||
);
|
||||
}
|
||||
|
||||
private _restoreParkedBlockViews() {
|
||||
this._parkedBlockViews.forEach(({ placeholder, view }) => {
|
||||
if (placeholder.parentNode === this) {
|
||||
placeholder.replaceWith(view);
|
||||
} else if (!view.isConnected) {
|
||||
this.append(view);
|
||||
}
|
||||
placeholder.remove();
|
||||
});
|
||||
this._parkedBlockViews.clear();
|
||||
}
|
||||
|
||||
private _syncMountedBlockViews(
|
||||
shouldRemainMounted: Set<GfxBlockElementModel>
|
||||
) {
|
||||
if (!this.host) return;
|
||||
|
||||
if (!this._shouldParkIdleBlocks()) {
|
||||
this._restoreParkedBlockViews();
|
||||
return;
|
||||
}
|
||||
|
||||
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||
gfx.std.view.views.forEach(view => {
|
||||
if (!isGfxBlockComponent(view)) return;
|
||||
|
||||
const parked = this._parkedBlockViews.get(view.model.id);
|
||||
if (shouldRemainMounted.has(view.model)) {
|
||||
if (parked) {
|
||||
if (parked.placeholder.parentNode === this) {
|
||||
parked.placeholder.replaceWith(view);
|
||||
} else if (!view.isConnected) {
|
||||
this.append(view);
|
||||
}
|
||||
parked.placeholder.remove();
|
||||
this._parkedBlockViews.delete(view.model.id);
|
||||
} else if (!view.isConnected || view.parentElement !== this) {
|
||||
this.append(view);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parked || view.parentElement !== this) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = document.createComment(`parked:${view.model.id}`);
|
||||
this.replaceChild(placeholder, view);
|
||||
this._parkedBlockFragment.append(view);
|
||||
this._parkedBlockViews.set(view.model.id, {
|
||||
placeholder,
|
||||
view,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private readonly _hideOutsideAndNoSelectedBlock = () => {
|
||||
if (!this.host) return;
|
||||
|
||||
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||
const currentViewportModels = this.getModelsInViewport();
|
||||
const currentSelectedModels = this._getSelectedModels();
|
||||
const shouldBeVisible = new Set([
|
||||
...currentViewportModels,
|
||||
...currentSelectedModels,
|
||||
]);
|
||||
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: this.viewport.zoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
});
|
||||
const shouldLimitActiveModels =
|
||||
shouldUseSurvivalMode &&
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
|
||||
const limitedActiveModels = shouldLimitActiveModels
|
||||
? getLowZoomGestureActiveModels({
|
||||
selectedModels: currentSelectedModels,
|
||||
viewportModels: currentViewportModels,
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
nearbyActiveBlockLimit:
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
|
||||
nearbyDistanceRatio:
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
|
||||
})
|
||||
: null;
|
||||
const shouldBeVisible =
|
||||
limitedActiveModels ??
|
||||
new Set([...currentViewportModels, ...currentSelectedModels]);
|
||||
|
||||
const previousVisible = this._lastVisibleModels
|
||||
? new Set(this._lastVisibleModels)
|
||||
: new Set<GfxBlockElementModel>();
|
||||
const candidatesToHide = new Set(previousVisible);
|
||||
|
||||
if (!this._lastVisibleModels) {
|
||||
this.host.std.view.views.forEach(view => {
|
||||
if (!isGfxBlockComponent(view)) return;
|
||||
candidatesToHide.add(view.model);
|
||||
});
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
// Step 1: Activate all the blocks that should be visible
|
||||
shouldBeVisible.forEach(model => {
|
||||
const view = gfx.view.get(model);
|
||||
if (!isGfxBlockComponent(view)) return;
|
||||
view.transformState$.value = 'active';
|
||||
view.transformState$.value = shouldLimitActiveModels
|
||||
? 'active'
|
||||
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
|
||||
? 'survival'
|
||||
: 'active';
|
||||
});
|
||||
|
||||
// Step 2: Hide all the blocks that should not be visible
|
||||
previousVisible.forEach(model => {
|
||||
candidatesToHide.forEach(model => {
|
||||
if (shouldBeVisible.has(model)) return;
|
||||
|
||||
const view = gfx.view.get(model);
|
||||
@@ -103,11 +364,161 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
});
|
||||
});
|
||||
|
||||
this._syncMountedBlockViews(shouldBeVisible);
|
||||
|
||||
this._lastVisibleModels = shouldBeVisible;
|
||||
};
|
||||
|
||||
/**
|
||||
* Chunked version of _hideOutsideAndNoSelectedBlock that processes blocks
|
||||
* in batches across multiple frames to prevent memory spikes on mobile.
|
||||
* Returns a cancel function.
|
||||
*/
|
||||
private _chunkedHideOutsideAndNoSelectedBlock(
|
||||
onComplete?: () => void
|
||||
): () => void {
|
||||
if (!this.host) return () => {};
|
||||
|
||||
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||
const currentViewportModels = this.getModelsInViewport();
|
||||
const currentSelectedModels = this._getSelectedModels();
|
||||
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: this.viewport.zoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
});
|
||||
const shouldLimitActiveModels =
|
||||
shouldUseSurvivalMode &&
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
|
||||
const limitedActiveModels = shouldLimitActiveModels
|
||||
? getLowZoomGestureActiveModels({
|
||||
selectedModels: currentSelectedModels,
|
||||
viewportModels: currentViewportModels,
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
nearbyActiveBlockLimit:
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
|
||||
nearbyDistanceRatio:
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
|
||||
})
|
||||
: null;
|
||||
const shouldBeVisible =
|
||||
limitedActiveModels ??
|
||||
new Set([...currentViewportModels, ...currentSelectedModels]);
|
||||
|
||||
const previousVisible = this._lastVisibleModels
|
||||
? new Set(this._lastVisibleModels)
|
||||
: new Set<GfxBlockElementModel>();
|
||||
const candidatesToHide = new Set(previousVisible);
|
||||
|
||||
if (!this._lastVisibleModels) {
|
||||
this.host.std.view.views.forEach(view => {
|
||||
if (!isGfxBlockComponent(view)) return;
|
||||
candidatesToHide.add(view.model);
|
||||
});
|
||||
}
|
||||
|
||||
// Compute which blocks need activation and which need hiding
|
||||
const toActivate: GfxBlockElementModel[] = [];
|
||||
shouldBeVisible.forEach(model => {
|
||||
if (!previousVisible.has(model)) {
|
||||
toActivate.push(model);
|
||||
} else {
|
||||
// Already visible, just ensure state is correct
|
||||
const view = gfx.view.get(model);
|
||||
if (!isGfxBlockComponent(view)) {
|
||||
return;
|
||||
}
|
||||
const targetState = shouldLimitActiveModels
|
||||
? 'active'
|
||||
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
|
||||
? 'survival'
|
||||
: 'active';
|
||||
if (view.transformState$.value !== targetState) {
|
||||
toActivate.push(model);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const toHide: GfxBlockElementModel[] = [];
|
||||
candidatesToHide.forEach(model => {
|
||||
if (!shouldBeVisible.has(model)) {
|
||||
toHide.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
this._lastVisibleModels = shouldBeVisible;
|
||||
|
||||
// Hide blocks immediately (cheap: just sets visibility:hidden)
|
||||
if (toHide.length > 0) {
|
||||
batch(() => {
|
||||
toHide.forEach(model => {
|
||||
const view = gfx.view.get(model);
|
||||
if (!isGfxBlockComponent(view)) return;
|
||||
view.transformState$.value = 'idle';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this._syncMountedBlockViews(shouldBeVisible);
|
||||
|
||||
// Activate blocks in chunks to prevent memory spikes
|
||||
const CHUNK_SIZE = 8;
|
||||
let chunkIndex = 0;
|
||||
let cancelled = false;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const processNextChunk = () => {
|
||||
if (cancelled) return;
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, toActivate.length);
|
||||
|
||||
if (start >= toActivate.length) {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (let i = start; i < end; i++) {
|
||||
const model = toActivate[i];
|
||||
const view = gfx.view.get(model);
|
||||
if (!isGfxBlockComponent(view)) continue;
|
||||
view.transformState$.value = shouldLimitActiveModels
|
||||
? 'active'
|
||||
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
|
||||
? 'survival'
|
||||
: 'active';
|
||||
}
|
||||
});
|
||||
|
||||
chunkIndex++;
|
||||
if (chunkIndex * CHUNK_SIZE < toActivate.length) {
|
||||
rafId = requestAnimationFrame(processNextChunk);
|
||||
} else {
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Start first chunk immediately (synchronous for responsiveness)
|
||||
if (toActivate.length > 0) {
|
||||
processNextChunk();
|
||||
} else {
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
||||
|
||||
private _pendingChunkedHideCancel: (() => void) | null = null;
|
||||
|
||||
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
|
||||
|
||||
private _lastViewportRefreshTime = 0;
|
||||
@@ -134,19 +545,49 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private _cancelPendingChunkedHide() {
|
||||
if (this._pendingChunkedHideCancel) {
|
||||
this._pendingChunkedHideCancel();
|
||||
this._pendingChunkedHideCancel = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleChunkedHide(onComplete?: () => void) {
|
||||
this._cancelPendingChunkedHide();
|
||||
this._pendingChunkedHideCancel = this._chunkedHideOutsideAndNoSelectedBlock(
|
||||
() => {
|
||||
this._pendingChunkedHideCancel = null;
|
||||
onComplete?.();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _scheduleTrailingViewportRefresh() {
|
||||
this._clearPendingViewportRefreshTimer();
|
||||
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
|
||||
this._pendingViewportRefreshTimer = null;
|
||||
this._lastViewportRefreshTime = performance.now();
|
||||
this._refreshViewport();
|
||||
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
|
||||
}, this._maxInterval);
|
||||
}
|
||||
|
||||
private _refreshViewportByViewportUpdate(update: {
|
||||
zoom: number;
|
||||
center: [number, number];
|
||||
}) {
|
||||
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer all DOM mutations
|
||||
// until panning/zooming ends to prevent main thread blocking
|
||||
if (
|
||||
this.viewport?.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
this._lastViewportUpdate = {
|
||||
zoom: update.zoom,
|
||||
center: [update.center[0], update.center[1]],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const previous = this._lastViewportUpdate;
|
||||
this._lastViewportUpdate = {
|
||||
@@ -166,13 +607,11 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
(update.center[1] - previous.center[1]) * update.zoom
|
||||
);
|
||||
const timeoutReached =
|
||||
now - this._lastViewportRefreshTime >=
|
||||
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
|
||||
now - this._lastViewportRefreshTime >= this._maxInterval;
|
||||
|
||||
if (
|
||||
zoomChanged ||
|
||||
centerMovedInPixel >=
|
||||
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
|
||||
centerMovedInPixel >= this._pixelThreshold ||
|
||||
timeoutReached
|
||||
) {
|
||||
this._clearPendingViewportRefreshTimer();
|
||||
@@ -197,17 +636,303 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||
this._refreshViewportByViewportUpdate(update)
|
||||
)
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.zoomUpdated.subscribe(({ previousZoom, zoom }) => {
|
||||
const previousMode = shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: previousZoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
});
|
||||
const nextMode = shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
});
|
||||
|
||||
if (previousMode !== nextMode) {
|
||||
this._hideOutsideAndNoSelectedBlock();
|
||||
}
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.resizeStarted.subscribe(() => {
|
||||
if (
|
||||
!shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: this.viewport.zoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearPendingViewportRefreshTimer();
|
||||
this._lastViewportRefreshTime = performance.now();
|
||||
this._lastVisibleModels = undefined;
|
||||
this._scheduleChunkedHide();
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => {
|
||||
this._clearPendingViewportRefreshTimer();
|
||||
this._lastViewportRefreshTime = performance.now();
|
||||
this._refreshViewport();
|
||||
// When SKIP_REFRESH_DURING_GESTURE is enabled, use chunked activation
|
||||
// on resize (orientation change) to avoid a synchronous full refresh
|
||||
// that causes white-screen flash on landscape with many elements.
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
this._scheduleChunkedHide(() => {
|
||||
this.viewport.viewportUpdated.next({
|
||||
zoom: this.viewport.zoom,
|
||||
center: [this.viewport.centerX, this.viewport.centerY],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this._refreshViewport();
|
||||
}
|
||||
})
|
||||
);
|
||||
if (!this.host) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.host.std.view.viewUpdated.subscribe(payload => {
|
||||
if (payload.type !== 'block' || payload.method !== 'add') return;
|
||||
if (!isGfxBlockComponent(payload.view)) return;
|
||||
|
||||
const currentSelectedModels = this._getSelectedModels();
|
||||
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: this.viewport.zoom,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
});
|
||||
const isSelected = currentSelectedModels.has(payload.view.model);
|
||||
const isInViewport = this.getModelsInViewport().has(payload.view.model);
|
||||
const shouldLimitActiveModels =
|
||||
shouldUseSurvivalMode &&
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
|
||||
const activeModels = shouldLimitActiveModels
|
||||
? getLowZoomGestureActiveModels({
|
||||
selectedModels: currentSelectedModels,
|
||||
viewportModels: this.getModelsInViewport(),
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
nearbyActiveBlockLimit:
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
|
||||
nearbyDistanceRatio:
|
||||
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
|
||||
})
|
||||
: null;
|
||||
|
||||
payload.view.transformState$.value = isSelected
|
||||
? 'active'
|
||||
: isInViewport
|
||||
? shouldLimitActiveModels
|
||||
? activeModels?.has(payload.view.model)
|
||||
? 'active'
|
||||
: 'idle'
|
||||
: shouldUseSurvivalMode
|
||||
? 'survival'
|
||||
: 'active'
|
||||
: 'idle';
|
||||
|
||||
if (shouldLimitActiveModels && this._shouldParkIdleBlocks()) {
|
||||
this._syncMountedBlockViews(activeModels ?? new Set());
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// When SKIP_REFRESH_DURING_GESTURE is enabled, do one final refresh
|
||||
// after panning/zooming ends to sync block visibility.
|
||||
// Uses setTimeout (not requestIdleCallback) to guarantee a minimum delay
|
||||
// before heavy work starts. requestIdleCallback fires immediately when
|
||||
// idle, which doesn't protect against the "quick pause then resume" pattern.
|
||||
// Uses chunked block activation to prevent memory spikes on mobile.
|
||||
// Cancel if a new gesture starts before completion.
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
// Marks this element so the stylesheet can drop 'will-change: transform'
|
||||
// from idle blocks (see styles above): in this mode the gesture is driven
|
||||
// by one container transform, so per-block layer promotion is pure
|
||||
// overhead and stalls WKWebView's compositor.
|
||||
this.dataset.skipGestureRefresh = '';
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelChunked: (() => void) | null = null;
|
||||
|
||||
// --- Container-level CSS transform during gestures ---
|
||||
// Instead of updating N block transforms per frame (expensive),
|
||||
// apply a single CSS transform on this element that represents the
|
||||
// relative zoom/pan delta from the gesture start state.
|
||||
// This keeps WKWebView's compositor in sync with only 1 DOM write/frame.
|
||||
let gestureBaseZoom: number | null = null;
|
||||
let gestureBaseTranslateX: number | null = null;
|
||||
let gestureBaseTranslateY: number | null = null;
|
||||
let gestureRAF: number | null = null;
|
||||
let lastTransformTime = 0;
|
||||
|
||||
const applyContainerTransform = () => {
|
||||
gestureRAF = null;
|
||||
if (gestureBaseZoom === null) return;
|
||||
const { zoom, translateX, translateY } = this.viewport;
|
||||
const relativeScale = zoom / gestureBaseZoom;
|
||||
const isPureTranslate = Math.abs(relativeScale - 1) < 1e-3;
|
||||
const now = performance.now();
|
||||
// Scale gestures were already throttled here. The new evidence shows the
|
||||
// crash can still happen while all editor/scroll counters stay at zero,
|
||||
// which points back to this gesture-time container transform path.
|
||||
// On iOS at far-out zoom (the 0.4 repro band), even pure translate can
|
||||
// still move a very large layer tree (17 canvases + active blocks). So
|
||||
// we now also throttle pure-translate writes in that zoom band instead of
|
||||
// assuming they are always cheap.
|
||||
if (
|
||||
shouldSkipGestureTransformWrite({
|
||||
isPureTranslate,
|
||||
zoom,
|
||||
elapsedMs: now - lastTransformTime,
|
||||
})
|
||||
) {
|
||||
gestureRAF = requestAnimationFrame(applyContainerTransform);
|
||||
return;
|
||||
}
|
||||
lastTransformTime = now;
|
||||
// Container transform: scale changes block sizes, translate compensates
|
||||
// for the center shift. Formula: final_pos = container_translate + scale * base_pos
|
||||
// We need: container_translate + scale * base_pos = current_pos
|
||||
// => container_translate = current_translate - scale * base_translate
|
||||
const dx = translateX - relativeScale * gestureBaseTranslateX!;
|
||||
const dy = translateY - relativeScale * gestureBaseTranslateY!;
|
||||
// Pure pan (relativeScale === 1) is the common gesture and the one that
|
||||
// crashes WKWebView's compositor: a transform that carries scale() keeps
|
||||
// the layer on the "non-trivial transform" path, so WebKit re-rasterizes
|
||||
// the whole container — and with OVERSCAN_RATIO that canvas area is
|
||||
// roughly 2x the visible area behind many canvas layers, which overruns
|
||||
// the GPU compositor (rafGap spikes while drift stays low). Emitting a bare
|
||||
// translate() instead routes panning through the cheap layer-move fast
|
||||
// path with no re-rasterization. The math is identical when scale === 1
|
||||
// (dx/dy already reduce to the pan delta), so this is exact, not a
|
||||
// visual approximation. scale() is only emitted for actual zoom.
|
||||
this.style.transform = isPureTranslate
|
||||
? `translate(${dx}px, ${dy}px)`
|
||||
: `translate(${dx}px, ${dy}px) scale(${relativeScale})`;
|
||||
this.style.transformOrigin = '0 0';
|
||||
};
|
||||
|
||||
const scheduleContainerTransform = () => {
|
||||
if (gestureRAF === null) {
|
||||
gestureRAF = requestAnimationFrame(applyContainerTransform);
|
||||
}
|
||||
};
|
||||
|
||||
const startGestureTransform = () => {
|
||||
gestureBaseZoom = this.viewport.zoom;
|
||||
gestureBaseTranslateX = this.viewport.translateX;
|
||||
gestureBaseTranslateY = this.viewport.translateY;
|
||||
// Let the first frame of a new gesture apply immediately.
|
||||
lastTransformTime = 0;
|
||||
};
|
||||
|
||||
const clearContainerTransform = () => {
|
||||
if (gestureRAF !== null) {
|
||||
cancelAnimationFrame(gestureRAF);
|
||||
gestureRAF = null;
|
||||
}
|
||||
gestureBaseZoom = null;
|
||||
gestureBaseTranslateX = null;
|
||||
gestureBaseTranslateY = null;
|
||||
this.style.transform = 'none';
|
||||
};
|
||||
|
||||
// --- End-of-gesture recovery ---
|
||||
const cancelPendingRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
if (cancelChunked !== null) {
|
||||
cancelChunked();
|
||||
cancelChunked = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleIdleRefresh = () => {
|
||||
cancelPendingRefresh();
|
||||
const delayMs = getPostGestureRecoveryDelay({
|
||||
isPanning: this.viewport.panning$.value,
|
||||
isZooming: this.viewport.zooming$.value,
|
||||
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
|
||||
});
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
// If a gesture is still in-flight when the timer fires (e.g. inertial
|
||||
// scroll or clamped setZoom at the zoom floor keeps re-arming the
|
||||
// panning$/zooming$ debounce), do NOT drop the refresh — reschedule
|
||||
// it. Dropping here is what left connectors/elements blank until the
|
||||
// user tapped to force a synchronous refresh.
|
||||
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||
scheduleIdleRefresh();
|
||||
return;
|
||||
}
|
||||
// Remove container transform before per-block update
|
||||
clearContainerTransform();
|
||||
this._lastViewportRefreshTime = performance.now();
|
||||
// Use chunked activation to spread block rendering across frames
|
||||
cancelChunked = this._chunkedHideOutsideAndNoSelectedBlock(() => {
|
||||
cancelChunked = null;
|
||||
// After all blocks are activated, emit viewportUpdated
|
||||
// to update individual block transforms
|
||||
this.viewport.viewportUpdated.next({
|
||||
zoom: this.viewport.zoom,
|
||||
center: [this.viewport.centerX, this.viewport.centerY],
|
||||
});
|
||||
});
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
// Listen to panning$ to drive the container transform during gestures
|
||||
// and handle end-of-gesture recovery
|
||||
this.disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) {
|
||||
if (gestureBaseZoom === null) {
|
||||
startGestureTransform();
|
||||
}
|
||||
scheduleContainerTransform();
|
||||
cancelPendingRefresh();
|
||||
} else {
|
||||
scheduleIdleRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) {
|
||||
if (gestureBaseZoom === null) {
|
||||
startGestureTransform();
|
||||
}
|
||||
scheduleContainerTransform();
|
||||
cancelPendingRefresh();
|
||||
} else {
|
||||
scheduleIdleRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add({
|
||||
dispose: () => {
|
||||
cancelPendingRefresh();
|
||||
clearContainerTransform();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this._clearPendingViewportRefreshTimer();
|
||||
this._cancelPendingChunkedHide();
|
||||
this._restoreParkedBlockViews();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,120 @@ export const ZOOM_INITIAL = 1.0;
|
||||
|
||||
export const FIT_TO_SCREEN_PADDING = 100;
|
||||
|
||||
/**
|
||||
* Process-wide defaults applied to every {@link Viewport} at construction.
|
||||
*
|
||||
* Platforms that need different behavior (e.g. mobile/iOS, which must clamp the
|
||||
* zoom floor and defer DOM mutations during gestures to avoid WKWebView process
|
||||
* termination) override these once at startup, before any editor mounts. This
|
||||
* guarantees both the editor and the readonly preview viewports are born with
|
||||
* the same limits — avoiding the race and wrong-instance problems of patching a
|
||||
* single Viewport asynchronously after it has already mounted.
|
||||
*
|
||||
* Desktop leaves these untouched, so its behavior is unchanged.
|
||||
*/
|
||||
export const viewportRuntimeConfig = {
|
||||
ZOOM_MIN,
|
||||
ZOOM_MAX,
|
||||
VIEWPORT_REFRESH_PIXEL_THRESHOLD: 18,
|
||||
VIEWPORT_REFRESH_MAX_INTERVAL: 120,
|
||||
SKIP_REFRESH_DURING_GESTURE: false,
|
||||
/**
|
||||
* Delay (ms) before the post-gesture refresh repaints canvases and reactivates
|
||||
* blocks, used only when {@link SKIP_REFRESH_DURING_GESTURE} is true. The same
|
||||
* value drives both the canvas and block refresh timers so they fire together
|
||||
* (avoiding the "blocks appear, then connectors" staggered reveal). Desktop
|
||||
* never enters that code path, so this is mobile-only.
|
||||
*/
|
||||
POST_GESTURE_REFRESH_DELAY: 800,
|
||||
/**
|
||||
* Caps the canvas backing-store device-pixel-ratio at low zoom.
|
||||
*
|
||||
* Each entry is `[zoomThreshold, dprCap]`, sorted ascending by threshold.
|
||||
* When the live zoom is below a threshold, the corresponding cap bounds the
|
||||
* effective dpr used to size canvases. Far-out zoom makes content tiny on
|
||||
* screen, so a full retina backing store is wasted memory — on iOS that waste
|
||||
* is what pushes WKWebView past its compositing budget and crashes the web
|
||||
* content process during pan/zoom.
|
||||
*
|
||||
* Empty (the desktop default) means no cap: canvases always use the raw
|
||||
* `window.devicePixelRatio`, so desktop behavior is unchanged.
|
||||
*/
|
||||
CANVAS_DPR_CAP_BY_ZOOM: [] as Array<[number, number]>,
|
||||
/**
|
||||
* Fraction by which the *render/activation* viewport bound is enlarged on
|
||||
* every side (see {@link Viewport.overscanViewportBounds}). Pre-painting a
|
||||
* margin around the visible area means moderate pan/zoom gestures move into
|
||||
* content that is already mounted and rasterized, so it does not blank out
|
||||
* and wait for the post-gesture refresh.
|
||||
*
|
||||
* Memory grows by roughly `(1 + 2 * ratio) ** 2`, so this must stay modest
|
||||
* and be paired with a zoom floor + dpr cap on mobile. `0` (desktop default)
|
||||
* makes {@link Viewport.overscanViewportBounds} identical to
|
||||
* {@link Viewport.viewportBounds}, leaving desktop behavior unchanged.
|
||||
*
|
||||
* This governs the *canvas* render bound only (see
|
||||
* {@link Viewport.overscanViewportBounds}). It enlarges the canvas backing
|
||||
* stores, so memory grows with the overscan area. Keep it modest and pair it
|
||||
* with the mobile zoom floor + dpr cap so connectors/elements stay painted
|
||||
* through a gesture without pushing WKWebView over budget.
|
||||
*/
|
||||
OVERSCAN_RATIO: 0,
|
||||
/**
|
||||
* Like {@link OVERSCAN_RATIO} but for the *DOM block mounting* bound (see
|
||||
* {@link Viewport.overscanBlockBounds}). This one is expensive: every
|
||||
* mounted block becomes its own composited layer subtree in the WebContent
|
||||
* process, so enlarging it multiplies resident memory and is what pushes the
|
||||
* process toward an iOS jetsam kill. Keep this small (or `0`) even when
|
||||
* {@link OVERSCAN_RATIO} is generous. `0` (desktop default) leaves block
|
||||
* mounting on the exact visible bound, unchanged from upstream.
|
||||
*/
|
||||
OVERSCAN_RATIO_BLOCK: 0,
|
||||
/**
|
||||
* During low-zoom gesture survival mode, keep only a tiny subset of DOM blocks
|
||||
* as real active DOM (selected + a few nearby blocks). `0` keeps the legacy
|
||||
* behavior where every viewport block remains visually mounted as `survival`.
|
||||
*/
|
||||
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT: 0,
|
||||
/**
|
||||
* Distance threshold (as a fraction of the viewport's shorter side) used to
|
||||
* decide whether an unselected viewport block counts as "nearby" to the
|
||||
* current selection during low-zoom gesture survival mode.
|
||||
*/
|
||||
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO: 0.35,
|
||||
};
|
||||
|
||||
export function getPostGestureRecoveryDelay({
|
||||
isPanning,
|
||||
isZooming,
|
||||
fallbackDelayMs,
|
||||
}: {
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
fallbackDelayMs: number;
|
||||
}) {
|
||||
return isPanning || isZooming ? fallbackDelayMs : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective device-pixel-ratio for canvas backing stores at the
|
||||
* given zoom, honoring {@link viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM}.
|
||||
*
|
||||
* Returns the raw `window.devicePixelRatio` when no cap applies.
|
||||
*/
|
||||
export function getEffectiveDpr(
|
||||
zoom: number,
|
||||
rawDpr = window.devicePixelRatio
|
||||
): number {
|
||||
const caps = viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM;
|
||||
for (const [zoomThreshold, dprCap] of caps) {
|
||||
if (zoom < zoomThreshold) {
|
||||
return Math.min(rawDpr, dprCap);
|
||||
}
|
||||
}
|
||||
return rawDpr;
|
||||
}
|
||||
|
||||
export interface ViewportRecord {
|
||||
left: number;
|
||||
top: number;
|
||||
@@ -92,6 +206,13 @@ export class Viewport {
|
||||
top: number;
|
||||
}>();
|
||||
|
||||
resizeStarted = new Subject<{
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
}>();
|
||||
|
||||
viewportMoved = new Subject<IVec>();
|
||||
|
||||
viewportUpdated = new Subject<{
|
||||
@@ -99,12 +220,71 @@ export class Viewport {
|
||||
center: IVec;
|
||||
}>();
|
||||
|
||||
zoomUpdated = new Subject<{
|
||||
previousZoom: number;
|
||||
zoom: number;
|
||||
}>();
|
||||
|
||||
zooming$ = new BehaviorSubject<boolean>(false);
|
||||
panning$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
ZOOM_MAX = ZOOM_MAX;
|
||||
/**
|
||||
* Per-instance override for the maximum zoom. When unset, the value is read
|
||||
* dynamically from {@link viewportRuntimeConfig} so that runtime overrides
|
||||
* (e.g. iOS mobile-safe limits configured at app startup) always apply,
|
||||
* regardless of whether this instance was constructed before or after the
|
||||
* override ran.
|
||||
*/
|
||||
private _zoomMaxOverride?: number;
|
||||
|
||||
ZOOM_MIN = ZOOM_MIN;
|
||||
private _zoomMinOverride?: number;
|
||||
|
||||
get ZOOM_MAX() {
|
||||
return this._zoomMaxOverride ?? viewportRuntimeConfig.ZOOM_MAX;
|
||||
}
|
||||
|
||||
set ZOOM_MAX(value: number) {
|
||||
this._zoomMaxOverride = value;
|
||||
}
|
||||
|
||||
get ZOOM_MIN() {
|
||||
return this._zoomMinOverride ?? viewportRuntimeConfig.ZOOM_MIN;
|
||||
}
|
||||
|
||||
set ZOOM_MIN(value: number) {
|
||||
this._zoomMinOverride = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum pixel movement before triggering a viewport refresh during panning.
|
||||
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
|
||||
* Default: 18 (desktop-optimized).
|
||||
*/
|
||||
VIEWPORT_REFRESH_PIXEL_THRESHOLD =
|
||||
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD;
|
||||
|
||||
/**
|
||||
* Maximum interval (ms) between viewport refreshes during continuous interaction.
|
||||
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
|
||||
* Default: 120 (desktop-optimized).
|
||||
*/
|
||||
VIEWPORT_REFRESH_MAX_INTERVAL =
|
||||
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL;
|
||||
|
||||
/**
|
||||
* When true, viewport element visibility refreshes are skipped entirely during
|
||||
* panning/zooming, deferring all DOM mutations until the gesture ends.
|
||||
* Prevents JS main thread blocking that can cause WKWebView process termination.
|
||||
* Default: false (desktop behavior unchanged).
|
||||
*/
|
||||
SKIP_REFRESH_DURING_GESTURE =
|
||||
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE;
|
||||
|
||||
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT =
|
||||
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT;
|
||||
|
||||
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO =
|
||||
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO;
|
||||
|
||||
private readonly _resetZooming = debounce(() => {
|
||||
this.zooming$.next(false);
|
||||
@@ -144,7 +324,7 @@ export class Viewport {
|
||||
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
|
||||
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
|
||||
|
||||
this.setCenter(newCenterX, newCenterY, false);
|
||||
this.setCenter(newCenterX, newCenterY, false, false);
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._left = left;
|
||||
@@ -245,6 +425,49 @@ export class Viewport {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link viewportBounds} but enlarged by
|
||||
* {@link viewportRuntimeConfig.OVERSCAN_RATIO} on every side. Used only by
|
||||
* the *canvas* render path so that gestures move into already-rasterized
|
||||
* vector content instead of blank space. This also enlarges the canvas
|
||||
* backing store, so keep the ratio conservative.
|
||||
*
|
||||
* Hit-testing, selection and other geometry must keep using the exact
|
||||
* {@link viewportBounds}; do not substitute this for those.
|
||||
*/
|
||||
get overscanViewportBounds() {
|
||||
return this._enlargeBounds(viewportRuntimeConfig.OVERSCAN_RATIO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link overscanViewportBounds} but governed by the separate, smaller
|
||||
* {@link viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK}. Used only by the *DOM
|
||||
* block mounting* path. Expensive: every mounted block adds a composited
|
||||
* layer subtree, so this must stay small to keep the WebContent process
|
||||
* under the iOS jetsam memory limit even when canvas overscan is generous.
|
||||
*/
|
||||
get overscanBlockBounds() {
|
||||
return this._enlargeBounds(viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK);
|
||||
}
|
||||
|
||||
private _enlargeBounds(ratio: number) {
|
||||
const bounds = this.viewportBounds;
|
||||
|
||||
if (ratio <= 0) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
const marginX = bounds.w * ratio;
|
||||
const marginY = bounds.h * ratio;
|
||||
|
||||
return new Bound(
|
||||
bounds.x - marginX,
|
||||
bounds.y - marginY,
|
||||
bounds.w + marginX * 2,
|
||||
bounds.h + marginY * 2
|
||||
);
|
||||
}
|
||||
|
||||
get viewportMaxXY() {
|
||||
const { centerX, centerY, width, height, zoom } = this;
|
||||
return {
|
||||
@@ -297,8 +520,10 @@ export class Viewport {
|
||||
dispose() {
|
||||
this.clearViewportElement();
|
||||
this.sizeUpdated.complete();
|
||||
this.resizeStarted.complete();
|
||||
this.viewportMoved.complete();
|
||||
this.viewportUpdated.complete();
|
||||
this.zoomUpdated.complete();
|
||||
this._resizeSubject.complete();
|
||||
this.zooming$.complete();
|
||||
this.panning$.complete();
|
||||
@@ -307,7 +532,7 @@ export class Viewport {
|
||||
getFitToScreenData(
|
||||
bounds?: Bound | null,
|
||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||
maxZoom = ZOOM_MAX,
|
||||
maxZoom = this.ZOOM_MAX,
|
||||
fitToScreenPadding = 100
|
||||
) {
|
||||
let { centerX, centerY, zoom } = this;
|
||||
@@ -324,7 +549,11 @@ export class Viewport {
|
||||
(width - fitToScreenPadding - (pr + pl)) / w,
|
||||
(height - fitToScreenPadding - (pt + pb)) / h
|
||||
);
|
||||
zoom = clamp(zoom, ZOOM_MIN, clamp(maxZoom, ZOOM_MIN, ZOOM_MAX));
|
||||
zoom = clamp(
|
||||
zoom,
|
||||
this.ZOOM_MIN,
|
||||
clamp(maxZoom, this.ZOOM_MIN, this.ZOOM_MAX)
|
||||
);
|
||||
|
||||
centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2;
|
||||
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
|
||||
@@ -353,6 +582,12 @@ export class Viewport {
|
||||
|
||||
this._left = left;
|
||||
this._top = top;
|
||||
this.resizeStarted.next({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this._resizeSubject.next({
|
||||
left,
|
||||
top,
|
||||
@@ -367,19 +602,39 @@ export class Viewport {
|
||||
* @param centerY The new y coordinate of the center of the viewport.
|
||||
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||
*/
|
||||
setCenter(centerX: number, centerY: number, forceUpdate = true) {
|
||||
setCenter(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
forceUpdate = true,
|
||||
signalPanning = true
|
||||
) {
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
}
|
||||
|
||||
this._center.x = centerX;
|
||||
this._center.y = centerY;
|
||||
this.panning$.next(true);
|
||||
this.viewportUpdated.next({
|
||||
zoom: this.zoom,
|
||||
center: Vec.toVec(this.center) as IVec,
|
||||
});
|
||||
this._resetPanning();
|
||||
|
||||
const gestureActive = this.panning$.value || this.zooming$.value;
|
||||
|
||||
if (signalPanning) {
|
||||
this.panning$.next(true);
|
||||
}
|
||||
|
||||
// When SKIP_REFRESH_DURING_GESTURE is active, suppress viewportUpdated
|
||||
// emissions during gestures. Heavy subscribers (canvas, DOM visibility,
|
||||
// per-block transforms) would otherwise fire on every gesture event.
|
||||
// Instead, the viewport-element applies a lightweight container-level
|
||||
// CSS transform to keep visuals in sync with zero per-block overhead.
|
||||
if (!(this.SKIP_REFRESH_DURING_GESTURE && gestureActive)) {
|
||||
this.viewportUpdated.next({
|
||||
zoom: this.zoom,
|
||||
center: Vec.toVec(this.center) as IVec,
|
||||
});
|
||||
}
|
||||
if (signalPanning) {
|
||||
this._resetPanning();
|
||||
}
|
||||
}
|
||||
|
||||
setRect(left: number, top: number, width: number, height: number) {
|
||||
@@ -410,7 +665,8 @@ export class Viewport {
|
||||
newZoom: number,
|
||||
newCenter = Vec.toVec(this.center),
|
||||
smooth = false,
|
||||
forceUpdate = true
|
||||
forceUpdate = true,
|
||||
signalGesture = false
|
||||
) {
|
||||
// Force complete any pending resize operations if forceUpdate is true
|
||||
if (forceUpdate && this._isResizing) {
|
||||
@@ -421,19 +677,19 @@ export class Viewport {
|
||||
if (smooth) {
|
||||
const cofficient = preZoom / newZoom;
|
||||
if (cofficient === 1) {
|
||||
this.smoothTranslate(newCenter[0], newCenter[1]);
|
||||
this.smoothTranslate(newCenter[0], newCenter[1], 10, signalGesture);
|
||||
} else {
|
||||
const center = [this.centerX, this.centerY] as IVec;
|
||||
const focusPoint = Vec.mul(
|
||||
Vec.sub(newCenter, Vec.mul(center, cofficient)),
|
||||
1 / (1 - cofficient)
|
||||
);
|
||||
this.smoothZoom(newZoom, Vec.toPoint(focusPoint));
|
||||
this.smoothZoom(newZoom, Vec.toPoint(focusPoint), 10, signalGesture);
|
||||
}
|
||||
} else {
|
||||
this._center.x = newCenter[0];
|
||||
this._center.y = newCenter[1];
|
||||
this.setZoom(newZoom, undefined, false, forceUpdate);
|
||||
this.setZoom(newZoom, undefined, false, forceUpdate, signalGesture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +706,8 @@ export class Viewport {
|
||||
bound: Bound,
|
||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||
smooth = false,
|
||||
forceUpdate = true
|
||||
forceUpdate = true,
|
||||
signalGesture = false
|
||||
) {
|
||||
let [pt, pr, pb, pl] = padding;
|
||||
|
||||
@@ -485,7 +742,7 @@ export class Viewport {
|
||||
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
|
||||
] as IVec;
|
||||
|
||||
this.setViewport(zoom, center, smooth, forceUpdate);
|
||||
this.setViewport(zoom, center, smooth, forceUpdate, signalGesture);
|
||||
}
|
||||
|
||||
/** This is the outer container of the viewport, which is the host of the viewport element */
|
||||
@@ -509,14 +766,15 @@ export class Viewport {
|
||||
* Set the viewport to the new zoom.
|
||||
* @param zoom The new zoom value.
|
||||
* @param focusPoint The point to focus on after zooming, default is the center of the viewport.
|
||||
* @param wheel Whether the zoom is caused by wheel event.
|
||||
* @param _wheel Legacy parameter kept for call-site compatibility.
|
||||
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||
*/
|
||||
setZoom(
|
||||
zoom: number,
|
||||
focusPoint?: IPoint,
|
||||
wheel = false,
|
||||
forceUpdate = true
|
||||
_wheel = false,
|
||||
forceUpdate = true,
|
||||
signalGesture = false
|
||||
) {
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
@@ -532,18 +790,21 @@ export class Viewport {
|
||||
Vec.toVec(focusPoint),
|
||||
Vec.mul(offset, prevZoom / newZoom)
|
||||
);
|
||||
if (wheel) {
|
||||
// Always signal zooming for any real gesture zoom change (pinch or wheel).
|
||||
// Programmatic viewport changes should use the normal refresh path without
|
||||
// entering low-zoom gesture survival mode.
|
||||
if (signalGesture) {
|
||||
this.zooming$.next(true);
|
||||
}
|
||||
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
|
||||
this.viewportUpdated.next({
|
||||
zoom: this.zoom,
|
||||
center: Vec.toVec(this.center) as IVec,
|
||||
});
|
||||
this._resetZooming();
|
||||
this.setCenter(newCenter[0], newCenter[1], forceUpdate, signalGesture);
|
||||
this.zoomUpdated.next({ previousZoom: prevZoom, zoom: newZoom });
|
||||
// setCenter already emits viewportUpdated, no need to emit again here.
|
||||
if (signalGesture) {
|
||||
this._resetZooming();
|
||||
}
|
||||
}
|
||||
|
||||
smoothTranslate(x: number, y: number, numSteps = 10) {
|
||||
smoothTranslate(x: number, y: number, numSteps = 10, signalGesture = false) {
|
||||
const { center } = this;
|
||||
const delta = { x: x - center.x, y: y - center.y };
|
||||
const innerSmoothTranslate = () => {
|
||||
@@ -558,7 +819,7 @@ export class Viewport {
|
||||
const signY = delta.y > 0 ? 1 : -1;
|
||||
nextCenter.x = cutoff(nextCenter.x, x, signX);
|
||||
nextCenter.y = cutoff(nextCenter.y, y, signY);
|
||||
this.setCenter(nextCenter.x, nextCenter.y, true);
|
||||
this.setCenter(nextCenter.x, nextCenter.y, true, signalGesture);
|
||||
|
||||
if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate();
|
||||
});
|
||||
@@ -566,7 +827,12 @@ export class Viewport {
|
||||
innerSmoothTranslate();
|
||||
}
|
||||
|
||||
smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
|
||||
smoothZoom(
|
||||
zoom: number,
|
||||
focusPoint?: IPoint,
|
||||
numSteps = 10,
|
||||
signalGesture = false
|
||||
) {
|
||||
const delta = zoom - this.zoom;
|
||||
if (this._rafId) cancelAnimationFrame(this._rafId);
|
||||
|
||||
@@ -576,7 +842,7 @@ export class Viewport {
|
||||
const step = delta / numSteps;
|
||||
const nextZoom = cutoff(this.zoom + step, zoom, sign);
|
||||
|
||||
this.setZoom(nextZoom, focusPoint, undefined, true);
|
||||
this.setZoom(nextZoom, focusPoint, undefined, true, signalGesture);
|
||||
|
||||
if (nextZoom != zoom) innerSmoothZoom();
|
||||
});
|
||||
|
||||
@@ -213,20 +213,22 @@ export class RangeBinding {
|
||||
return;
|
||||
}
|
||||
|
||||
const startElement = getElement(range.startContainer);
|
||||
const endElement = getElement(range.endContainer);
|
||||
const hasInlineEndpoint =
|
||||
!!startElement?.closest('v-text') || !!endElement?.closest('v-text');
|
||||
|
||||
const el = getElement(range.commonAncestorContainer);
|
||||
if (!el) return;
|
||||
|
||||
const closestExclude = el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`);
|
||||
if (closestExclude) return;
|
||||
if (closestExclude && !hasInlineEndpoint) return;
|
||||
|
||||
const closestEditable = el.closest('[contenteditable]');
|
||||
if (!closestEditable) return;
|
||||
|
||||
const startElement = getElement(range.startContainer);
|
||||
const endElement = getElement(range.endContainer);
|
||||
if (!closestEditable && !hasInlineEndpoint) return;
|
||||
|
||||
// if neither start nor end is in a v-text, the range is invalid
|
||||
if (!startElement?.closest('v-text') && !endElement?.closest('v-text')) {
|
||||
if (!hasInlineEndpoint) {
|
||||
this._prevTextSelection = null;
|
||||
this.selectionManager.clear(['text']);
|
||||
|
||||
|
||||
@@ -42,12 +42,17 @@ function updateBlockVisibility(view: GfxBlockComponent) {
|
||||
if (view.transformState$.value === 'active') {
|
||||
view.style.visibility = 'visible';
|
||||
view.style.pointerEvents = 'auto';
|
||||
view.classList.remove('block-idle');
|
||||
view.classList.remove('block-idle', 'block-survival');
|
||||
view.classList.add('block-active');
|
||||
} else if (view.transformState$.value === 'survival') {
|
||||
view.style.visibility = 'visible';
|
||||
view.style.pointerEvents = 'none';
|
||||
view.classList.remove('block-active', 'block-idle');
|
||||
view.classList.add('block-survival');
|
||||
} else {
|
||||
view.style.visibility = 'hidden';
|
||||
view.style.pointerEvents = 'none';
|
||||
view.classList.remove('block-active');
|
||||
view.classList.remove('block-active', 'block-survival');
|
||||
view.classList.add('block-idle');
|
||||
}
|
||||
}
|
||||
@@ -55,8 +60,19 @@ function updateBlockVisibility(view: GfxBlockComponent) {
|
||||
function handleGfxConnection(instance: GfxBlockComponent) {
|
||||
instance.style.position = 'absolute';
|
||||
|
||||
const viewport = instance.gfx.viewport;
|
||||
|
||||
instance.disposables.add(
|
||||
instance.gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
viewport.viewportUpdated.subscribe(() => {
|
||||
// When SKIP_REFRESH_DURING_GESTURE is enabled and a gesture is active,
|
||||
// skip per-block transform updates. The viewport-element applies a
|
||||
// container-level CSS transform to keep visuals in sync instead.
|
||||
if (
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(viewport.panning$.value || viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateTransform(instance);
|
||||
})
|
||||
);
|
||||
@@ -95,7 +111,7 @@ export abstract class GfxBlockComponent<
|
||||
{
|
||||
[GfxElementSymbol] = true;
|
||||
|
||||
readonly transformState$ = signal<'idle' | 'active'>('active');
|
||||
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
@@ -207,7 +223,7 @@ export function toGfxBlockComponent<
|
||||
return class extends CustomBlock {
|
||||
[GfxElementSymbol] = true;
|
||||
|
||||
readonly transformState$ = signal<'idle' | 'active'>('active');
|
||||
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
|
||||
|
||||
override selected$ = computed(() => {
|
||||
const selection = this.std.selection.value.find(
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"playwright": "=1.58.2",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3"
|
||||
|
||||
+17
-2
@@ -90,7 +90,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"packageManager": "yarn@4.13.0",
|
||||
@@ -167,7 +167,22 @@
|
||||
"typedarray": "npm:@nolyfill/typedarray@^1",
|
||||
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
|
||||
"fs-xattr": "npm:@napi-rs/xattr@latest",
|
||||
"ioredis": "5.8.2",
|
||||
"@opentelemetry/core": "^2.8.0",
|
||||
"@opentelemetry/resources": "^2.8.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
||||
"@tootallnate/once": "^2.0.1",
|
||||
"ioredis": "^5.11.1",
|
||||
"js-yaml@npm:^4.1.0": "^4.2.0",
|
||||
"js-yaml@npm:4.1.1": "^4.2.0",
|
||||
"multer": "^2.2.0",
|
||||
"protobufjs": "^7.6.4",
|
||||
"tar": "^7.5.16",
|
||||
"tmp": "^0.2.7",
|
||||
"ws@npm:^8.18.0": "^8.21.0",
|
||||
"ws@npm:^8.18.3": "^8.21.0",
|
||||
"ws@npm:^8.19.0": "^8.21.0",
|
||||
"ws@npm:8.20.1": "^8.21.0",
|
||||
"ws@npm:~8.17.1": "^8.21.0",
|
||||
"decode-named-character-reference@npm:^1.0.0": "patch:decode-named-character-reference@npm%3A1.0.2#~/.yarn/patches/decode-named-character-reference-npm-1.0.2-db17a755fd.patch",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "patch:@atlaskit/pragmatic-drag-and-drop@npm%3A1.4.0#~/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch",
|
||||
"yjs": "patch:yjs@npm%3A13.6.21#~/.yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch"
|
||||
|
||||
@@ -11,13 +11,13 @@ crate-type = ["cdylib"]
|
||||
[dependencies]
|
||||
aes-gcm = { workspace = true }
|
||||
affine_common = { workspace = true, features = [
|
||||
"doc-loader",
|
||||
"hashcash",
|
||||
"napi",
|
||||
"ydoc-loader",
|
||||
] }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
doc_extractor = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use affine_common::{doc_loader::Doc, napi_utils::map_napi_err};
|
||||
use affine_common::napi_utils::map_napi_err;
|
||||
use doc_extractor::Doc;
|
||||
use napi::{
|
||||
Env, Result, Status, Task,
|
||||
bindgen_prelude::{AsyncTask, Buffer},
|
||||
|
||||
+12
@@ -637,6 +637,18 @@ BEGIN
|
||||
RAISE EXCEPTION 'Cannot project unknown doc role % for %.% user %', NEW.type, NEW.workspace_id, NEW.page_id, NEW.user_id;
|
||||
END IF;
|
||||
|
||||
IF projected_role = 'owner' AND EXISTS (
|
||||
SELECT 1
|
||||
FROM doc_grants
|
||||
WHERE workspace_id = NEW.workspace_id
|
||||
AND doc_id = NEW.page_id
|
||||
AND principal_type = 'user'
|
||||
AND role = 'owner'
|
||||
AND principal_id <> NEW.user_id
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
INSERT INTO doc_grants (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "provider_subscriptions" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"provider" "Provider" NOT NULL,
|
||||
"target_type" TEXT NOT NULL,
|
||||
"target_id" VARCHAR NOT NULL,
|
||||
"plan" VARCHAR(20) NOT NULL,
|
||||
"recurring" VARCHAR(20),
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"external_customer_id" VARCHAR,
|
||||
"external_subscription_id" VARCHAR,
|
||||
"external_product_id" VARCHAR,
|
||||
"external_price_id" VARCHAR,
|
||||
"iap_store" "IapStore",
|
||||
"external_ref" VARCHAR,
|
||||
"currency" VARCHAR(3),
|
||||
"amount" INTEGER,
|
||||
"quantity" INTEGER,
|
||||
"period_start" TIMESTAMPTZ(3),
|
||||
"period_end" TIMESTAMPTZ(3),
|
||||
"trial_start" TIMESTAMPTZ(3),
|
||||
"trial_end" TIMESTAMPTZ(3),
|
||||
"canceled_at" TIMESTAMPTZ(3),
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "provider_subscriptions_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "provider_subscriptions_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance')),
|
||||
CONSTRAINT "provider_subscriptions_stripe_identity_check" CHECK ("provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL),
|
||||
CONSTRAINT "provider_subscriptions_revenuecat_identity_check" CHECK ("provider" <> 'revenuecat' OR ("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL))
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment_events" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"provider" "Provider" NOT NULL,
|
||||
"event_type" VARCHAR NOT NULL,
|
||||
"external_event_id" VARCHAR NOT NULL,
|
||||
"target_type" TEXT,
|
||||
"target_id" VARCHAR,
|
||||
"external_invoice_id" VARCHAR,
|
||||
"external_payment_id" VARCHAR,
|
||||
"plan" VARCHAR(20),
|
||||
"amount" INTEGER,
|
||||
"currency" VARCHAR(3),
|
||||
"occurred_at" TIMESTAMPTZ(3),
|
||||
"processing_status" VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
"processing_attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"processed_at" TIMESTAMPTZ(3),
|
||||
"last_error" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "payment_events_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "payment_events_target_type_check" CHECK ("target_type" IS NULL OR "target_type" IN ('user', 'workspace', 'instance')),
|
||||
CONSTRAINT "payment_events_processing_status_check" CHECK ("processing_status" IN ('pending', 'processing', 'processed', 'failed'))
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "subscription_trial_usages" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"target_type" TEXT NOT NULL,
|
||||
"target_id" VARCHAR NOT NULL,
|
||||
"plan" VARCHAR(20) NOT NULL,
|
||||
"provider" "Provider" NOT NULL,
|
||||
"external_ref" VARCHAR,
|
||||
"first_used_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "subscription_trial_usages_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "subscription_trial_usages_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance'))
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "provider_subscriptions_target_type_target_id_plan_status_idx" ON "provider_subscriptions"("target_type", "target_id", "plan", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "provider_subscriptions_provider_external_customer_id_idx" ON "provider_subscriptions"("provider", "external_customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"("provider", "external_subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payment_events_provider_external_event_id_key" ON "payment_events"("provider", "external_event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "payment_events_processing_status_updated_at_idx" ON "payment_events"("processing_status", "updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "payment_events_target_type_target_id_idx" ON "payment_events"("target_type", "target_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscription_trial_usages_target_type_target_id_plan_key" ON "subscription_trial_usages"("target_type", "target_id", "plan");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscription_trial_usages_provider_external_ref_idx" ON "subscription_trial_usages"("provider", "external_ref");
|
||||
@@ -45,27 +45,28 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^2.7.1",
|
||||
"@opentelemetry/exporter-prometheus": "^0.218.0",
|
||||
"@opentelemetry/exporter-zipkin": "^2.7.1",
|
||||
"@opentelemetry/host-metrics": "^0.38.3",
|
||||
"@opentelemetry/instrumentation": "^0.218.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.66.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.218.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.65.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.1",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.38.0",
|
||||
"@opentelemetry/core": "^2.8.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.219.0",
|
||||
"@opentelemetry/exporter-zipkin": "^2.8.0",
|
||||
"@opentelemetry/host-metrics": "^0.39.0",
|
||||
"@opentelemetry/instrumentation": "^0.219.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.67.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.219.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.67.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.65.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.66.0",
|
||||
"@opentelemetry/resources": "^2.8.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.8.0",
|
||||
"@opentelemetry/sdk-node": "^0.219.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.8.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@prisma/instrumentation": "^6.7.0",
|
||||
"@queuedash/api": "^3.16.0",
|
||||
"@react-email/components": "^0.5.7",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"bullmq": "5.77.6",
|
||||
"bullmq": "^5.79.0",
|
||||
"commander": "^13.1.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cross-env": "^10.1.0",
|
||||
@@ -83,7 +84,7 @@
|
||||
"html-validate": "^9.0.0",
|
||||
"htmlrewriter": "^0.0.12",
|
||||
"http-errors": "^2.0.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"ioredis": "^5.11.1",
|
||||
"is-mobile": "^5.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -92,7 +93,7 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"nest-winston": "^1.9.7",
|
||||
"nestjs-cls": "^6.0.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^9.0.0",
|
||||
"on-headers": "^1.1.0",
|
||||
"piscina": "^5.1.4",
|
||||
"prisma": "^6.6.0",
|
||||
@@ -102,7 +103,7 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"ses": "^1.15.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.7.0",
|
||||
"tldts": "^7.0.19",
|
||||
"winston": "^3.17.0",
|
||||
|
||||
@@ -1117,6 +1117,39 @@ model Subscription {
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model ProviderSubscription {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
provider Provider
|
||||
targetType String @map("target_type") @db.Text
|
||||
targetId String @map("target_id") @db.VarChar
|
||||
plan String @db.VarChar(20)
|
||||
recurring String? @db.VarChar(20)
|
||||
status String @db.VarChar(20)
|
||||
externalCustomerId String? @map("external_customer_id") @db.VarChar
|
||||
externalSubscriptionId String? @map("external_subscription_id") @db.VarChar
|
||||
externalProductId String? @map("external_product_id") @db.VarChar
|
||||
externalPriceId String? @map("external_price_id") @db.VarChar
|
||||
iapStore IapStore? @map("iap_store")
|
||||
externalRef String? @map("external_ref") @db.VarChar
|
||||
currency String? @db.VarChar(3)
|
||||
amount Int? @db.Integer
|
||||
quantity Int? @db.Integer
|
||||
periodStart DateTime? @map("period_start") @db.Timestamptz(3)
|
||||
periodEnd DateTime? @map("period_end") @db.Timestamptz(3)
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
|
||||
metadata Json @default("{}") @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([provider, externalSubscriptionId])
|
||||
@@unique([provider, iapStore, externalRef, externalProductId, externalCustomerId])
|
||||
@@index([targetType, targetId, plan, status])
|
||||
@@index([provider, externalCustomerId])
|
||||
@@map("provider_subscriptions")
|
||||
}
|
||||
|
||||
enum Provider {
|
||||
stripe
|
||||
revenuecat
|
||||
@@ -1148,6 +1181,50 @@ model Invoice {
|
||||
@@map("invoices")
|
||||
}
|
||||
|
||||
model PaymentEvent {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
provider Provider
|
||||
eventType String @map("event_type") @db.VarChar
|
||||
externalEventId String @map("external_event_id") @db.VarChar
|
||||
targetType String? @map("target_type") @db.Text
|
||||
targetId String? @map("target_id") @db.VarChar
|
||||
externalInvoiceId String? @map("external_invoice_id") @db.VarChar
|
||||
externalPaymentId String? @map("external_payment_id") @db.VarChar
|
||||
plan String? @db.VarChar(20)
|
||||
amount Int? @db.Integer
|
||||
currency String? @db.VarChar(3)
|
||||
occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3)
|
||||
processingStatus String @default("pending") @map("processing_status") @db.VarChar(20)
|
||||
processingAttempts Int @default(0) @map("processing_attempts") @db.Integer
|
||||
processedAt DateTime? @map("processed_at") @db.Timestamptz(3)
|
||||
lastError String? @map("last_error") @db.Text
|
||||
metadata Json @default("{}") @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([provider, externalEventId])
|
||||
@@index([processingStatus, updatedAt])
|
||||
@@index([targetType, targetId])
|
||||
@@map("payment_events")
|
||||
}
|
||||
|
||||
model SubscriptionTrialUsage {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
targetType String @map("target_type") @db.Text
|
||||
targetId String @map("target_id") @db.VarChar
|
||||
plan String @db.VarChar(20)
|
||||
provider Provider
|
||||
externalRef String? @map("external_ref") @db.VarChar
|
||||
firstUsedAt DateTime @default(now()) @map("first_used_at") @db.Timestamptz(3)
|
||||
metadata Json @default("{}") @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([targetType, targetId, plan])
|
||||
@@index([provider, externalRef])
|
||||
@@map("subscription_trial_usages")
|
||||
}
|
||||
|
||||
model License {
|
||||
key String @id @map("key") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
@@ -23,6 +23,33 @@ test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('payment provider facts migration makes nullable provider identities explicit', t => {
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260604000000_payment_provider_facts/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
t.regex(
|
||||
migration,
|
||||
/provider_subscriptions_stripe_identity_check[\s\S]*"provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL/
|
||||
);
|
||||
t.regex(
|
||||
migration,
|
||||
/provider_subscriptions_revenuecat_identity_check[\s\S]*"provider" <> 'revenuecat' OR \("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL\)/
|
||||
);
|
||||
t.regex(
|
||||
migration,
|
||||
/CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"\("provider", "external_subscription_id"\)/
|
||||
);
|
||||
t.regex(
|
||||
migration,
|
||||
/CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"\("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id"\)/
|
||||
);
|
||||
});
|
||||
|
||||
class TestPermissionProjectionModel extends PermissionProjectionModel {
|
||||
constructor(private readonly fakeDb: unknown) {
|
||||
super();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { CryptoHelper, EventBus } from '../../base';
|
||||
import { CryptoHelper, EventBus, JobQueue } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { licenseClient, LicenseService } from '../../plugins/license/service';
|
||||
import { StripeWebhookController } from '../../plugins/payment/controller';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { PaymentEventHandlers } from '../../plugins/payment/event';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
@@ -196,3 +198,269 @@ test('recurring selfhost license activation returns activation projection withou
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('stripe webhook persists failed async processing for retry visibility', async t => {
|
||||
const event = {
|
||||
id: 'evt_1',
|
||||
type: 'invoice.paid',
|
||||
created: 1710000000,
|
||||
data: { object: { id: 'in_1' } },
|
||||
};
|
||||
const updates: unknown[] = [];
|
||||
const db = {
|
||||
paymentEvent: {
|
||||
findUnique: async () => null,
|
||||
create: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return { id: 'payment_event_1' };
|
||||
},
|
||||
updateMany: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return { count: 1 };
|
||||
},
|
||||
update: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
const controller = new StripeWebhookController(
|
||||
{ payment: { stripe: { webhookKey: 'whsec' } } } as never,
|
||||
db,
|
||||
{
|
||||
stripe: {
|
||||
webhooks: {
|
||||
constructEvent: () => event,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
emitAsync: async () => {
|
||||
throw new Error('handler failed');
|
||||
},
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await controller.handleWebhook({
|
||||
rawBody: Buffer.from('{}'),
|
||||
headers: { 'stripe-signature': 'sig' },
|
||||
} as never);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
t.like(updates[0], {
|
||||
data: {
|
||||
provider: 'stripe',
|
||||
eventType: 'invoice.paid',
|
||||
externalEventId: 'evt_1',
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
updates.slice(1).map(update => (update as { data: unknown }).data),
|
||||
[
|
||||
{
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
},
|
||||
{
|
||||
processingStatus: 'failed',
|
||||
lastError: 'handler failed',
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('stripe webhook skips already processed events', async t => {
|
||||
const event = {
|
||||
id: 'evt_processed',
|
||||
type: 'invoice.paid',
|
||||
created: 1710000000,
|
||||
data: { object: { id: 'in_1' } },
|
||||
};
|
||||
const controller = new StripeWebhookController(
|
||||
{ payment: { stripe: { webhookKey: 'whsec' } } } as never,
|
||||
{
|
||||
paymentEvent: {
|
||||
findUnique: async () => ({
|
||||
id: 'payment_event_processed',
|
||||
processingStatus: 'processed',
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
stripe: {
|
||||
webhooks: {
|
||||
constructEvent: () => event,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
emitAsync: async () => {
|
||||
t.fail('processed event should not be emitted again');
|
||||
},
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await controller.handleWebhook({
|
||||
rawBody: Buffer.from('{}'),
|
||||
headers: { 'stripe-signature': 'sig' },
|
||||
} as never);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('stripe webhook skips events already claimed by another processor', async t => {
|
||||
const event = {
|
||||
id: 'evt_claimed',
|
||||
type: 'invoice.paid',
|
||||
created: 1710000000,
|
||||
data: { object: { id: 'in_1' } },
|
||||
};
|
||||
const controller = new StripeWebhookController(
|
||||
{ payment: { stripe: { webhookKey: 'whsec' } } } as never,
|
||||
{
|
||||
paymentEvent: {
|
||||
findUnique: async () => null,
|
||||
create: async () => ({ id: 'payment_event_claimed' }),
|
||||
updateMany: async () => ({ count: 0 }),
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
stripe: {
|
||||
webhooks: {
|
||||
constructEvent: () => event,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
emitAsync: async () => {
|
||||
t.fail('unclaimed event should not be emitted');
|
||||
},
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await controller.handleWebhook({
|
||||
rawBody: Buffer.from('{}'),
|
||||
headers: { 'stripe-signature': 'sig' },
|
||||
} as never);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('stripe webhook replay job reprocesses pending events', async t => {
|
||||
const updates: unknown[] = [];
|
||||
const emitted: unknown[] = [];
|
||||
let findManyInput: unknown;
|
||||
const cron = new SubscriptionCronJobs(
|
||||
{
|
||||
paymentEvent: {
|
||||
findMany: async (input: unknown) => {
|
||||
findManyInput = input;
|
||||
return [
|
||||
{
|
||||
id: 'payment_event_1',
|
||||
eventType: 'invoice.paid',
|
||||
metadata: { id: 'in_1' },
|
||||
},
|
||||
];
|
||||
},
|
||||
updateMany: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return { count: 1 };
|
||||
},
|
||||
update: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emitAsync: async (name: string, payload: unknown) => {
|
||||
emitted.push({ name, payload });
|
||||
},
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as JobQueue,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
await cron.replayStripeWebhookEvents();
|
||||
|
||||
t.deepEqual(emitted, [
|
||||
{ name: 'stripe.invoice.paid', payload: { id: 'in_1' } },
|
||||
]);
|
||||
t.like(findManyInput, {
|
||||
where: {
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{ processingStatus: 'processing' },
|
||||
],
|
||||
},
|
||||
});
|
||||
t.deepEqual((updates[0] as { data: unknown }).data, {
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
});
|
||||
t.like((updates[1] as { data: unknown }).data, {
|
||||
processingStatus: 'processed',
|
||||
lastError: null,
|
||||
});
|
||||
t.true(
|
||||
(updates[1] as { data: { processedAt: Date } }).data.processedAt instanceof
|
||||
Date
|
||||
);
|
||||
});
|
||||
|
||||
test('stripe webhook replay job keeps failed events retryable', async t => {
|
||||
const updates: unknown[] = [];
|
||||
const cron = new SubscriptionCronJobs(
|
||||
{
|
||||
paymentEvent: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 'payment_event_1',
|
||||
eventType: 'invoice.paid',
|
||||
metadata: { id: 'in_1' },
|
||||
},
|
||||
],
|
||||
updateMany: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return { count: 1 };
|
||||
},
|
||||
update: async (input: unknown) => {
|
||||
updates.push(input);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emitAsync: async () => {
|
||||
throw new Error('handler still failing');
|
||||
},
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as JobQueue,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
await cron.replayStripeWebhookEvents();
|
||||
|
||||
t.deepEqual(
|
||||
updates.map(update => (update as { data: unknown }).data),
|
||||
[
|
||||
{
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
},
|
||||
{
|
||||
processingStatus: 'failed',
|
||||
lastError: 'handler still failing',
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SubscriptionService } from '../../plugins/payment/service';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../plugins/payment/types';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
@@ -1084,3 +1085,40 @@ test('user subscriptions ignore active rows after their current period ended', a
|
||||
});
|
||||
t.is(activeAI, null);
|
||||
});
|
||||
|
||||
test('user subscriptions preserve provider trialing status', async t => {
|
||||
const { db, models, subResolver } = t.context;
|
||||
const trialUser = await models.user.create({
|
||||
email: `${Date.now()}-trial-status@affine.pro`,
|
||||
});
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: trialUser.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
provider: 'stripe',
|
||||
status: SubscriptionStatus.Trialing,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
start: new Date('2026-01-01T00:00:00.000Z'),
|
||||
end: new Date('2099-01-01T00:00:00.000Z'),
|
||||
stripeSubscriptionId: 'sub_trialing_status',
|
||||
},
|
||||
});
|
||||
await db.providerSubscription.create({
|
||||
data: {
|
||||
provider: 'stripe',
|
||||
targetType: 'user',
|
||||
targetId: trialUser.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Trialing,
|
||||
externalSubscriptionId: 'sub_trialing_status',
|
||||
periodStart: new Date('2026-01-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptions = await subResolver.subscriptions(trialUser, trialUser);
|
||||
|
||||
t.is(subscriptions[0]?.status, SubscriptionStatus.Trialing);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfigFactory, ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { RevenueCatService } from '../../plugins/payment/revenuecat';
|
||||
import { SubscriptionService } from '../../plugins/payment/service';
|
||||
import { StripeFactory } from '../../plugins/payment/stripe';
|
||||
import {
|
||||
@@ -135,6 +136,7 @@ const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
service: SubscriptionService;
|
||||
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||
revenueCat: Sinon.SinonStubbedInstance<RevenueCatService>;
|
||||
stripe: {
|
||||
customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>;
|
||||
prices: Sinon.SinonStubbedInstance<Stripe.PricesResource>;
|
||||
@@ -157,6 +159,11 @@ function getLastCheckoutPrice(checkoutStub: Sinon.SinonStub) {
|
||||
};
|
||||
}
|
||||
|
||||
function getLastCheckoutParams(checkoutStub: Sinon.SinonStub) {
|
||||
const call = checkoutStub.getCall(checkoutStub.callCount - 1);
|
||||
return call.args[0] as Stripe.Checkout.SessionCreateParams;
|
||||
}
|
||||
|
||||
test.before(async t => {
|
||||
const app = await createTestingApp({
|
||||
imports: [
|
||||
@@ -179,6 +186,7 @@ test.before(async t => {
|
||||
|
||||
t.context.event = app.get(EventBus);
|
||||
t.context.service = app.get(SubscriptionService);
|
||||
t.context.revenueCat = Sinon.stub(app.get(RevenueCatService));
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.app = app;
|
||||
|
||||
@@ -209,6 +217,9 @@ test.beforeEach(async t => {
|
||||
app.get(ConfigFactory).override({
|
||||
payment: {
|
||||
showLifetimePrice: true,
|
||||
revenuecat: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -240,6 +251,8 @@ test.beforeEach(async t => {
|
||||
|
||||
// @ts-expect-error stub
|
||||
stripe.subscriptions.list.resolves({ data: [] });
|
||||
// @ts-expect-error stub
|
||||
stripe.checkout.sessions.create.resolves({ id: 'cs_1' });
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
@@ -409,6 +422,90 @@ test('should allow checkout after local subscription period ended', async t => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should reject checkout when stripe already has current subscription', async t => {
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
{
|
||||
...sub,
|
||||
id: 'sub_pending_webhook',
|
||||
status: SubscriptionStatus.Active,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
// @ts-expect-error stub
|
||||
price: {
|
||||
lookup_key: PRO_YEARLY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
|
||||
t.false(stripe.checkout.sessions.create.called);
|
||||
});
|
||||
|
||||
test('should reject checkout when revenuecat already has active subscription', async t => {
|
||||
const { app, revenueCat, service, u1, stripe } = t.context;
|
||||
|
||||
app.get(ConfigFactory).override({
|
||||
payment: {
|
||||
revenuecat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revenueCat.getSubscriptions.resolves([
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date(),
|
||||
expirationDate: new Date(Date.now() + 100000),
|
||||
customerId: 'rc_customer',
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: 'P1Y',
|
||||
},
|
||||
]);
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
),
|
||||
{
|
||||
message:
|
||||
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
|
||||
}
|
||||
);
|
||||
|
||||
t.false(stripe.checkout.sessions.create.called);
|
||||
});
|
||||
|
||||
test('should get correct pro plan price for checking out', async t => {
|
||||
const { app, service, u1, stripe } = t.context;
|
||||
// monthly
|
||||
@@ -523,29 +620,15 @@ test('should get correct ai plan price for checking out', async t => {
|
||||
price: AI_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
t.is(
|
||||
getLastCheckoutParams(stripe.checkout.sessions.create).subscription_data
|
||||
?.trial_period_days,
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
// user with old subscription
|
||||
// user with recorded trial usage
|
||||
{
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
{
|
||||
id: 'sub_1',
|
||||
status: 'canceled',
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
// @ts-expect-error stub
|
||||
price: {
|
||||
lookup_key: AI_YEARLY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
@@ -559,9 +642,38 @@ test('should get correct ai plan price for checking out', async t => {
|
||||
price: AI_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
t.is(
|
||||
getLastCheckoutParams(stripe.checkout.sessions.create).subscription_data
|
||||
?.trial_period_days,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should record AI trial usage when checkout grants trial', async t => {
|
||||
const { db, service, u1 } = t.context;
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
const usage = await db.subscriptionTrialUsage.findUnique({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: u1.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
});
|
||||
t.is(usage?.externalRef, 'cs_1');
|
||||
});
|
||||
|
||||
test('should apply user coupon for checking out', async t => {
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
@@ -610,6 +722,22 @@ test('should be able to create subscription', async t => {
|
||||
})
|
||||
);
|
||||
t.is(subInDB?.stripeSubscriptionId, sub.id);
|
||||
|
||||
const providerFact = await db.providerSubscription.findUnique({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: 'stripe',
|
||||
externalSubscriptionId: sub.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
t.like(providerFact, {
|
||||
targetType: 'user',
|
||||
targetId: u1.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to update subscription', async t => {
|
||||
@@ -640,6 +768,49 @@ test('should be able to update subscription', async t => {
|
||||
t.is(subInDB?.canceledAt?.getTime(), canceledAt * 1000);
|
||||
});
|
||||
|
||||
test('should replace old subscription row when stripe creates a new subscription for the same plan', async t => {
|
||||
const { service, db, u1 } = t.context;
|
||||
|
||||
const old = await db.subscription.create({
|
||||
data: {
|
||||
targetId: u1.id,
|
||||
stripeSubscriptionId: 'sub_old',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Canceled,
|
||||
start: new Date('2026-03-26T08:23:57.000Z'),
|
||||
end: new Date('2027-03-26T08:23:57.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await service.saveStripeSubscription({
|
||||
...sub,
|
||||
id: 'sub_new',
|
||||
status: SubscriptionStatus.Active,
|
||||
items: {
|
||||
...sub.items,
|
||||
data: [
|
||||
{
|
||||
...sub.items.data[0],
|
||||
// @ts-expect-error stub
|
||||
price: {
|
||||
lookup_key: PRO_YEARLY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptions = await db.subscription.findMany({
|
||||
where: { targetId: u1.id, plan: SubscriptionPlan.Pro },
|
||||
});
|
||||
|
||||
t.is(subscriptions.length, 1);
|
||||
t.is(subscriptions[0].id, old.id);
|
||||
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new');
|
||||
t.is(subscriptions[0].status, SubscriptionStatus.Active);
|
||||
});
|
||||
|
||||
test('should be able to delete subscription', async t => {
|
||||
const { event, service, db, u1 } = t.context;
|
||||
await service.saveStripeSubscription(sub);
|
||||
@@ -662,6 +833,19 @@ test('should be able to delete subscription', async t => {
|
||||
});
|
||||
|
||||
t.is(subInDB, null);
|
||||
t.like(
|
||||
await db.providerSubscription.findUnique({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: 'stripe',
|
||||
externalSubscriptionId: sub.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: SubscriptionStatus.Canceled,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to cancel subscription', async t => {
|
||||
@@ -1118,6 +1302,23 @@ test('should be able to subscribe to lifetime recurring', async t => {
|
||||
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||
t.is(subInDB?.stripeSubscriptionId, null);
|
||||
|
||||
const paymentFact = await db.paymentEvent.findUnique({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: 'stripe',
|
||||
externalEventId: `stripe_invoice:${lifetimeInvoice.id}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
t.like(paymentFact, {
|
||||
targetType: 'user',
|
||||
targetId: u1.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
amount: lifetimeInvoice.total,
|
||||
currency: lifetimeInvoice.currency,
|
||||
processingStatus: 'processed',
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to subscribe to lifetime recurring with old subscription', async t => {
|
||||
|
||||
@@ -176,6 +176,31 @@ function createYjsUpdateBase64() {
|
||||
return Buffer.from(update).toString('base64');
|
||||
}
|
||||
|
||||
async function createSnapshot(
|
||||
db: PrismaClient,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
blob?: Buffer;
|
||||
state?: Buffer;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
) {
|
||||
await db.snapshot.create({
|
||||
data: {
|
||||
id: input.docId,
|
||||
workspaceId: input.workspaceId,
|
||||
blob: input.blob ?? Buffer.from([1, 1]),
|
||||
state: input.state ?? Buffer.from([1, 1]),
|
||||
createdAt: input.updatedAt ?? new Date(),
|
||||
updatedAt: input.updatedAt ?? new Date(),
|
||||
createdBy: input.userId,
|
||||
updatedBy: input.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSyncActiveUsersTable(db: PrismaClient) {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||
@@ -612,17 +637,10 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
|
||||
}
|
||||
);
|
||||
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
|
||||
await db.snapshot.create({
|
||||
data: {
|
||||
id: docId,
|
||||
workspaceId: workspace.id,
|
||||
blob: Buffer.from([1, 1]),
|
||||
state: Buffer.from([1, 1]),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: owner.id,
|
||||
updatedBy: owner.id,
|
||||
},
|
||||
await createSnapshot(db, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
@@ -657,3 +675,206 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace sync load-doc should enforce doc read permissions', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
const models = app.get(Models);
|
||||
const { user: owner } = await login(app);
|
||||
const { user: collaborator, cookieHeader } = await login(app);
|
||||
const workspace = await models.workspace.create(owner.id);
|
||||
const docId = 'private-load-doc';
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
|
||||
await createSnapshot(db, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const join = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join',
|
||||
{
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
clientVersion: '0.26.0',
|
||||
}
|
||||
)
|
||||
);
|
||||
t.true(join.success);
|
||||
|
||||
const error = getErrorResponse(
|
||||
t,
|
||||
await emitWithAck(socket, 'space:load-doc', {
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
docId,
|
||||
})
|
||||
);
|
||||
t.true(error.message.includes('Doc.Read'));
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace sync push-doc-update should enforce doc update permissions', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
const models = app.get(Models);
|
||||
const { user: owner } = await login(app);
|
||||
const { user: collaborator, cookieHeader } = await login(app);
|
||||
const workspace = await models.workspace.create(owner.id);
|
||||
const docId = 'readonly-push-doc';
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
|
||||
await models.docUser.set(
|
||||
workspace.id,
|
||||
docId,
|
||||
collaborator.id,
|
||||
DocRole.Reader
|
||||
);
|
||||
await createSnapshot(db, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const join = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join',
|
||||
{
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
clientVersion: '0.26.0',
|
||||
}
|
||||
)
|
||||
);
|
||||
t.true(join.success);
|
||||
|
||||
const error = getErrorResponse(
|
||||
t,
|
||||
await emitWithAck(socket, 'space:push-doc-update', {
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
docId,
|
||||
update: createYjsUpdateBase64(),
|
||||
})
|
||||
);
|
||||
t.true(error.message.includes('Doc.Update'));
|
||||
|
||||
const updates = await db.update.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
id: docId,
|
||||
},
|
||||
});
|
||||
t.is(updates, 0);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace sync load-doc-timestamps should filter unreadable docs', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
const models = app.get(Models);
|
||||
const { user: owner } = await login(app);
|
||||
const { user: collaborator, cookieHeader } = await login(app);
|
||||
const workspace = await models.workspace.create(owner.id);
|
||||
const privateDocId = 'private-timestamp-doc';
|
||||
const readableDocId = 'readable-timestamp-doc';
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
collaborator.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.doc.setDefaultRole(workspace.id, privateDocId, DocRole.None);
|
||||
await models.doc.setDefaultRole(workspace.id, readableDocId, DocRole.None);
|
||||
await models.docUser.set(
|
||||
workspace.id,
|
||||
readableDocId,
|
||||
collaborator.id,
|
||||
DocRole.Reader
|
||||
);
|
||||
await createSnapshot(db, {
|
||||
workspaceId: workspace.id,
|
||||
docId: privateDocId,
|
||||
userId: owner.id,
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
await createSnapshot(db, {
|
||||
workspaceId: workspace.id,
|
||||
docId: readableDocId,
|
||||
userId: owner.id,
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const join = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join',
|
||||
{
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
clientVersion: '0.26.0',
|
||||
}
|
||||
)
|
||||
);
|
||||
t.true(join.success);
|
||||
|
||||
const timestamps = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<Record<string, number>>(
|
||||
socket,
|
||||
'space:load-doc-timestamps',
|
||||
{
|
||||
spaceType: 'workspace',
|
||||
spaceId: workspace.id,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
t.false(privateDocId in timestamps);
|
||||
t.true(readableDocId in timestamps);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,3 +133,43 @@ test('checker reports legal legacy facts missing entitlements', async t => {
|
||||
t.is(report.cloudSubscriptionEntitlementMissing, 1);
|
||||
t.is(report.selfhostLicenseEntitlementMissing, 1);
|
||||
});
|
||||
|
||||
test('checker reports provider facts missing entitlements', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.db.providerSubscription.create({
|
||||
data: {
|
||||
provider: 'stripe',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
externalSubscriptionId: 'sub_provider_without_entitlement',
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.providerActiveEntitlementMissing, 1);
|
||||
});
|
||||
|
||||
test('checker reports entitlements missing active provider facts', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
stripeSubscriptionId: 'sub_entitlement_without_active_provider',
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.entitlementProviderMissing, 1);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,10 @@ test('projects user entitlement to legacy user features and subscriptions', asyn
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
|
||||
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
|
||||
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
|
||||
@@ -95,6 +99,10 @@ test('projects workspace entitlement and readonly state to legacy workspace feat
|
||||
status: 'active',
|
||||
quantity: 8,
|
||||
});
|
||||
await t.context.projection.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: workspace.id,
|
||||
});
|
||||
|
||||
const teamFeature = await t.context.models.workspaceFeature.get(
|
||||
workspace.id,
|
||||
@@ -296,6 +304,135 @@ test('backfill removes dangling legacy subscriptions and entitlements', async t
|
||||
t.is(await t.context.db.entitlement.count(), 0);
|
||||
});
|
||||
|
||||
test('shadow backfill preserves legacy rows and records provider facts', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const paidAiUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
const danglingTargetId = randomUUID();
|
||||
|
||||
await t.context.db.subscription.createMany({
|
||||
data: [
|
||||
{
|
||||
targetId: user.id,
|
||||
stripeSubscriptionId: 'sub_ai_trial',
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date('2026-01-01T00:00:00.000Z'),
|
||||
trialStart: new Date('2026-01-01T00:00:00.000Z'),
|
||||
trialEnd: new Date('2026-01-08T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
targetId: paidAiUser.id,
|
||||
stripeSubscriptionId: 'sub_ai_paid',
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
targetId: danglingTargetId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
await t.context.db.invoice.create({
|
||||
data: {
|
||||
stripeInvoiceId: 'in_backfill_lifetime',
|
||||
targetId: user.id,
|
||||
currency: 'usd',
|
||||
amount: 9999,
|
||||
status: 'paid',
|
||||
reason: 'subscription_create',
|
||||
},
|
||||
});
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'shadow-license-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'shadow-validate-key',
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.projection.shadowBackfillEntitlementsAndQuotaStates();
|
||||
|
||||
t.truthy(
|
||||
await t.context.db.subscription.findFirst({
|
||||
where: { targetId: danglingTargetId },
|
||||
})
|
||||
);
|
||||
t.like(
|
||||
await t.context.db.providerSubscription.findUnique({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: 'stripe',
|
||||
externalSubscriptionId: 'sub_ai_trial',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
status: SubscriptionStatus.Active,
|
||||
}
|
||||
);
|
||||
t.truthy(
|
||||
await t.context.db.subscriptionTrialUsage.findUnique({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
t.falsy(
|
||||
await t.context.db.subscriptionTrialUsage.findUnique({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: paidAiUser.id,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
t.like(
|
||||
await t.context.db.paymentEvent.findUnique({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: 'stripe',
|
||||
externalEventId: 'stripe_invoice:in_backfill_lifetime',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
targetId: user.id,
|
||||
externalInvoiceId: 'in_backfill_lifetime',
|
||||
amount: 9999,
|
||||
processingStatus: 'processed',
|
||||
}
|
||||
);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1')
|
||||
);
|
||||
});
|
||||
|
||||
test('key based selfhost entitlements without raw payload need reupload', async t => {
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
|
||||
@@ -16,6 +16,8 @@ export class EntitlementProjectionChecker {
|
||||
selfhostLicenseProjectionMissing,
|
||||
cloudSubscriptionEntitlementMissing,
|
||||
selfhostLicenseEntitlementMissing,
|
||||
providerActiveEntitlementMissing,
|
||||
entitlementProviderMissing,
|
||||
dirtyLegacyUserFeatures,
|
||||
dirtyLegacyWorkspaceFeatures,
|
||||
missingUserFeatureProjection,
|
||||
@@ -41,6 +43,8 @@ export class EntitlementProjectionChecker {
|
||||
this.selfhostLicenseProjectionMissing(),
|
||||
this.cloudSubscriptionEntitlementMissing(),
|
||||
this.selfhostLicenseEntitlementMissing(),
|
||||
this.providerActiveEntitlementMissing(),
|
||||
this.entitlementProviderMissing(),
|
||||
this.dirtyLegacyUserFeatures(),
|
||||
this.dirtyLegacyWorkspaceFeatures(),
|
||||
this.missingUserFeatureProjection(),
|
||||
@@ -56,6 +60,8 @@ export class EntitlementProjectionChecker {
|
||||
selfhostLicenseProjectionMissing,
|
||||
cloudSubscriptionEntitlementMissing,
|
||||
selfhostLicenseEntitlementMissing,
|
||||
providerActiveEntitlementMissing,
|
||||
entitlementProviderMissing,
|
||||
dirtyLegacyUserFeatures,
|
||||
dirtyLegacyWorkspaceFeatures,
|
||||
missingUserFeatureProjection,
|
||||
@@ -147,6 +153,39 @@ export class EntitlementProjectionChecker {
|
||||
return licenses.filter(license => !validKeys.has(license.key)).length;
|
||||
}
|
||||
|
||||
private async providerActiveEntitlementMissing() {
|
||||
const activeProviderKeys = await this.activeProviderSubscriptionKeys();
|
||||
const valid = new Set(
|
||||
(
|
||||
await this.validEntitlements({
|
||||
source: 'cloud_subscription',
|
||||
})
|
||||
).map(
|
||||
entitlement =>
|
||||
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
|
||||
)
|
||||
);
|
||||
|
||||
return activeProviderKeys.filter(key => !valid.has(key)).length;
|
||||
}
|
||||
|
||||
private async entitlementProviderMissing() {
|
||||
const activeProviderKeys = new Set(
|
||||
await this.activeProviderSubscriptionKeys()
|
||||
);
|
||||
const entitlements = await this.validEntitlements({
|
||||
source: 'cloud_subscription',
|
||||
});
|
||||
|
||||
return entitlements.filter(
|
||||
entitlement =>
|
||||
entitlement.targetId &&
|
||||
!activeProviderKeys.has(
|
||||
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
|
||||
)
|
||||
).length;
|
||||
}
|
||||
|
||||
private async dirtyLegacyUserFeatures() {
|
||||
const rows = await this.db.userFeature.findMany({
|
||||
where: {
|
||||
@@ -287,4 +326,22 @@ export class EntitlementProjectionChecker {
|
||||
private subscriptionPlan(plan: string) {
|
||||
return plan === 'lifetime_pro' ? 'pro' : plan;
|
||||
}
|
||||
|
||||
private async activeProviderSubscriptionKeys() {
|
||||
const now = new Date();
|
||||
const subscriptions = await this.db.providerSubscription.findMany({
|
||||
where: {
|
||||
status: { in: ['active', 'trialing', 'past_due'] },
|
||||
OR: [{ periodEnd: null }, { periodEnd: { gt: now } }],
|
||||
},
|
||||
select: {
|
||||
targetId: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
return subscriptions.map(
|
||||
subscription => `${subscription.targetId}:${subscription.plan}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Entitlement, PrismaClient } from '@prisma/client';
|
||||
import { Entitlement, IapStore, PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
@@ -52,44 +52,65 @@ export class LegacyEntitlementProjectionService {
|
||||
await this.#projectReadonlyFeature(workspaceId);
|
||||
}
|
||||
|
||||
async scanInstalledLicenses() {
|
||||
async scanInstalledLicenses(options: { emit?: boolean } = {}) {
|
||||
const licenses = await this.db.installedLicense.findMany();
|
||||
const emit = options.emit ?? true;
|
||||
|
||||
await Promise.all(
|
||||
licenses.map(async license =>
|
||||
license.license
|
||||
? await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
license: Buffer.from(license.license),
|
||||
})
|
||||
: license.validateKey
|
||||
? await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
? await this.entitlement.upsertFromSelfhostLicense(
|
||||
{
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
validateKey: license.validateKey,
|
||||
variant: license.variant,
|
||||
})
|
||||
: await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
reason: 'Installed license has no raw payload to verify.',
|
||||
})
|
||||
license: Buffer.from(license.license),
|
||||
},
|
||||
{ emit }
|
||||
)
|
||||
: license.validateKey
|
||||
? await this.entitlement.upsertFromValidatedSelfhostLicense(
|
||||
{
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: license.recurring,
|
||||
quantity: license.quantity,
|
||||
expiresAt: license.expiredAt,
|
||||
validatedAt: license.validatedAt,
|
||||
validateKey: license.validateKey,
|
||||
variant: license.variant,
|
||||
},
|
||||
{ emit }
|
||||
)
|
||||
: await this.entitlement.markSelfhostLicenseNeedsReupload(
|
||||
{
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
reason: 'Installed license has no raw payload to verify.',
|
||||
},
|
||||
{ emit }
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async backfillEntitlementsAndQuotaStates() {
|
||||
await this.#cleanupDanglingLegacyEntitlements();
|
||||
await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: true });
|
||||
}
|
||||
|
||||
async shadowBackfillEntitlementsAndQuotaStates() {
|
||||
await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: false });
|
||||
}
|
||||
|
||||
async #backfillEntitlementsAndQuotaStates({
|
||||
cleanupLegacy,
|
||||
}: {
|
||||
cleanupLegacy: boolean;
|
||||
}) {
|
||||
const [subscriptions, users, workspaces] = await Promise.all([
|
||||
this.db.subscription.findMany(),
|
||||
this.db.user.findMany({ select: { id: true } }),
|
||||
@@ -101,17 +122,31 @@ export class LegacyEntitlementProjectionService {
|
||||
continue;
|
||||
}
|
||||
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
|
||||
await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||
licenseKey: subscription.targetId,
|
||||
reason:
|
||||
'Historical self-hosted team subscription needs license activation or revalidation.',
|
||||
});
|
||||
await this.entitlement.markSelfhostLicenseNeedsReupload(
|
||||
{
|
||||
licenseKey: subscription.targetId,
|
||||
reason:
|
||||
'Historical self-hosted team subscription needs license activation or revalidation.',
|
||||
},
|
||||
{ emit: cleanupLegacy }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription, {
|
||||
emit: cleanupLegacy,
|
||||
legacySync: true,
|
||||
});
|
||||
await this.#backfillProviderSubscription(subscription);
|
||||
if (
|
||||
subscription.plan === SubscriptionPlan.AI &&
|
||||
(subscription.trialStart || subscription.trialEnd)
|
||||
) {
|
||||
await this.#backfillTrialUsage(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanInstalledLicenses();
|
||||
await this.#backfillPaymentEvents();
|
||||
await this.scanInstalledLicenses({ emit: cleanupLegacy });
|
||||
|
||||
await Promise.all([
|
||||
...users.map(user =>
|
||||
@@ -153,6 +188,206 @@ export class LegacyEntitlementProjectionService {
|
||||
]);
|
||||
}
|
||||
|
||||
async #backfillProviderSubscription(subscription: {
|
||||
targetId: string;
|
||||
plan: string;
|
||||
recurring: string;
|
||||
status: string;
|
||||
provider: Provider | string;
|
||||
iapStore?: IapStore | null;
|
||||
rcExternalRef?: string | null;
|
||||
rcProductId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
quantity: number;
|
||||
start: Date;
|
||||
end?: Date | null;
|
||||
trialStart?: Date | null;
|
||||
trialEnd?: Date | null;
|
||||
canceledAt?: Date | null;
|
||||
}) {
|
||||
const targetType =
|
||||
subscription.plan === SubscriptionPlan.Team ? 'workspace' : 'user';
|
||||
if (
|
||||
subscription.provider === 'stripe' &&
|
||||
subscription.stripeSubscriptionId
|
||||
) {
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: 'stripe',
|
||||
externalSubscriptionId: subscription.stripeSubscriptionId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType,
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
recurring: subscription.recurring,
|
||||
status: subscription.status,
|
||||
quantity: subscription.quantity,
|
||||
periodStart: subscription.start,
|
||||
periodEnd: subscription.end,
|
||||
trialStart: subscription.trialStart,
|
||||
trialEnd: subscription.trialEnd,
|
||||
canceledAt: subscription.canceledAt,
|
||||
metadata: { legacySync: true },
|
||||
},
|
||||
create: {
|
||||
provider: 'stripe',
|
||||
targetType,
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
recurring: subscription.recurring,
|
||||
status: subscription.status,
|
||||
externalSubscriptionId: subscription.stripeSubscriptionId,
|
||||
quantity: subscription.quantity,
|
||||
periodStart: subscription.start,
|
||||
periodEnd: subscription.end,
|
||||
trialStart: subscription.trialStart,
|
||||
trialEnd: subscription.trialEnd,
|
||||
canceledAt: subscription.canceledAt,
|
||||
metadata: { legacySync: true },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
subscription.provider === 'revenuecat' &&
|
||||
subscription.iapStore &&
|
||||
subscription.rcExternalRef &&
|
||||
subscription.rcProductId
|
||||
) {
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_iapStore_externalRef_externalProductId_externalCustomerId: {
|
||||
provider: 'revenuecat',
|
||||
iapStore: subscription.iapStore,
|
||||
externalRef: subscription.rcExternalRef,
|
||||
externalProductId: subscription.rcProductId,
|
||||
externalCustomerId: subscription.targetId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType,
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
recurring: subscription.recurring,
|
||||
status: subscription.status,
|
||||
quantity: subscription.quantity,
|
||||
periodStart: subscription.start,
|
||||
periodEnd: subscription.end,
|
||||
trialStart: subscription.trialStart,
|
||||
trialEnd: subscription.trialEnd,
|
||||
canceledAt: subscription.canceledAt,
|
||||
metadata: { legacySync: true },
|
||||
},
|
||||
create: {
|
||||
provider: 'revenuecat',
|
||||
targetType,
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
recurring: subscription.recurring,
|
||||
status: subscription.status,
|
||||
externalCustomerId: subscription.targetId,
|
||||
iapStore: subscription.iapStore,
|
||||
externalRef: subscription.rcExternalRef,
|
||||
externalProductId: subscription.rcProductId,
|
||||
quantity: subscription.quantity,
|
||||
periodStart: subscription.start,
|
||||
periodEnd: subscription.end,
|
||||
trialStart: subscription.trialStart,
|
||||
trialEnd: subscription.trialEnd,
|
||||
canceledAt: subscription.canceledAt,
|
||||
metadata: { legacySync: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #backfillTrialUsage(subscription: {
|
||||
targetId: string;
|
||||
plan: string;
|
||||
provider: Provider | string;
|
||||
stripeSubscriptionId?: string | null;
|
||||
rcExternalRef?: string | null;
|
||||
trialStart?: Date | null;
|
||||
trialEnd?: Date | null;
|
||||
start: Date;
|
||||
}) {
|
||||
await this.db.subscriptionTrialUsage.upsert({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
targetType: 'user',
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
provider:
|
||||
subscription.provider === 'revenuecat' ? 'revenuecat' : 'stripe',
|
||||
externalRef:
|
||||
subscription.stripeSubscriptionId ??
|
||||
subscription.rcExternalRef ??
|
||||
null,
|
||||
firstUsedAt:
|
||||
subscription.trialStart ??
|
||||
subscription.trialEnd ??
|
||||
subscription.start,
|
||||
metadata: { legacySync: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #backfillPaymentEvents() {
|
||||
const invoices = await this.db.invoice.findMany();
|
||||
|
||||
for (const invoice of invoices) {
|
||||
await this.db.paymentEvent.upsert({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: 'stripe',
|
||||
externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetId: invoice.targetId,
|
||||
externalInvoiceId: invoice.stripeInvoiceId,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
processingStatus: 'processed',
|
||||
processedAt: invoice.updatedAt,
|
||||
metadata: {
|
||||
legacySync: true,
|
||||
status: invoice.status,
|
||||
reason: invoice.reason,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
provider: 'stripe',
|
||||
eventType: 'invoice.backfill',
|
||||
externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`,
|
||||
targetId: invoice.targetId,
|
||||
externalInvoiceId: invoice.stripeInvoiceId,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
occurredAt: invoice.createdAt,
|
||||
processingStatus: 'processed',
|
||||
processedAt: invoice.updatedAt,
|
||||
metadata: {
|
||||
legacySync: true,
|
||||
status: invoice.status,
|
||||
reason: invoice.reason,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #cleanupDanglingLegacyEntitlements() {
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM entitlements entitlement
|
||||
@@ -220,6 +455,7 @@ export class LegacyEntitlementProjectionService {
|
||||
}
|
||||
|
||||
async #projectUserFeatures(userId: string) {
|
||||
// TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone.
|
||||
const entitlements = await this.#activeEntitlements('user', userId);
|
||||
const quotaEntitlement = entitlements.find(entitlement =>
|
||||
['lifetime_pro', 'pro'].includes(entitlement.plan)
|
||||
@@ -262,6 +498,7 @@ export class LegacyEntitlementProjectionService {
|
||||
}
|
||||
|
||||
async #projectWorkspaceFeatures(workspaceId: string) {
|
||||
// TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone.
|
||||
const [entitlement, resolved] = await Promise.all([
|
||||
this.entitlement.getBestEntitlement('workspace', workspaceId),
|
||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||
@@ -290,6 +527,7 @@ export class LegacyEntitlementProjectionService {
|
||||
targetType: 'user' | 'workspace',
|
||||
targetId: string
|
||||
) {
|
||||
// TODO(stable-upgrade): remove reverse projection after stable no longer depends on old subscriptions.
|
||||
if (env.selfhosted) return;
|
||||
const entitlements = await this.db.entitlement.findMany({
|
||||
where: {
|
||||
|
||||
@@ -298,6 +298,7 @@ export class EntitlementService {
|
||||
targetType: TargetType,
|
||||
targetId: string
|
||||
) {
|
||||
// TODO(stable-upgrade): remove legacy subscription import after stable no longer writes old subscriptions.
|
||||
if (env.selfhosted || targetType === 'instance') {
|
||||
return;
|
||||
}
|
||||
@@ -324,7 +325,11 @@ export class EntitlementService {
|
||||
return task;
|
||||
}
|
||||
|
||||
async upsertFromSelfhostLicense(input: SelfhostLicenseEntitlementInput) {
|
||||
async upsertFromSelfhostLicense(
|
||||
input: SelfhostLicenseEntitlementInput,
|
||||
options: { emit?: boolean } = {}
|
||||
) {
|
||||
const emit = options.emit ?? true;
|
||||
const resolved = input.license
|
||||
? resolveEntitlementV1({
|
||||
deploymentType: 'selfhosted',
|
||||
@@ -372,12 +377,16 @@ export class EntitlementService {
|
||||
where: { id: entitlement.id },
|
||||
data,
|
||||
});
|
||||
await this.emitEntitlementChanged(updated);
|
||||
if (emit) {
|
||||
await this.emitEntitlementChanged(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
const created = await this.db.entitlement.create({ data });
|
||||
await this.emitEntitlementChanged(created);
|
||||
if (emit) {
|
||||
await this.emitEntitlementChanged(created);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -385,8 +394,10 @@ export class EntitlementService {
|
||||
input: Omit<SelfhostLicenseEntitlementInput, 'license'> & {
|
||||
licenseKey: string;
|
||||
quantity: number;
|
||||
}
|
||||
},
|
||||
options: { emit?: boolean } = {}
|
||||
) {
|
||||
const emit = options.emit ?? true;
|
||||
const entitlement = await this.findBySubject(
|
||||
'selfhost_license',
|
||||
input.licenseKey
|
||||
@@ -415,20 +426,28 @@ export class EntitlementService {
|
||||
where: { id: entitlement.id },
|
||||
data,
|
||||
});
|
||||
await this.emitEntitlementChanged(updated);
|
||||
if (emit) {
|
||||
await this.emitEntitlementChanged(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
const created = await this.db.entitlement.create({ data });
|
||||
await this.emitEntitlementChanged(created);
|
||||
if (emit) {
|
||||
await this.emitEntitlementChanged(created);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
async markSelfhostLicenseNeedsReupload(input: {
|
||||
workspaceId?: string;
|
||||
licenseKey: string;
|
||||
reason: string;
|
||||
}) {
|
||||
async markSelfhostLicenseNeedsReupload(
|
||||
input: {
|
||||
workspaceId?: string;
|
||||
licenseKey: string;
|
||||
reason: string;
|
||||
},
|
||||
options: { emit?: boolean } = {}
|
||||
) {
|
||||
const emit = options.emit ?? true;
|
||||
const entitlement = await this.findBySubject(
|
||||
'selfhost_license',
|
||||
input.licenseKey
|
||||
@@ -454,12 +473,16 @@ export class EntitlementService {
|
||||
where: { id: entitlement.id },
|
||||
data,
|
||||
});
|
||||
await this.emitEntitlementChanged(updated);
|
||||
if (emit) {
|
||||
await this.emitEntitlementChanged(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
const created = await this.db.entitlement.create({ data });
|
||||
await this.emitEntitlementChanged(created);
|
||||
if (emit) {
|
||||
await this.emitEntitlementChanged(created);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
|
||||
@@ -524,3 +524,73 @@ test('should filter docs by Doc.Publish', async t => {
|
||||
|
||||
t.is(docs3.length, 0);
|
||||
});
|
||||
|
||||
test('legacy duplicate doc owner grants do not block projection', async t => {
|
||||
const owner = await module.create(Mockers.User);
|
||||
const secondOwner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
const docId = randomUUID();
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_pages (
|
||||
workspace_id,
|
||||
page_id,
|
||||
public,
|
||||
"defaultRole"
|
||||
)
|
||||
VALUES (${workspace.id}, ${docId}, false, ${DocRole.Manager})
|
||||
`;
|
||||
await resetProjection(workspace.id);
|
||||
|
||||
await db.$transaction(async tx => {
|
||||
await tx.$executeRaw`
|
||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO workspace_page_user_permissions (
|
||||
workspace_id,
|
||||
page_id,
|
||||
user_id,
|
||||
type,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${docId},
|
||||
${owner.id},
|
||||
${DocRole.Owner},
|
||||
${new Date('2026-01-02T00:00:00Z')}
|
||||
)
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO workspace_page_user_permissions (
|
||||
workspace_id,
|
||||
page_id,
|
||||
user_id,
|
||||
type,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${docId},
|
||||
${secondOwner.id},
|
||||
${DocRole.Owner},
|
||||
${new Date('2026-01-01T00:00:00Z')}
|
||||
)
|
||||
`;
|
||||
});
|
||||
|
||||
await models.permissionProjection.backfillLegacyProjection();
|
||||
|
||||
const projectedOwners = await db.$queryRaw<{ principalId: string }[]>`
|
||||
SELECT principal_id AS "principalId"
|
||||
FROM doc_grants
|
||||
WHERE workspace_id = ${workspace.id}
|
||||
AND doc_id = ${docId}
|
||||
AND role = 'owner'
|
||||
`;
|
||||
|
||||
t.deepEqual(projectedOwners, [{ principalId: secondOwner.id }]);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,10 @@ export class QuotaStateService {
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
async reconcileUserQuotaState(userId: string) {
|
||||
async reconcileUserQuotaState(
|
||||
userId: string,
|
||||
options: { emit?: boolean } = {}
|
||||
) {
|
||||
const [previous, entitlement, entitlements, resolved, usedStorageQuota] =
|
||||
await Promise.all([
|
||||
this.db.effectiveUserQuotaState.findUnique({ where: { userId } }),
|
||||
@@ -72,13 +75,16 @@ export class QuotaStateService {
|
||||
staleAfter: this.staleAfter(now),
|
||||
},
|
||||
});
|
||||
if (this.userQuotaStateChanged(previous, state)) {
|
||||
if ((options.emit ?? true) && this.userQuotaStateChanged(previous, state)) {
|
||||
await this.event.emitAsync('user.quota_state.changed', { userId });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async reconcileWorkspaceQuotaState(workspaceId: string) {
|
||||
async reconcileWorkspaceQuotaState(
|
||||
workspaceId: string,
|
||||
options: { emit?: boolean } = {}
|
||||
) {
|
||||
const owner = await this.getWorkspaceOwner(workspaceId);
|
||||
const [
|
||||
previous,
|
||||
@@ -98,7 +104,7 @@ export class QuotaStateService {
|
||||
const usesOwnerQuota = !this.hasStandaloneWorkspaceQuota(resolved.plan);
|
||||
const [ownerState, ownerEntitlement] = usesOwnerQuota
|
||||
? await Promise.all([
|
||||
this.reconcileUserQuotaState(owner.id),
|
||||
this.reconcileUserQuotaState(owner.id, options),
|
||||
this.entitlement.resolveUserEntitlement(owner.id),
|
||||
])
|
||||
: [null, null];
|
||||
@@ -156,7 +162,10 @@ export class QuotaStateService {
|
||||
staleAfter: this.staleAfter(now),
|
||||
},
|
||||
});
|
||||
if (this.workspaceQuotaStateChanged(previous, state)) {
|
||||
if (
|
||||
(options.emit ?? true) &&
|
||||
this.workspaceQuotaStateChanged(previous, state)
|
||||
) {
|
||||
await this.event.emitAsync('workspace.quota_state.changed', {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
@@ -633,6 +633,7 @@ export class SpaceSyncGateway
|
||||
@SubscribeMessage('space:load-doc')
|
||||
async onLoadSpaceDoc(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody()
|
||||
{ spaceType, spaceId, docId, stateVector }: LoadDocMessage
|
||||
): Promise<
|
||||
@@ -641,6 +642,13 @@ export class SpaceSyncGateway
|
||||
const id = new DocID(docId, spaceId);
|
||||
const adapter = this.selectAdapter(client, spaceType);
|
||||
adapter.assertIn(spaceId);
|
||||
await this.assertDocActionAllowed(
|
||||
spaceType,
|
||||
user.id,
|
||||
spaceId,
|
||||
id.guid,
|
||||
'Doc.Read'
|
||||
);
|
||||
|
||||
const doc = await adapter.diff(
|
||||
spaceId,
|
||||
@@ -666,7 +674,7 @@ export class SpaceSyncGateway
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody() { spaceType, spaceId, docId }: DeleteDocMessage
|
||||
) {
|
||||
): Promise<EventResponse<{ success: true }>> {
|
||||
const adapter = this.selectAdapter(client, spaceType);
|
||||
await this.assertDocActionAllowed(
|
||||
spaceType,
|
||||
@@ -676,6 +684,7 @@ export class SpaceSyncGateway
|
||||
'Doc.Delete'
|
||||
);
|
||||
await adapter.delete(spaceId, docId);
|
||||
return { data: { success: true } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -692,8 +701,13 @@ export class SpaceSyncGateway
|
||||
const adapter = this.selectAdapter(client, spaceType);
|
||||
|
||||
// Quota recovery mode is intentionally not applied to sync in this phase.
|
||||
// TODO(@forehalo): enable after frontend supporting doc revert
|
||||
// await this.ac.user(user.id).doc(spaceId, docId).assert('Doc.Update');
|
||||
await this.assertDocActionAllowed(
|
||||
spaceType,
|
||||
user.id,
|
||||
spaceId,
|
||||
docId,
|
||||
'Doc.Update'
|
||||
);
|
||||
const timestamp = await adapter.push(
|
||||
spaceId,
|
||||
docId,
|
||||
@@ -740,15 +754,32 @@ export class SpaceSyncGateway
|
||||
@SubscribeMessage('space:load-doc-timestamps')
|
||||
async onLoadDocTimestamps(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody()
|
||||
{ spaceType, spaceId, timestamp }: LoadDocTimestampsMessage
|
||||
): Promise<EventResponse<Record<string, number>>> {
|
||||
const adapter = this.selectAdapter(client, spaceType);
|
||||
|
||||
const stats = await adapter.getTimestamps(spaceId, timestamp);
|
||||
if (!stats || spaceType === SpaceType.Userspace) {
|
||||
return {
|
||||
data: stats ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
const readableDocs = await this.ac
|
||||
.user(user.id)
|
||||
.workspace(spaceId)
|
||||
.docs(
|
||||
Object.keys(stats).map(docId => ({ docId })),
|
||||
'Doc.Read'
|
||||
);
|
||||
const readableDocIds = new Set(readableDocs.map(doc => doc.docId));
|
||||
|
||||
return {
|
||||
data: stats ?? {},
|
||||
data: Object.fromEntries(
|
||||
Object.entries(stats).filter(([docId]) => readableDocIds.has(docId))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -9,7 +9,7 @@ export class BackfillEntitlementProjection1765600000000 {
|
||||
const projection = ref.get(LegacyEntitlementProjectionService, {
|
||||
strict: false,
|
||||
});
|
||||
await projection.backfillEntitlementsAndQuotaStates();
|
||||
await projection.shadowBackfillEntitlementsAndQuotaStates();
|
||||
|
||||
const quota = ref.get(QuotaStateService, { strict: false });
|
||||
const [users, workspaces] = await Promise.all([
|
||||
@@ -18,9 +18,12 @@ export class BackfillEntitlementProjection1765600000000 {
|
||||
]);
|
||||
|
||||
const tasks = [
|
||||
...users.map(user => () => quota.reconcileUserQuotaState(user.id)),
|
||||
...users.map(
|
||||
user => () => quota.reconcileUserQuotaState(user.id, { emit: false })
|
||||
),
|
||||
...workspaces.map(
|
||||
workspace => () => quota.reconcileWorkspaceQuotaState(workspace.id)
|
||||
workspace => () =>
|
||||
quota.reconcileWorkspaceQuotaState(workspace.id, { emit: false })
|
||||
),
|
||||
];
|
||||
const batchSize = 16;
|
||||
|
||||
@@ -268,6 +268,20 @@ export class PermissionProjectionModel extends BaseModel {
|
||||
`;
|
||||
|
||||
await tx.$executeRaw`
|
||||
WITH legacy_doc_grants AS (
|
||||
SELECT
|
||||
workspace_id,
|
||||
page_id,
|
||||
user_id,
|
||||
type,
|
||||
created_at,
|
||||
row_number() OVER (
|
||||
PARTITION BY workspace_id, page_id, affine_permission_legacy_doc_role(type)
|
||||
ORDER BY created_at ASC, user_id ASC
|
||||
) AS role_rank
|
||||
FROM workspace_page_user_permissions
|
||||
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
|
||||
)
|
||||
INSERT INTO doc_grants (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
@@ -291,8 +305,12 @@ export class PermissionProjectionModel extends BaseModel {
|
||||
user_id,
|
||||
created_at,
|
||||
now()
|
||||
FROM workspace_page_user_permissions
|
||||
FROM legacy_doc_grants
|
||||
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
|
||||
AND (
|
||||
affine_permission_legacy_doc_role(type) <> 'owner'
|
||||
OR role_rank = 1
|
||||
)
|
||||
ON CONFLICT (workspace_id, doc_id, principal_type, principal_id)
|
||||
DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RawBodyRequest } from '@nestjs/common';
|
||||
import { Controller, Logger, Post, Req } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient, Provider } from '@prisma/client';
|
||||
import type { Request } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config, EventBus, InternalServerError } from '../../base';
|
||||
import { Public } from '../../core/auth';
|
||||
@@ -12,6 +14,7 @@ export class StripeWebhookController {
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly stripeProvider: StripeFactory,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
@@ -33,14 +36,98 @@ export class StripeWebhookController {
|
||||
`[${event.id}] Stripe Webhook {${event.type}} received.`
|
||||
);
|
||||
|
||||
// Stripe requires responseing webhook immediately and handle event asynchronously.
|
||||
const existingPaymentEvent = await this.db.paymentEvent.findUnique({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: Provider.stripe,
|
||||
externalEventId: event.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingPaymentEvent?.processingStatus === 'processed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentEvent = existingPaymentEvent
|
||||
? await this.db.paymentEvent.update({
|
||||
where: { id: existingPaymentEvent.id },
|
||||
data: {
|
||||
eventType: event.type,
|
||||
lastError: null,
|
||||
metadata: event as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
: await this.db.paymentEvent.create({
|
||||
data: {
|
||||
provider: Provider.stripe,
|
||||
eventType: event.type,
|
||||
externalEventId: event.id,
|
||||
occurredAt: new Date(event.created * 1000),
|
||||
metadata: event as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (paymentEvent.processingStatus === 'processing') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stripe requires responding to webhooks immediately and handling events asynchronously.
|
||||
setImmediate(() => {
|
||||
this.event.emitAsync(`stripe.${event.type}` as any, event).catch(e => {
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
this.processEvent(paymentEvent.id, event).catch(e => {
|
||||
this.logger.error('Failed to persist Stripe Webhook failure.', e);
|
||||
});
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new InternalServerError(err.message);
|
||||
} catch (err: unknown) {
|
||||
throw new InternalServerError(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processEvent(id: string, event: Stripe.Event) {
|
||||
const stuckBefore = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const locked = await this.db.paymentEvent.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{
|
||||
processingStatus: 'processing',
|
||||
updatedAt: { lt: stuckBefore },
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (locked.count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.event.emitAsync(
|
||||
`stripe.${event.type}` as keyof Events,
|
||||
event as never
|
||||
);
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
processingStatus: 'failed',
|
||||
lastError: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
});
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ declare global {
|
||||
'nightly.reconcileRevenueCatSubscriptions': {};
|
||||
'nightly.reconcileStripeSubscriptions': {};
|
||||
'nightly.reconcileStripeRefunds': {};
|
||||
'nightly.replayStripeWebhookEvents': {};
|
||||
'nightly.revenuecat.syncUser': { userId: string };
|
||||
}
|
||||
}
|
||||
@@ -78,6 +79,12 @@ export class SubscriptionCronJobs {
|
||||
{ jobId: 'nightly-payment-reconcile-stripe-refunds' }
|
||||
);
|
||||
|
||||
await this.queue.add(
|
||||
'nightly.replayStripeWebhookEvents',
|
||||
{},
|
||||
{ jobId: 'nightly-payment-replay-stripe-webhook-events' }
|
||||
);
|
||||
|
||||
// FIXME(@forehalo): the strategy is totally wrong, for monthly plan. redesign required
|
||||
// await this.queue.add(
|
||||
// 'nightly.notifyAboutToExpireWorkspaceSubscriptions',
|
||||
@@ -219,6 +226,64 @@ export class SubscriptionCronJobs {
|
||||
await this.rcHandler.syncAppUser(payload.userId);
|
||||
}
|
||||
|
||||
@OnJob('nightly.replayStripeWebhookEvents')
|
||||
async replayStripeWebhookEvents() {
|
||||
const stuckBefore = new Date(Date.now() - OneHour);
|
||||
const events = await this.db.paymentEvent.findMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{ processingStatus: 'processing', updatedAt: { lt: stuckBefore } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
const locked = await this.db.paymentEvent.updateMany({
|
||||
where: {
|
||||
id: event.id,
|
||||
OR: [
|
||||
{ processingStatus: { in: ['pending', 'failed'] } },
|
||||
{ processingStatus: 'processing', updatedAt: { lt: stuckBefore } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
processingStatus: 'processing',
|
||||
processingAttempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (locked.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.event.emitAsync(
|
||||
`stripe.${event.eventType}` as keyof Events,
|
||||
event.metadata as never
|
||||
);
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
await this.db.paymentEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
processingStatus: 'failed',
|
||||
lastError: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('nightly.reconcileStripeSubscriptions')
|
||||
async reconcileStripeSubscriptions() {
|
||||
const stripe = this.stripeFactory.stripe;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LookupKey,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../types';
|
||||
import {
|
||||
activeSubscriptionWhere,
|
||||
@@ -129,8 +130,9 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
if (!existingSubscription) {
|
||||
const key = randomUUID();
|
||||
const [subscription] = await this.db.$transaction([
|
||||
const [saved] = await this.db.$transaction([
|
||||
this.db.subscription.create({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
data: {
|
||||
provider: Provider.stripe,
|
||||
targetId: key,
|
||||
@@ -148,9 +150,16 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
props: { license: key },
|
||||
});
|
||||
|
||||
return subscription;
|
||||
await this.upsertStripeProviderSubscription(
|
||||
key,
|
||||
subscription,
|
||||
subscriptionData
|
||||
);
|
||||
|
||||
return saved;
|
||||
} else {
|
||||
return this.db.subscription.update({
|
||||
const saved = await this.db.subscription.update({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
@@ -162,12 +171,30 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
'end',
|
||||
]),
|
||||
});
|
||||
await this.upsertStripeProviderSubscription(
|
||||
saved.targetId,
|
||||
subscription,
|
||||
subscriptionData
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteStripeSubscription({
|
||||
stripeSubscription,
|
||||
}: KnownStripeSubscription) {
|
||||
await this.db.providerSubscription.updateMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.Canceled,
|
||||
canceledAt: new Date(),
|
||||
periodEnd: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = await this.db.subscription.findFirst({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
@@ -248,4 +275,74 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
return invoiceData;
|
||||
}
|
||||
|
||||
private async upsertStripeProviderSubscription(
|
||||
targetId: string,
|
||||
known: KnownStripeSubscription,
|
||||
subscriptionData: Subscription
|
||||
) {
|
||||
const { lookupKey, stripeSubscription } = known;
|
||||
const price = stripeSubscription.items.data[0]?.price;
|
||||
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'instance',
|
||||
targetId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
targetType: 'instance',
|
||||
targetId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
Provider,
|
||||
UserStripeCustomer,
|
||||
} from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
@@ -17,6 +22,7 @@ import {
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { resolveProductMapping, RevenueCatService } from '../revenuecat';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
KnownStripeInvoice,
|
||||
@@ -33,13 +39,9 @@ import {
|
||||
CheckoutParams,
|
||||
Subscription,
|
||||
SubscriptionManager,
|
||||
visibleSubscriptionWhere,
|
||||
} from './common';
|
||||
|
||||
interface PriceStrategyStatus {
|
||||
proSubscribed: boolean;
|
||||
aiSubscribed: boolean;
|
||||
}
|
||||
|
||||
export const UserSubscriptionIdentity = z.object({
|
||||
plan: z.enum([SubscriptionPlan.Pro, SubscriptionPlan.AI]),
|
||||
userId: z.string(),
|
||||
@@ -54,6 +56,8 @@ export const UserSubscriptionCheckoutArgs = z.object({
|
||||
|
||||
@Injectable()
|
||||
export class UserSubscriptionManager extends SubscriptionManager {
|
||||
private readonly logger = new Logger(UserSubscriptionManager.name);
|
||||
|
||||
constructor(
|
||||
stripeProvider: StripeFactory,
|
||||
db: PrismaClient,
|
||||
@@ -61,7 +65,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly entitlement: EntitlementService
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly revenueCat: RevenueCatService
|
||||
) {
|
||||
super(stripeProvider, db);
|
||||
}
|
||||
@@ -94,27 +99,29 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
throw new InvalidCheckoutParameters();
|
||||
}
|
||||
|
||||
const active = await this.getActiveSubscription({
|
||||
const active = await this.getVisibleSubscription({
|
||||
plan: lookupKey.plan,
|
||||
userId: user.id,
|
||||
});
|
||||
await this.assertNoActiveLocalEntitlement(user.id, lookupKey);
|
||||
if (active?.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
if (
|
||||
active &&
|
||||
// do not allow to re-subscribe unless
|
||||
!(
|
||||
active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
)
|
||||
!this.canCheckoutWithExistingSubscription(active.recurring, lookupKey)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
}
|
||||
|
||||
const customer = await this.getOrCreateCustomer(user.id);
|
||||
const strategy = await this.strategyStatus(customer);
|
||||
const stripeSubscriptions = await this.stripe.subscriptions.list({
|
||||
customer: customer.stripeCustomerId,
|
||||
status: 'all',
|
||||
});
|
||||
this.assertNoActiveStripeSubscription(stripeSubscriptions.data, lookupKey);
|
||||
await this.assertNoActiveRevenueCatSubscription(user.id, lookupKey);
|
||||
const price = await this.getPrice(lookupKey);
|
||||
|
||||
if (!price || !(await this.isPriceAvailable(price))) {
|
||||
@@ -138,8 +145,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return { allow_promotion_codes: true };
|
||||
})();
|
||||
|
||||
const trials = (() => {
|
||||
if (lookupKey.plan === SubscriptionPlan.AI && !strategy.aiSubscribed) {
|
||||
const subscriptionData = await (async () => {
|
||||
if (
|
||||
lookupKey.plan === SubscriptionPlan.AI &&
|
||||
!(await this.hasUsedTrial(user.id, lookupKey.plan))
|
||||
) {
|
||||
return {
|
||||
trial_period_days: 7,
|
||||
} as Stripe.Checkout.SessionCreateParams.SubscriptionData;
|
||||
@@ -158,12 +168,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
: {
|
||||
mode: 'subscription' as const,
|
||||
subscription_data: {
|
||||
...trials,
|
||||
},
|
||||
subscription_data: subscriptionData,
|
||||
};
|
||||
|
||||
return this.stripe.checkout.sessions.create({
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
customer: customer.stripeCustomerId,
|
||||
line_items: [
|
||||
{
|
||||
@@ -175,6 +183,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
...discounts,
|
||||
success_url: this.url.safeLink(params.successCallbackLink || '/'),
|
||||
});
|
||||
|
||||
if (subscriptionData?.trial_period_days) {
|
||||
await this.recordTrialUsage({
|
||||
userId: user.id,
|
||||
provider: Provider.stripe,
|
||||
externalRef: session.id,
|
||||
metadata: { source: 'checkout_session' },
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async getSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||
@@ -196,6 +215,16 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
async getVisibleSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||
return this.db.subscription.findFirst({
|
||||
where: {
|
||||
targetId: args.userId,
|
||||
plan: args.plan,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
||||
const { userId, lookupKey, stripeSubscription } = subscription;
|
||||
this.assertUserIdExists(userId);
|
||||
@@ -220,23 +249,54 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
await this.upsertStripeProviderSubscription(subscription, subscriptionData);
|
||||
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
update: pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'end',
|
||||
]),
|
||||
create: {
|
||||
targetId: userId,
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
},
|
||||
if (
|
||||
lookupKey.plan === SubscriptionPlan.AI &&
|
||||
(stripeSubscription.status === SubscriptionStatus.Trialing ||
|
||||
stripeSubscription.trial_start ||
|
||||
stripeSubscription.trial_end)
|
||||
) {
|
||||
await this.recordTrialUsage({
|
||||
userId,
|
||||
provider: Provider.stripe,
|
||||
externalRef: stripeSubscription.id,
|
||||
metadata: { source: 'stripe_subscription' },
|
||||
});
|
||||
}
|
||||
|
||||
const existingByStripeId = await this.db.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
|
||||
const saved = existingByStripeId
|
||||
? await this.db.subscription.update({
|
||||
where: { id: existingByStripeId.id },
|
||||
data: pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'end',
|
||||
]),
|
||||
})
|
||||
: await this.db.subscription.upsert({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
|
||||
where: { targetId_plan: { targetId: userId, plan: lookupKey.plan } },
|
||||
update: {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
provider: Provider.stripe,
|
||||
iapStore: null,
|
||||
rcEntitlement: null,
|
||||
rcProductId: null,
|
||||
rcExternalRef: null,
|
||||
},
|
||||
create: {
|
||||
targetId: userId,
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
return saved;
|
||||
}
|
||||
@@ -247,6 +307,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
stripeSubscription,
|
||||
}: KnownStripeSubscription) {
|
||||
this.assertUserIdExists(userId);
|
||||
await this.db.providerSubscription.updateMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.Canceled,
|
||||
canceledAt: new Date(),
|
||||
periodEnd: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await this.db.subscription.deleteMany({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -311,6 +382,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
const invoiceData = await this.transformInvoice(knownInvoice);
|
||||
await this.upsertStripePaymentEvent(knownInvoice, invoiceData);
|
||||
|
||||
const invoice = await this.db.invoice.upsert({
|
||||
where: {
|
||||
@@ -357,6 +429,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
if (prevSubscription) {
|
||||
if (prevSubscription.stripeSubscriptionId) {
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
const subscription = await this.db.subscription.update({
|
||||
where: {
|
||||
id: prevSubscription.id,
|
||||
@@ -382,6 +455,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
const subscription = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: knownInvoice.userId,
|
||||
@@ -420,6 +494,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
await this.db.subscription.update({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
@@ -463,6 +538,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
: Date.now() / 1000);
|
||||
|
||||
if (subscription) {
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
const saved = await this.db.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: {
|
||||
@@ -475,6 +551,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
} else {
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
const saved = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: userId,
|
||||
@@ -530,33 +607,282 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return lookupKey.variant === null;
|
||||
}
|
||||
|
||||
private async strategyStatus(
|
||||
customer: UserStripeCustomer
|
||||
): Promise<PriceStrategyStatus> {
|
||||
let proSubscribed = false;
|
||||
let aiSubscribed = false;
|
||||
|
||||
const subscriptions = await this.stripe.subscriptions.list({
|
||||
customer: customer.stripeCustomerId,
|
||||
status: 'all',
|
||||
private async assertNoActiveLocalEntitlement(
|
||||
userId: string,
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
const entitlements = await this.entitlement.getActiveEntitlements(
|
||||
'user',
|
||||
userId
|
||||
);
|
||||
const existing = entitlements.find(entitlement => {
|
||||
if (lookupKey.plan === SubscriptionPlan.Pro) {
|
||||
return (
|
||||
entitlement.plan === 'pro' || entitlement.plan === 'lifetime_pro'
|
||||
);
|
||||
}
|
||||
if (lookupKey.plan === SubscriptionPlan.AI) {
|
||||
return entitlement.plan === 'ai';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sub of subscriptions.data) {
|
||||
const lookupKey = retriveLookupKeyFromStripeSubscription(sub);
|
||||
if (!lookupKey) {
|
||||
const metadata = existing.metadata as { provider?: string | null };
|
||||
if (metadata.provider === Provider.revenuecat) {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
if (
|
||||
!this.canCheckoutWithExistingSubscription(
|
||||
(existing.metadata as { recurring?: string | null }).recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
lookupKey
|
||||
)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
}
|
||||
}
|
||||
|
||||
private async hasUsedTrial(userId: string, plan: SubscriptionPlan) {
|
||||
return !!(await this.db.subscriptionTrialUsage.findUnique({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
}));
|
||||
}
|
||||
|
||||
private async recordTrialUsage(input: {
|
||||
userId: string;
|
||||
provider: Provider;
|
||||
externalRef: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}) {
|
||||
await this.db.subscriptionTrialUsage.upsert({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: input.userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
provider: input.provider,
|
||||
externalRef: input.externalRef,
|
||||
metadata: input.metadata as Prisma.InputJsonObject,
|
||||
},
|
||||
create: {
|
||||
targetType: 'user',
|
||||
targetId: input.userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
provider: input.provider,
|
||||
externalRef: input.externalRef,
|
||||
metadata: input.metadata as Prisma.InputJsonObject,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertStripeProviderSubscription(
|
||||
known: KnownStripeSubscription,
|
||||
subscriptionData: Subscription
|
||||
) {
|
||||
const { userId, lookupKey, stripeSubscription } = known;
|
||||
this.assertUserIdExists(userId);
|
||||
const price = stripeSubscription.items.data[0]?.price;
|
||||
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertStripePaymentEvent(
|
||||
known: KnownStripeInvoice,
|
||||
invoiceData: Awaited<
|
||||
ReturnType<UserSubscriptionManager['transformInvoice']>
|
||||
>
|
||||
) {
|
||||
const { userId, lookupKey, stripeInvoice } = known;
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
await this.db.paymentEvent.upsert({
|
||||
where: {
|
||||
provider_externalEventId: {
|
||||
provider: Provider.stripe,
|
||||
externalEventId: `stripe_invoice:${stripeInvoice.id}`,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
eventType: `invoice.${invoiceData.status}`,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
externalInvoiceId: stripeInvoice.id,
|
||||
plan: lookupKey.plan,
|
||||
amount: invoiceData.amount,
|
||||
currency: invoiceData.currency,
|
||||
occurredAt:
|
||||
typeof stripeInvoice.created === 'number'
|
||||
? new Date(stripeInvoice.created * 1000)
|
||||
: undefined,
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
eventType: `invoice.${invoiceData.status}`,
|
||||
externalEventId: `stripe_invoice:${stripeInvoice.id}`,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
externalInvoiceId: stripeInvoice.id,
|
||||
plan: lookupKey.plan,
|
||||
amount: invoiceData.amount,
|
||||
currency: invoiceData.currency,
|
||||
occurredAt:
|
||||
typeof stripeInvoice.created === 'number'
|
||||
? new Date(stripeInvoice.created * 1000)
|
||||
: undefined,
|
||||
processingStatus: 'processed',
|
||||
processedAt: new Date(),
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private isCurrentStripeSubscription(subscription: Stripe.Subscription) {
|
||||
return [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
].includes(subscription.status as SubscriptionStatus);
|
||||
}
|
||||
|
||||
private canCheckoutWithExistingSubscription(
|
||||
existingRecurring: string,
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
return (
|
||||
existingRecurring !== SubscriptionRecurring.Lifetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
);
|
||||
}
|
||||
|
||||
private assertNoActiveStripeSubscription(
|
||||
subscriptions: Stripe.Subscription[],
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
for (const subscription of subscriptions) {
|
||||
if (!this.isCurrentStripeSubscription(subscription)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lookupKey.plan === SubscriptionPlan.Pro) {
|
||||
proSubscribed = true;
|
||||
}
|
||||
|
||||
if (lookupKey.plan === SubscriptionPlan.AI) {
|
||||
aiSubscribed = true;
|
||||
const activeLookupKey =
|
||||
retriveLookupKeyFromStripeSubscription(subscription);
|
||||
if (
|
||||
activeLookupKey?.plan === lookupKey.plan &&
|
||||
!this.canCheckoutWithExistingSubscription(
|
||||
activeLookupKey.recurring,
|
||||
lookupKey
|
||||
)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { proSubscribed, aiSubscribed };
|
||||
private async assertNoActiveRevenueCatSubscription(
|
||||
userId: string,
|
||||
lookupKey: LookupKey
|
||||
) {
|
||||
if (!this.config.payment.revenuecat?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriptions: Awaited<
|
||||
ReturnType<RevenueCatService['getSubscriptions']>
|
||||
>;
|
||||
try {
|
||||
subscriptions = await this.revenueCat.getSubscriptions(userId);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch RevenueCat subscriptions for ${userId}`,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const productMap = this.config.payment.revenuecat?.productMap;
|
||||
if (
|
||||
subscriptions?.some(subscription => {
|
||||
if (!subscription.isActive) return false;
|
||||
const mapping = resolveProductMapping(subscription, productMap);
|
||||
return mapping?.plan === lookupKey.plan;
|
||||
})
|
||||
) {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
}
|
||||
|
||||
private assertUserIdExists(
|
||||
|
||||
@@ -139,6 +139,11 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
await this.upsertStripeProviderSubscription(
|
||||
workspaceId,
|
||||
subscription,
|
||||
subscriptionData
|
||||
);
|
||||
|
||||
if (
|
||||
stripeSubscription.status === SubscriptionStatus.Active ||
|
||||
@@ -159,6 +164,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
const saved = await this.db.subscription.upsert({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -194,6 +201,17 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.providerSubscription.updateMany({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.Canceled,
|
||||
canceledAt: new Date(),
|
||||
periodEnd: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await this.db.subscription.deleteMany({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
});
|
||||
@@ -337,4 +355,74 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
await schedule.updateQuantity(count);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertStripeProviderSubscription(
|
||||
workspaceId: string,
|
||||
known: KnownStripeSubscription,
|
||||
subscriptionData: Subscription
|
||||
) {
|
||||
const { lookupKey, stripeSubscription } = known;
|
||||
const price = stripeSubscription.items.data[0]?.price;
|
||||
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_externalSubscriptionId: {
|
||||
provider: Provider.stripe,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
create: {
|
||||
provider: Provider.stripe,
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
status: stripeSubscription.status,
|
||||
externalCustomerId:
|
||||
typeof stripeSubscription.customer === 'string'
|
||||
? stripeSubscription.customer
|
||||
: stripeSubscription.customer.id,
|
||||
externalSubscriptionId: stripeSubscription.id,
|
||||
externalProductId:
|
||||
typeof price?.product === 'string'
|
||||
? price.product
|
||||
: price?.product?.id,
|
||||
externalPriceId: price?.id,
|
||||
currency: price?.currency,
|
||||
amount: price?.unit_amount ?? null,
|
||||
quantity: known.quantity,
|
||||
periodStart: subscriptionData.start,
|
||||
periodEnd: subscriptionData.end,
|
||||
trialStart: subscriptionData.trialStart,
|
||||
trialEnd: subscriptionData.trialEnd,
|
||||
canceledAt: subscriptionData.canceledAt,
|
||||
metadata: known.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||
import type { Entitlement, User } from '@prisma/client';
|
||||
import { PrismaClient, Provider } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
@@ -27,15 +28,11 @@ import {
|
||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { PermissionAccess } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import {
|
||||
Invoice,
|
||||
Subscription,
|
||||
visibleSubscriptionWhere,
|
||||
WorkspaceSubscriptionManager,
|
||||
} from './manager';
|
||||
import { Invoice, Subscription, visibleSubscriptionWhere } from './manager';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { CheckoutParams, SubscriptionService } from './service';
|
||||
import {
|
||||
@@ -463,6 +460,7 @@ export class SubscriptionResolver {
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler
|
||||
) {}
|
||||
|
||||
@@ -473,6 +471,90 @@ export class UserSubscriptionResolver {
|
||||
return s;
|
||||
}
|
||||
|
||||
private async currentUserSubscriptions(userId: string) {
|
||||
const entitlements = (
|
||||
await this.entitlement.getActiveEntitlements('user', userId)
|
||||
).filter(
|
||||
entitlement =>
|
||||
entitlement.source === 'cloud_subscription' &&
|
||||
['pro', 'lifetime_pro', 'ai'].includes(entitlement.plan)
|
||||
);
|
||||
const providerFacts = await this.db.providerSubscription.findMany({
|
||||
where: {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
plan: {
|
||||
in: entitlements.map(entitlement =>
|
||||
this.subscriptionPlan(entitlement.plan)
|
||||
),
|
||||
},
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
OR: [{ periodEnd: null }, { periodEnd: { gt: new Date() } }],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return entitlements.map(entitlement => {
|
||||
const plan = this.subscriptionPlan(entitlement.plan);
|
||||
const providerFact = providerFacts.find(
|
||||
fact => fact.targetId === userId && fact.plan === plan
|
||||
);
|
||||
const metadata = entitlement.metadata as {
|
||||
provider?: string | null;
|
||||
recurring?: string | null;
|
||||
variant?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
};
|
||||
|
||||
return this.normalizeSubscription({
|
||||
stripeSubscriptionId:
|
||||
providerFact?.externalSubscriptionId ??
|
||||
metadata.stripeSubscriptionId ??
|
||||
null,
|
||||
stripeScheduleId: null,
|
||||
status: providerFact?.status ?? this.subscriptionStatus(entitlement),
|
||||
plan,
|
||||
recurring:
|
||||
providerFact?.recurring ??
|
||||
metadata.recurring ??
|
||||
(entitlement.plan === 'lifetime_pro'
|
||||
? SubscriptionRecurring.Lifetime
|
||||
: SubscriptionRecurring.Monthly),
|
||||
variant:
|
||||
metadata.variant ??
|
||||
(entitlement.plan === 'lifetime_pro'
|
||||
? SubscriptionVariant.Onetime
|
||||
: null),
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialStart: providerFact?.trialStart ?? null,
|
||||
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
|
||||
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
|
||||
canceledAt: providerFact?.canceledAt ?? null,
|
||||
provider: providerFact?.provider ?? metadata.provider ?? null,
|
||||
iapStore: providerFact?.iapStore ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private subscriptionPlan(plan: string) {
|
||||
return plan === 'lifetime_pro' ? SubscriptionPlan.Pro : plan;
|
||||
}
|
||||
|
||||
private subscriptionStatus(entitlement: Entitlement) {
|
||||
if (entitlement.status === 'grace') {
|
||||
return SubscriptionStatus.PastDue;
|
||||
}
|
||||
return SubscriptionStatus.Active;
|
||||
}
|
||||
|
||||
@ResolveField(() => [SubscriptionType])
|
||||
async subscriptions(
|
||||
@CurrentUser() me: User,
|
||||
@@ -482,18 +564,7 @@ export class UserSubscriptionResolver {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
|
||||
const subscriptions = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
|
||||
subscriptions.forEach(subscription =>
|
||||
this.normalizeSubscription(subscription)
|
||||
);
|
||||
|
||||
return subscriptions;
|
||||
return this.currentUserSubscriptions(user.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
@@ -560,17 +631,10 @@ export class UserSubscriptionResolver {
|
||||
|
||||
try {
|
||||
await this.rcHandler.syncAppUserWithExternalRef(user.id, transactionId);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
current = await this.currentUserSubscriptions(user.id);
|
||||
// ignore errors
|
||||
} catch {}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -612,39 +676,93 @@ export class UserSubscriptionResolver {
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.rcHandler.syncAppUser(user.id);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
...visibleSubscriptionWhere(),
|
||||
},
|
||||
});
|
||||
// ignore errors
|
||||
} catch {}
|
||||
}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
return this.currentUserSubscriptions(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: WorkspaceSubscriptionManager,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
private async currentWorkspaceSubscription(workspaceId: string) {
|
||||
const entitlement = await this.entitlement.getBestEntitlement(
|
||||
'workspace',
|
||||
workspaceId
|
||||
);
|
||||
if (
|
||||
!entitlement ||
|
||||
entitlement.source !== 'cloud_subscription' ||
|
||||
entitlement.plan !== 'team'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerFact = await this.db.providerSubscription.findFirst({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
OR: [{ periodEnd: null }, { periodEnd: { gt: new Date() } }],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
const metadata = entitlement.metadata as {
|
||||
provider?: string | null;
|
||||
recurring?: string | null;
|
||||
variant?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
stripeSubscriptionId:
|
||||
providerFact?.externalSubscriptionId ??
|
||||
metadata.stripeSubscriptionId ??
|
||||
null,
|
||||
stripeScheduleId: null,
|
||||
status:
|
||||
providerFact?.status ??
|
||||
(entitlement.status === 'grace'
|
||||
? SubscriptionStatus.PastDue
|
||||
: SubscriptionStatus.Active),
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring:
|
||||
providerFact?.recurring ??
|
||||
metadata.recurring ??
|
||||
SubscriptionRecurring.Monthly,
|
||||
variant: metadata.variant ?? null,
|
||||
quantity: entitlement.quantity ?? 1,
|
||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||
end: entitlement.expiresAt,
|
||||
trialStart: providerFact?.trialStart ?? null,
|
||||
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
|
||||
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
|
||||
canceledAt: providerFact?.canceledAt ?? null,
|
||||
provider: providerFact?.provider ?? metadata.provider ?? null,
|
||||
iapStore: providerFact?.iapStore ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => SubscriptionType, {
|
||||
nullable: true,
|
||||
description: 'The team subscription of the workspace, if exists.',
|
||||
})
|
||||
async subscription(@Parent() workspace: WorkspaceType) {
|
||||
return this.service.getActiveSubscription({
|
||||
plan: SubscriptionPlan.Team,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
return this.currentWorkspaceSubscription(workspace.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
|
||||
@@ -165,6 +165,85 @@ export class RevenueCatWebhookHandler {
|
||||
const end = overrideExpirationDate || sub.expirationDate || null;
|
||||
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
||||
|
||||
if (rcExternalRef && iapStore) {
|
||||
await this.db.providerSubscription.upsert({
|
||||
where: {
|
||||
provider_iapStore_externalRef_externalProductId_externalCustomerId:
|
||||
{
|
||||
provider: Provider.revenuecat,
|
||||
iapStore,
|
||||
externalRef: rcExternalRef,
|
||||
externalProductId: sub.productId,
|
||||
externalCustomerId: sub.customerId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
targetType: 'user',
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
status,
|
||||
externalCustomerId: sub.customerId,
|
||||
externalProductId: sub.productId,
|
||||
iapStore,
|
||||
externalRef: rcExternalRef,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
canceledAt,
|
||||
metadata: {
|
||||
entitlement: sub.identifier,
|
||||
isTrial: sub.isTrial,
|
||||
willRenew: sub.willRenew,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
provider: Provider.revenuecat,
|
||||
targetType: 'user',
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
status,
|
||||
externalCustomerId: sub.customerId,
|
||||
externalProductId: sub.productId,
|
||||
iapStore,
|
||||
externalRef: rcExternalRef,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
canceledAt,
|
||||
metadata: {
|
||||
entitlement: sub.identifier,
|
||||
isTrial: sub.isTrial,
|
||||
willRenew: sub.willRenew,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (mapping.plan === SubscriptionPlan.AI && sub.isTrial) {
|
||||
await this.db.subscriptionTrialUsage.upsert({
|
||||
where: {
|
||||
targetType_targetId_plan: {
|
||||
targetType: 'user',
|
||||
targetId: appUserId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
targetType: 'user',
|
||||
targetId: appUserId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
provider: Provider.revenuecat,
|
||||
externalRef: rcExternalRef,
|
||||
firstUsedAt: start,
|
||||
metadata: {
|
||||
entitlement: sub.identifier,
|
||||
productId: sub.productId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Mutual exclusion: skip if Stripe already active for the same plan
|
||||
const conflict = await this.db.subscription.findFirst({
|
||||
where: {
|
||||
@@ -214,6 +293,8 @@ export class RevenueCatWebhookHandler {
|
||||
}
|
||||
|
||||
const saved = await this.db.subscription.upsert({
|
||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
||||
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
|
||||
where: {
|
||||
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
||||
},
|
||||
|
||||
@@ -629,6 +629,7 @@ export class SubscriptionService {
|
||||
`Failed to handle ${reason} for invoice ${invoiceId}`,
|
||||
e
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,38 +7,8 @@ version = "0.1.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
doc-loader = [
|
||||
"docx-parser",
|
||||
"infer",
|
||||
"path-ext",
|
||||
"pdf-extract",
|
||||
"readability",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum_macros",
|
||||
"text-splitter",
|
||||
"thiserror",
|
||||
"tiktoken-rs",
|
||||
"tree-sitter",
|
||||
"url",
|
||||
]
|
||||
hashcash = ["chrono", "sha3", "rand"]
|
||||
napi = ["dep:napi"]
|
||||
tree-sitter = [
|
||||
"cc",
|
||||
"dep:tree-sitter",
|
||||
"dep:tree-sitter-c",
|
||||
"dep:tree-sitter-c-sharp",
|
||||
"dep:tree-sitter-cpp",
|
||||
"dep:tree-sitter-go",
|
||||
"dep:tree-sitter-java",
|
||||
"dep:tree-sitter-javascript",
|
||||
"dep:tree-sitter-kotlin-ng",
|
||||
"dep:tree-sitter-python",
|
||||
"dep:tree-sitter-rust",
|
||||
"dep:tree-sitter-scala",
|
||||
"dep:tree-sitter-typescript",
|
||||
]
|
||||
ydoc-loader = [
|
||||
"assert-json-diff",
|
||||
"nanoid",
|
||||
@@ -51,49 +21,22 @@ ydoc-loader = [
|
||||
|
||||
[dependencies]
|
||||
assert-json-diff = { workspace = true, optional = true }
|
||||
chrono = { workspace = true, optional = true }
|
||||
docx-parser = { workspace = true, optional = true }
|
||||
infer = { workspace = true, optional = true }
|
||||
nanoid = { workspace = true, optional = true }
|
||||
napi = { workspace = true, optional = true }
|
||||
path-ext = { workspace = true, optional = true }
|
||||
pdf-extract = { workspace = true, optional = true }
|
||||
pulldown-cmark = { workspace = true, optional = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
readability = { workspace = true, optional = true, default-features = false }
|
||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
sha3 = { workspace = true, optional = true }
|
||||
strum_macros = { workspace = true, optional = true }
|
||||
text-splitter = { workspace = true, features = [
|
||||
"markdown",
|
||||
"tiktoken-rs",
|
||||
], optional = true }
|
||||
thiserror = { workspace = true, optional = true }
|
||||
tiktoken-rs = { workspace = true, optional = true }
|
||||
tree-sitter = { workspace = true, optional = true }
|
||||
tree-sitter-c = { workspace = true, optional = true }
|
||||
tree-sitter-c-sharp = { workspace = true, optional = true }
|
||||
tree-sitter-cpp = { workspace = true, optional = true }
|
||||
tree-sitter-go = { workspace = true, optional = true }
|
||||
tree-sitter-java = { workspace = true, optional = true }
|
||||
tree-sitter-javascript = { workspace = true, optional = true }
|
||||
tree-sitter-kotlin-ng = { workspace = true, optional = true }
|
||||
tree-sitter-python = { workspace = true, optional = true }
|
||||
tree-sitter-rust = { workspace = true, optional = true }
|
||||
tree-sitter-scala = { workspace = true, optional = true }
|
||||
tree-sitter-typescript = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
y-octo = { workspace = true, optional = true }
|
||||
chrono = { workspace = true, optional = true }
|
||||
nanoid = { workspace = true, optional = true }
|
||||
napi = { workspace = true, optional = true }
|
||||
pulldown-cmark = { workspace = true, optional = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
sha3 = { workspace = true, optional = true }
|
||||
thiserror = { workspace = true, optional = true }
|
||||
y-octo = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
cc = { version = "1", optional = true }
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "hashcash"
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
use std::{
|
||||
io::Cursor,
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use path_ext::PathExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Chunk {
|
||||
pub index: usize,
|
||||
pub content: String,
|
||||
pub start: Option<usize>,
|
||||
pub end: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct DocOptions {
|
||||
code_threshold: u64,
|
||||
}
|
||||
|
||||
impl Default for DocOptions {
|
||||
fn default() -> Self {
|
||||
Self { code_threshold: 1000 }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Doc {
|
||||
pub name: String,
|
||||
pub chunks: Vec<Chunk>,
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
pub fn new(file_path: &str, doc: &[u8]) -> LoaderResult<Self> {
|
||||
Self::with_options(file_path, doc, DocOptions::default())
|
||||
}
|
||||
|
||||
pub fn with_options(file_path: &str, doc: &[u8], options: DocOptions) -> LoaderResult<Self> {
|
||||
if let Some(kind) = infer::get(&doc[..4096.min(doc.len())]).or(infer::get_from_path(file_path).ok().flatten()) {
|
||||
if kind.extension() == "pdf" {
|
||||
return Self::load_pdf(file_path, doc);
|
||||
} else if kind.extension() == "docx" {
|
||||
return Self::load_docx(file_path, doc);
|
||||
} else if kind.extension() == "html" {
|
||||
return Self::load_html(file_path, doc);
|
||||
}
|
||||
} else if let Ok(string) = String::from_utf8(doc.to_vec()).or_else(|_| {
|
||||
String::from_utf16(
|
||||
&doc
|
||||
.chunks_exact(2)
|
||||
.map(|b| u16::from_le_bytes([b[0], b[1]]))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}) {
|
||||
let path = PathBuf::from(file_path);
|
||||
match path.ext_str() {
|
||||
"md" => {
|
||||
let loader = TextLoader::new(string);
|
||||
let splitter = MarkdownSplitter::default();
|
||||
return Self::from_loader(file_path, loader, splitter);
|
||||
}
|
||||
"rs" | "c" | "cpp" | "h" | "hpp" | "js" | "ts" | "tsx" | "go" | "py" => {
|
||||
let name = path.full_str().to_string();
|
||||
let loader = SourceCodeLoader::from_string(string).with_parser_option(LanguageParserOptions {
|
||||
language: get_language_by_filename(&name)?,
|
||||
parser_threshold: options.code_threshold,
|
||||
});
|
||||
let splitter = TokenSplitter::default();
|
||||
return Self::from_loader(file_path, loader, splitter);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let loader = TextLoader::new(string);
|
||||
let splitter = TokenSplitter::default();
|
||||
return Self::from_loader(file_path, loader, splitter);
|
||||
}
|
||||
Err(LoaderError::Other("Failed to infer document type".into()))
|
||||
}
|
||||
|
||||
fn from_loader(
|
||||
file_path: &str,
|
||||
loader: impl Loader + 'static,
|
||||
splitter: impl TextSplitter + 'static,
|
||||
) -> Result<Doc, LoaderError> {
|
||||
let name = file_path.to_string();
|
||||
let chunks = catch_unwind(AssertUnwindSafe(|| Self::get_chunks_from_loader(loader, splitter))).map_err(|e| {
|
||||
LoaderError::Other(match e.downcast::<String>() {
|
||||
Ok(v) => *v,
|
||||
Err(e) => match e.downcast::<&str>() {
|
||||
Ok(v) => v.to_string(),
|
||||
_ => "Unknown Source of Error".to_owned(),
|
||||
},
|
||||
})
|
||||
})??;
|
||||
|
||||
Ok(Self { name, chunks })
|
||||
}
|
||||
|
||||
fn get_chunks_from_loader(
|
||||
loader: impl Loader + 'static,
|
||||
splitter: impl TextSplitter + 'static,
|
||||
) -> Result<Vec<Chunk>, LoaderError> {
|
||||
let docs = loader.load_and_split(splitter)?;
|
||||
Ok(
|
||||
docs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, d)| Chunk {
|
||||
index,
|
||||
content: d.page_content,
|
||||
..Chunk::default()
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_docx(file_path: &str, doc: &[u8]) -> LoaderResult<Self> {
|
||||
let loader = DocxLoader::new(Cursor::new(doc)).ok_or(LoaderError::Other("Failed to parse docx document".into()))?;
|
||||
let splitter = TokenSplitter::default();
|
||||
Self::from_loader(file_path, loader, splitter)
|
||||
}
|
||||
|
||||
fn load_html(file_path: &str, doc: &[u8]) -> LoaderResult<Self> {
|
||||
let loader = HtmlLoader::from_string(
|
||||
String::from_utf8(doc.to_vec())?,
|
||||
Url::parse(file_path).or(Url::parse("https://example.com/"))?,
|
||||
);
|
||||
let splitter = TokenSplitter::default();
|
||||
Self::from_loader(file_path, loader, splitter)
|
||||
}
|
||||
|
||||
fn load_pdf(file_path: &str, doc: &[u8]) -> LoaderResult<Self> {
|
||||
let loader = PdfExtractLoader::new(Cursor::new(doc))?;
|
||||
let splitter = TokenSplitter::default();
|
||||
Self::from_loader(file_path, loader, splitter)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs::{read, read_to_string},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
const FIXTURES: [&str; 6] = [
|
||||
"demo.docx",
|
||||
"sample.pdf",
|
||||
"sample.html",
|
||||
"sample.rs",
|
||||
"sample.c",
|
||||
"sample.ts",
|
||||
];
|
||||
|
||||
fn get_fixtures() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixtures() {
|
||||
let fixtures = get_fixtures();
|
||||
for fixture in FIXTURES.iter() {
|
||||
let buffer = read(fixtures.join(fixture)).unwrap();
|
||||
let doc = Doc::with_options(fixture, &buffer, DocOptions { code_threshold: 0 }).unwrap();
|
||||
for chunk in doc.chunks.iter() {
|
||||
let output = read_to_string(fixtures.join(format!("{}.{}.md", fixture, chunk.index))).unwrap();
|
||||
assert_eq!(chunk.content, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use std::{io, str::Utf8Error, string::FromUtf8Error};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
*/
|
||||
use super::*;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LoaderError {
|
||||
#[error("{0}")]
|
||||
TextSplitter(#[from] TextSplitterError),
|
||||
|
||||
#[error(transparent)]
|
||||
IO(#[from] io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Utf8(#[from] Utf8Error),
|
||||
|
||||
#[error(transparent)]
|
||||
FromUtf8(#[from] FromUtf8Error),
|
||||
|
||||
#[error(transparent)]
|
||||
PdfExtract(#[from] pdf_extract::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
PdfExtractOutput(#[from] pdf_extract::OutputError),
|
||||
|
||||
#[error(transparent)]
|
||||
Readability(#[from] readability::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
|
||||
#[error("Unsupported source language")]
|
||||
UnsupportedLanguage,
|
||||
|
||||
#[error("Error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub type LoaderResult<T> = Result<T, LoaderError>;
|
||||
@@ -1,69 +0,0 @@
|
||||
use docx_parser::MarkdownDocument;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DocxLoader {
|
||||
document: MarkdownDocument,
|
||||
}
|
||||
|
||||
impl DocxLoader {
|
||||
pub fn new<R: Read + Seek>(reader: R) -> Option<Self> {
|
||||
Some(Self {
|
||||
document: MarkdownDocument::from_reader(reader)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_text(&self) -> String {
|
||||
self.document.to_markdown(false)
|
||||
}
|
||||
|
||||
fn extract_text_to_doc(&self) -> Document {
|
||||
Document::new(self.extract_text())
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader for DocxLoader {
|
||||
fn load(self) -> LoaderResult<Vec<Document>> {
|
||||
let doc = self.extract_text_to_doc();
|
||||
Ok(vec![doc])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::read, io::Cursor, path::PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn get_fixtures_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_docx() {
|
||||
let docx_buffer = include_bytes!("../../../fixtures/demo.docx");
|
||||
let parsed_buffer = include_str!("../../../fixtures/demo.docx.md");
|
||||
|
||||
{
|
||||
let loader = DocxLoader::new(Cursor::new(docx_buffer)).unwrap();
|
||||
|
||||
let documents = loader.load().unwrap();
|
||||
|
||||
assert_eq!(documents.len(), 1);
|
||||
assert_eq!(documents[0].page_content, parsed_buffer);
|
||||
}
|
||||
|
||||
{
|
||||
let loader = DocxLoader::new(Cursor::new(docx_buffer)).unwrap();
|
||||
let documents = loader.load_and_split(TokenSplitter::default()).unwrap();
|
||||
|
||||
for (idx, doc) in documents.into_iter().enumerate() {
|
||||
assert_eq!(
|
||||
doc.page_content,
|
||||
String::from_utf8_lossy(&read(get_fixtures_path().join(format!("demo.docx.{}.md", idx))).unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
use std::{collections::HashMap, io::Cursor};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
*/
|
||||
use super::*;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HtmlLoader<R> {
|
||||
html: R,
|
||||
url: Url,
|
||||
}
|
||||
|
||||
impl HtmlLoader<Cursor<Vec<u8>>> {
|
||||
pub fn from_string<S: Into<String>>(input: S, url: Url) -> Self {
|
||||
let input = input.into();
|
||||
let reader = Cursor::new(input.into_bytes());
|
||||
Self::new(reader, url)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> HtmlLoader<R> {
|
||||
pub fn new(html: R, url: Url) -> Self {
|
||||
Self { html, url }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read + Send + Sync + 'static> Loader for HtmlLoader<R> {
|
||||
fn load(mut self) -> LoaderResult<Vec<Document>> {
|
||||
let cleaned_html = readability::extractor::extract(&mut self.html, &self.url)?;
|
||||
let doc = Document::new(format!("{}\n{}", cleaned_html.title, cleaned_html.text))
|
||||
.with_metadata(HashMap::from([("source".to_string(), Value::from(self.url.as_str()))]));
|
||||
|
||||
Ok(vec![doc])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_html_loader() {
|
||||
let input = "<p>Hello world!</p>";
|
||||
|
||||
let html_loader = HtmlLoader::new(input.as_bytes(), Url::parse("https://example.com/").unwrap());
|
||||
|
||||
let documents = html_loader.load().unwrap();
|
||||
|
||||
let expected = "\nHello world!";
|
||||
|
||||
assert_eq!(documents.len(), 1);
|
||||
assert_eq!(
|
||||
documents[0].metadata.get("source").unwrap(),
|
||||
&Value::from("https://example.com/")
|
||||
);
|
||||
assert_eq!(documents[0].page_content, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_load_from_path() {
|
||||
let buffer = include_bytes!("../../../fixtures/sample.html");
|
||||
let html_loader = HtmlLoader::new(Cursor::new(buffer), Url::parse("https://example.com/").unwrap());
|
||||
|
||||
let documents = html_loader.load().unwrap();
|
||||
|
||||
let expected = [
|
||||
"Example Domain",
|
||||
"",
|
||||
" This domain is for use in illustrative examples in documents. You may",
|
||||
" use this domain in literature without prior coordination or asking for",
|
||||
" permission.",
|
||||
" More information...",
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
assert_eq!(documents.len(), 1);
|
||||
assert_eq!(
|
||||
documents[0].metadata.get("source").unwrap(),
|
||||
&Value::from("https://example.com/")
|
||||
);
|
||||
assert_eq!(documents[0].page_content, expected);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
mod docx;
|
||||
mod html;
|
||||
mod pdf;
|
||||
mod source;
|
||||
mod text;
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
use super::*;
|
||||
|
||||
// modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
pub trait Loader: Send + Sync {
|
||||
fn load(self) -> LoaderResult<Vec<Document>>;
|
||||
fn load_and_split<TS: TextSplitter + 'static>(self, splitter: TS) -> LoaderResult<Vec<Document>>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let docs = self.load()?;
|
||||
Ok(splitter.split_documents(&docs)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub use docx::DocxLoader;
|
||||
pub use html::HtmlLoader;
|
||||
pub use pdf::PdfExtractLoader;
|
||||
pub use source::{LanguageParserOptions, SourceCodeLoader, get_language_by_filename};
|
||||
pub use text::TextLoader;
|
||||
pub use url::Url;
|
||||
@@ -1,103 +0,0 @@
|
||||
use pdf_extract::{PlainTextOutput, output_doc, output_doc_encrypted};
|
||||
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
*/
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PdfExtractLoader {
|
||||
document: pdf_extract::Document,
|
||||
}
|
||||
|
||||
impl PdfExtractLoader {
|
||||
pub fn new<R: Read>(reader: R) -> Result<Self, LoaderError> {
|
||||
let document = pdf_extract::Document::load_from(reader)?;
|
||||
Ok(Self { document })
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfExtractLoader {
|
||||
fn extract_text(&self) -> Result<String, LoaderError> {
|
||||
let mut doc = self.document.clone();
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut output = PlainTextOutput::new(&mut buffer as &mut dyn std::io::Write);
|
||||
if doc.is_encrypted() {
|
||||
output_doc_encrypted(&mut doc, &mut output, "")?;
|
||||
} else {
|
||||
output_doc(&doc, &mut output)?;
|
||||
}
|
||||
Ok(String::from_utf8(buffer)?)
|
||||
}
|
||||
|
||||
fn extract_text_to_doc(&self) -> Result<Document, LoaderError> {
|
||||
let text = self.extract_text()?;
|
||||
Ok(Document::new(text))
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader for PdfExtractLoader {
|
||||
fn load(self) -> LoaderResult<Vec<Document>> {
|
||||
let doc = self.extract_text_to_doc()?;
|
||||
Ok(vec![doc])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs::read,
|
||||
io::Cursor,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use path_ext::PathExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse_pdf_content(path: &Path) -> Vec<Document> {
|
||||
let buffer = read(path).unwrap();
|
||||
|
||||
let reader = Cursor::new(buffer);
|
||||
let loader = PdfExtractLoader::new(reader).expect("Failed to create PdfExtractLoader");
|
||||
|
||||
loader.load().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pdf() {
|
||||
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures");
|
||||
let docs = parse_pdf_content(&fixtures.join("sample.pdf"));
|
||||
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert_eq!(
|
||||
&docs[0].page_content[..100],
|
||||
"\n\nSample PDF\nThis is a simple PDF file. Fun fun fun.\n\nLorem ipsum dolor sit amet, consectetuer a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "for debugging only"]
|
||||
fn test_parse_pdf_custom() {
|
||||
let mut args = std::env::args().collect::<Vec<_>>();
|
||||
|
||||
let fixtures = 'path: {
|
||||
while let Some(path) = args.pop() {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_dir() {
|
||||
break 'path path;
|
||||
}
|
||||
}
|
||||
panic!("No directory provided");
|
||||
};
|
||||
|
||||
for path in fixtures.walk_iter(|p| p.is_file() && p.ext_str() == "pdf") {
|
||||
println!("Parsing: {}", path.display());
|
||||
let docs = parse_pdf_content(&path);
|
||||
|
||||
let chunks = docs.len();
|
||||
let words = docs.iter().map(|d| d.page_content.len()).sum::<usize>();
|
||||
println!("{}: {} chunks, {} words", path.display(), chunks, words,);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
*/
|
||||
mod parser;
|
||||
|
||||
pub use parser::{LanguageParser, LanguageParserOptions, get_language_by_filename};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourceCodeLoader {
|
||||
content: String,
|
||||
parser_option: LanguageParserOptions,
|
||||
}
|
||||
|
||||
impl SourceCodeLoader {
|
||||
pub fn from_string<S: Into<String>>(input: S) -> Self {
|
||||
Self {
|
||||
content: input.into(),
|
||||
parser_option: LanguageParserOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceCodeLoader {
|
||||
pub fn with_parser_option(mut self, parser_option: LanguageParserOptions) -> Self {
|
||||
self.parser_option = parser_option;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader for SourceCodeLoader {
|
||||
fn load(self) -> LoaderResult<Vec<Document>> {
|
||||
let options = self.parser_option.clone();
|
||||
|
||||
let docs = LanguageParser::from_language(options.language)
|
||||
.with_parser_threshold(options.parser_threshold)
|
||||
.parse_code(&self.content)?;
|
||||
|
||||
Ok(docs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use parser::Language;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_source_code_loader() {
|
||||
let content = include_str!("../../../../fixtures/sample.rs");
|
||||
let loader = SourceCodeLoader::from_string(content).with_parser_option(LanguageParserOptions {
|
||||
language: Language::Rust,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let documents_with_content = loader.load().unwrap();
|
||||
assert_eq!(documents_with_content.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
use std::{collections::HashMap, fmt::Debug, string::ToString};
|
||||
|
||||
use strum_macros::Display;
|
||||
use tree_sitter::{Parser, Tree};
|
||||
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
*/
|
||||
use super::*;
|
||||
|
||||
#[derive(Display, Debug, Clone)]
|
||||
pub enum Language {
|
||||
Rust,
|
||||
C,
|
||||
Cpp,
|
||||
Javascript,
|
||||
Typescript,
|
||||
Go,
|
||||
Python,
|
||||
}
|
||||
|
||||
pub enum LanguageContentTypes {
|
||||
SimplifiedCode,
|
||||
FunctionsImpls,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LanguageContentTypes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
LanguageContentTypes::SimplifiedCode => "simplified_code",
|
||||
LanguageContentTypes::FunctionsImpls => "functions_impls",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LanguageParserOptions {
|
||||
pub parser_threshold: u64,
|
||||
pub language: Language,
|
||||
}
|
||||
|
||||
impl Default for LanguageParserOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
parser_threshold: 1000,
|
||||
language: Language::Rust,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LanguageParser {
|
||||
parser: Parser,
|
||||
parser_options: LanguageParserOptions,
|
||||
}
|
||||
|
||||
impl Debug for LanguageParser {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "LanguageParser {{ language: {:?} }}", self.parser_options.language)
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for LanguageParser {
|
||||
fn clone(&self) -> Self {
|
||||
LanguageParser {
|
||||
parser: get_language_parser(&self.parser_options.language),
|
||||
parser_options: self.parser_options.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_language_by_filename(name: &str) -> LoaderResult<Language> {
|
||||
let extension = name.split('.').next_back().ok_or(LoaderError::UnsupportedLanguage)?;
|
||||
let language = match extension.to_lowercase().as_str() {
|
||||
"rs" => Language::Rust,
|
||||
"c" => Language::C,
|
||||
"cpp" => Language::Cpp,
|
||||
"h" => Language::C,
|
||||
"hpp" => Language::Cpp,
|
||||
"js" => Language::Javascript,
|
||||
"ts" => Language::Typescript,
|
||||
"tsx" => Language::Typescript,
|
||||
"go" => Language::Go,
|
||||
"py" => Language::Python,
|
||||
_ => return Err(LoaderError::UnsupportedLanguage),
|
||||
};
|
||||
Ok(language)
|
||||
}
|
||||
|
||||
fn get_language_parser(language: &Language) -> Parser {
|
||||
let mut parser = Parser::new();
|
||||
let lang = match language {
|
||||
Language::Rust => tree_sitter_rust::LANGUAGE,
|
||||
Language::C => tree_sitter_c::LANGUAGE,
|
||||
Language::Cpp => tree_sitter_cpp::LANGUAGE,
|
||||
Language::Javascript => tree_sitter_javascript::LANGUAGE,
|
||||
Language::Typescript => tree_sitter_typescript::LANGUAGE_TSX,
|
||||
Language::Go => tree_sitter_go::LANGUAGE,
|
||||
Language::Python => tree_sitter_python::LANGUAGE,
|
||||
};
|
||||
parser
|
||||
.set_language(&lang.into())
|
||||
.unwrap_or_else(|_| panic!("Error loading grammar for language: {language:?}"));
|
||||
parser
|
||||
}
|
||||
|
||||
impl LanguageParser {
|
||||
pub fn from_language(language: Language) -> Self {
|
||||
Self {
|
||||
parser: get_language_parser(&language),
|
||||
parser_options: LanguageParserOptions {
|
||||
language,
|
||||
..LanguageParserOptions::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_parser_threshold(mut self, threshold: u64) -> Self {
|
||||
self.parser_options.parser_threshold = threshold;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageParser {
|
||||
pub fn parse_code(&mut self, code: &String) -> LoaderResult<Vec<Document>> {
|
||||
let tree = self.parser.parse(code, None).ok_or(LoaderError::UnsupportedLanguage)?;
|
||||
if self.parser_options.parser_threshold > tree.root_node().end_position().row as u64 {
|
||||
return Ok(vec![Document::new(code).with_metadata(HashMap::from([
|
||||
(
|
||||
"content_type".to_string(),
|
||||
serde_json::Value::from(LanguageContentTypes::SimplifiedCode.to_string()),
|
||||
),
|
||||
(
|
||||
"language".to_string(),
|
||||
serde_json::Value::from(self.parser_options.language.to_string()),
|
||||
),
|
||||
]))]);
|
||||
}
|
||||
self.extract_functions_classes(tree, code)
|
||||
}
|
||||
|
||||
pub fn extract_functions_classes(&self, tree: Tree, code: &String) -> LoaderResult<Vec<Document>> {
|
||||
let mut chunks = Vec::new();
|
||||
|
||||
let count = tree.root_node().child_count();
|
||||
for i in 0..count {
|
||||
let Some(node) = tree.root_node().child(i) else {
|
||||
continue;
|
||||
};
|
||||
let source_code = node.utf8_text(code.as_bytes())?.to_string();
|
||||
let lang_meta = (
|
||||
"language".to_string(),
|
||||
serde_json::Value::from(self.parser_options.language.to_string()),
|
||||
);
|
||||
if node.kind() == "function_item" || node.kind() == "impl_item" {
|
||||
let doc = Document::new(source_code).with_metadata(HashMap::from([
|
||||
lang_meta.clone(),
|
||||
(
|
||||
"content_type".to_string(),
|
||||
serde_json::Value::from(LanguageContentTypes::FunctionsImpls.to_string()),
|
||||
),
|
||||
]));
|
||||
chunks.push(doc);
|
||||
} else {
|
||||
let doc = Document::new(source_code).with_metadata(HashMap::from([
|
||||
lang_meta.clone(),
|
||||
(
|
||||
"content_type".to_string(),
|
||||
serde_json::Value::from(LanguageContentTypes::SimplifiedCode.to_string()),
|
||||
),
|
||||
]));
|
||||
chunks.push(doc);
|
||||
}
|
||||
}
|
||||
Ok(chunks)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_code_parser() {
|
||||
let code = r#"
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
|
||||
pub struct Person {
|
||||
name: String,
|
||||
age: i32,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(name: String, age: i32) -> Self {
|
||||
Self { name, age }
|
||||
}
|
||||
|
||||
pub fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn get_age(&self) -> i32 {
|
||||
self.age
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let mut parser = LanguageParser::from_language(Language::Rust);
|
||||
|
||||
let documents = parser.parse_code(&code.to_string()).unwrap();
|
||||
assert_eq!(documents.len(), 1);
|
||||
|
||||
// Set the parser threshold to 10 for testing
|
||||
let mut parser = parser.with_parser_threshold(10);
|
||||
|
||||
let documents = parser.parse_code(&code.to_string()).unwrap();
|
||||
assert_eq!(documents.len(), 3);
|
||||
assert_eq!(
|
||||
documents[0].page_content,
|
||||
"fn main() {\n println!(\"Hello, world!\");\n }"
|
||||
);
|
||||
assert_eq!(
|
||||
documents[1].metadata.get("content_type").unwrap(),
|
||||
LanguageContentTypes::SimplifiedCode.to_string().as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/document_loaders
|
||||
*/
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextLoader {
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl TextLoader {
|
||||
pub fn new<T: Into<String>>(input: T) -> Self {
|
||||
Self { content: input.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader for TextLoader {
|
||||
fn load(self) -> LoaderResult<Vec<Document>> {
|
||||
let doc = Document::new(self.content);
|
||||
Ok(vec![doc])
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
mod document;
|
||||
mod error;
|
||||
mod loader;
|
||||
mod splitter;
|
||||
mod types;
|
||||
|
||||
pub use document::{Chunk, Doc};
|
||||
pub use error::{LoaderError, LoaderResult};
|
||||
use loader::{
|
||||
DocxLoader, HtmlLoader, LanguageParserOptions, Loader, PdfExtractLoader, SourceCodeLoader, TextLoader, Url,
|
||||
get_language_by_filename,
|
||||
};
|
||||
use splitter::{MarkdownSplitter, TextSplitter, TextSplitterError, TokenSplitter};
|
||||
use types::Document;
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/text_splitter
|
||||
*/
|
||||
use text_splitter::ChunkConfigError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum TextSplitterError {
|
||||
#[error("Empty input text")]
|
||||
EmptyInputText,
|
||||
|
||||
#[error("Mismatch metadata and text")]
|
||||
MetadataTextMismatch,
|
||||
|
||||
#[error("Tokenizer not found")]
|
||||
TokenizerNotFound,
|
||||
|
||||
#[error("Tokenizer creation failed due to invalid tokenizer")]
|
||||
InvalidTokenizer,
|
||||
|
||||
#[error("Tokenizer creation failed due to invalid model")]
|
||||
InvalidModel,
|
||||
|
||||
#[error("Invalid chunk overlap and size")]
|
||||
InvalidSplitterOptions,
|
||||
|
||||
#[error("Error: {0}")]
|
||||
OtherError(String),
|
||||
}
|
||||
|
||||
impl From<ChunkConfigError> for TextSplitterError {
|
||||
fn from(_: ChunkConfigError) -> Self {
|
||||
Self::InvalidSplitterOptions
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
use text_splitter::ChunkConfig;
|
||||
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/text_splitter
|
||||
*/
|
||||
use super::*;
|
||||
|
||||
pub struct MarkdownSplitter {
|
||||
splitter_options: SplitterOptions,
|
||||
}
|
||||
|
||||
impl Default for MarkdownSplitter {
|
||||
fn default() -> Self {
|
||||
MarkdownSplitter::new(SplitterOptions::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownSplitter {
|
||||
pub fn new(options: SplitterOptions) -> MarkdownSplitter {
|
||||
MarkdownSplitter {
|
||||
splitter_options: options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextSplitter for MarkdownSplitter {
|
||||
fn split_text(&self, text: &str) -> Result<Vec<String>, TextSplitterError> {
|
||||
let chunk_config = ChunkConfig::try_from(&self.splitter_options)?;
|
||||
Ok(
|
||||
text_splitter::MarkdownSplitter::new(chunk_config)
|
||||
.chunks(text)
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/text_splitter
|
||||
*/
|
||||
mod error;
|
||||
mod markdown;
|
||||
mod options;
|
||||
mod token;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use error::TextSplitterError;
|
||||
pub use markdown::MarkdownSplitter;
|
||||
use options::SplitterOptions;
|
||||
use serde_json::Value;
|
||||
pub use token::TokenSplitter;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub trait TextSplitter: Send + Sync {
|
||||
fn split_text(&self, text: &str) -> Result<Vec<String>, TextSplitterError>;
|
||||
|
||||
fn split_documents(&self, documents: &[Document]) -> Result<Vec<Document>, TextSplitterError> {
|
||||
let mut texts: Vec<String> = Vec::new();
|
||||
let mut metadata: Vec<HashMap<String, Value>> = Vec::new();
|
||||
documents.iter().for_each(|d| {
|
||||
texts.push(d.page_content.clone());
|
||||
metadata.push(d.metadata.clone());
|
||||
});
|
||||
|
||||
self.create_documents(&texts, &metadata)
|
||||
}
|
||||
|
||||
fn create_documents(
|
||||
&self,
|
||||
text: &[String],
|
||||
metadata: &[HashMap<String, Value>],
|
||||
) -> Result<Vec<Document>, TextSplitterError> {
|
||||
let mut metadata = metadata.to_vec();
|
||||
if metadata.is_empty() {
|
||||
metadata = vec![HashMap::new(); text.len()];
|
||||
}
|
||||
|
||||
if text.len() != metadata.len() {
|
||||
return Err(TextSplitterError::MetadataTextMismatch);
|
||||
}
|
||||
|
||||
let mut documents: Vec<Document> = Vec::new();
|
||||
for i in 0..text.len() {
|
||||
let chunks = self.split_text(&text[i])?;
|
||||
for chunk in chunks {
|
||||
let document = Document::new(chunk).with_metadata(metadata[i].clone());
|
||||
documents.push(document);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(documents)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/text_splitter
|
||||
*/
|
||||
use text_splitter::ChunkConfig;
|
||||
use tiktoken_rs::{CoreBPE, get_bpe_from_model, get_bpe_from_tokenizer, tokenizer::Tokenizer};
|
||||
|
||||
use super::TextSplitterError;
|
||||
|
||||
// Options is a struct that contains options for a text splitter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplitterOptions {
|
||||
pub chunk_size: usize,
|
||||
pub chunk_overlap: usize,
|
||||
pub model_name: String,
|
||||
pub encoding_name: String,
|
||||
pub trim_chunks: bool,
|
||||
}
|
||||
|
||||
impl Default for SplitterOptions {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SplitterOptions {
|
||||
pub fn new() -> Self {
|
||||
SplitterOptions {
|
||||
chunk_size: 7168,
|
||||
chunk_overlap: 128,
|
||||
model_name: String::from("gpt-3.5-turbo"),
|
||||
encoding_name: String::from("cl100k_base"),
|
||||
trim_chunks: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Builder pattern for Options struct
|
||||
impl SplitterOptions {
|
||||
pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
|
||||
self.chunk_size = chunk_size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_chunk_overlap(mut self, chunk_overlap: usize) -> Self {
|
||||
self.chunk_overlap = chunk_overlap;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_model_name(mut self, model_name: &str) -> Self {
|
||||
self.model_name = String::from(model_name);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_encoding_name(mut self, encoding_name: &str) -> Self {
|
||||
self.encoding_name = String::from(encoding_name);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_trim_chunks(mut self, trim_chunks: bool) -> Self {
|
||||
self.trim_chunks = trim_chunks;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get_tokenizer_from_str(s: &str) -> Option<Tokenizer> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"o200k_base" => Some(Tokenizer::O200kBase),
|
||||
"cl100k_base" => Some(Tokenizer::Cl100kBase),
|
||||
"p50k_base" => Some(Tokenizer::P50kBase),
|
||||
"r50k_base" => Some(Tokenizer::R50kBase),
|
||||
"p50k_edit" => Some(Tokenizer::P50kEdit),
|
||||
"gpt2" => Some(Tokenizer::Gpt2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&SplitterOptions> for ChunkConfig<CoreBPE> {
|
||||
type Error = TextSplitterError;
|
||||
|
||||
fn try_from(options: &SplitterOptions) -> Result<Self, Self::Error> {
|
||||
let tk = if !options.encoding_name.is_empty() {
|
||||
let tokenizer =
|
||||
SplitterOptions::get_tokenizer_from_str(&options.encoding_name).ok_or(TextSplitterError::TokenizerNotFound)?;
|
||||
|
||||
get_bpe_from_tokenizer(tokenizer).map_err(|_| TextSplitterError::InvalidTokenizer)?
|
||||
} else {
|
||||
get_bpe_from_model(&options.model_name).map_err(|_| TextSplitterError::InvalidModel)?
|
||||
};
|
||||
|
||||
Ok(
|
||||
ChunkConfig::new(options.chunk_size)
|
||||
.with_sizer(tk)
|
||||
.with_trim(options.trim_chunks)
|
||||
.with_overlap(options.chunk_overlap)?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user