mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-01 17:50:50 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e343802b2d | |||
| 7ea8800c99 | |||
| 16196c6ca1 | |||
| 9a9f243966 | |||
| e2624d93c7 | |||
| 766219d4e1 | |||
| 01d7ef88e3 | |||
| 154d9e975d | |||
| 24e07f73bb | |||
| d500e472f0 | |||
| 13d9fe506e | |||
| 1256d66938 | |||
| da7781a751 | |||
| a77d89bb1a | |||
| c51bdb74de | |||
| ac3c93ccfa |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use text_splitter::ChunkConfig;
|
||||
|
||||
/**
|
||||
* modified from https://github.com/Abraxas-365/langchain-rust/tree/v4.6.0/src/text_splitter
|
||||
*/
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenSplitter {
|
||||
splitter_options: SplitterOptions,
|
||||
}
|
||||
|
||||
impl Default for TokenSplitter {
|
||||
fn default() -> Self {
|
||||
TokenSplitter::new(SplitterOptions::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenSplitter {
|
||||
pub fn new(options: SplitterOptions) -> TokenSplitter {
|
||||
TokenSplitter {
|
||||
splitter_options: options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextSplitter for TokenSplitter {
|
||||
fn split_text(&self, text: &str) -> Result<Vec<String>, TextSplitterError> {
|
||||
let chunk_config = ChunkConfig::try_from(&self.splitter_options)?;
|
||||
Ok(
|
||||
text_splitter::TextSplitter::new(chunk_config)
|
||||
.chunks(text)
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Document {
|
||||
pub page_content: String,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Constructs a new `Document` with provided `page_content`, an empty
|
||||
/// `metadata` map and a `score` of 0.
|
||||
pub fn new<S: Into<String>>(page_content: S) -> Self {
|
||||
Document {
|
||||
page_content: page_content.into(),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the `metadata` Map of the `Document` to the provided HashMap.
|
||||
pub fn with_metadata(mut self, metadata: HashMap<String, Value>) -> Self {
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
/// Provides a default `Document` with an empty `page_content`, an empty
|
||||
/// `metadata` map and a `score` of 0.
|
||||
fn default() -> Self {
|
||||
Document {
|
||||
page_content: "".to_string(),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#[cfg(feature = "doc-loader")]
|
||||
pub mod doc_loader;
|
||||
#[cfg(feature = "ydoc-loader")]
|
||||
pub mod doc_parser;
|
||||
#[cfg(feature = "hashcash")]
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SpaceStorage,
|
||||
} from '../storage';
|
||||
import { Sync } from '../sync';
|
||||
import { DocSyncPeer } from '../sync/doc/peer';
|
||||
import { IndexerSyncImpl } from '../sync/indexer';
|
||||
import { expectYjsEqual } from './utils';
|
||||
|
||||
@@ -112,6 +113,64 @@ class TestDocStorage implements DocStorage {
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedRemoteDocStorage implements DocStorage {
|
||||
readonly storageType = 'doc' as const;
|
||||
readonly connection = new DummyConnection();
|
||||
readonly isReadonly = false;
|
||||
pushCount = 0;
|
||||
|
||||
constructor(readonly spaceId: string) {}
|
||||
|
||||
async getDoc(_docId: string): Promise<DocRecord | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDocDiff(
|
||||
_docId: string,
|
||||
_state?: Uint8Array
|
||||
): Promise<DocDiff | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async pushDocUpdate(_update: DocUpdate): Promise<DocClock> {
|
||||
this.pushCount++;
|
||||
const error = new Error('No permission to update doc');
|
||||
error.name = 'DOC_ACTION_DENIED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async getDocTimestamp(_docId: string): Promise<DocClock | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDocTimestamps(): Promise<DocClocks> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async deleteDoc(_docId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeDocUpdate(_callback: (update: DocRecord, origin?: string) => void) {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedConnection extends DummyConnection {
|
||||
waitCount = 0;
|
||||
|
||||
override async waitForConnected(_signal?: AbortSignal): Promise<void> {
|
||||
this.waitCount++;
|
||||
const error = new Error('No permission to access space');
|
||||
error.name = 'SPACE_ACCESS_DENIED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDeniedConnectionDocStorage extends PermissionDeniedRemoteDocStorage {
|
||||
override readonly connection = new PermissionDeniedConnection();
|
||||
}
|
||||
|
||||
class TrackingIndexerStorage extends IndexerStorageBase {
|
||||
override readonly connection = new DummyConnection();
|
||||
override readonly isReadonly = false;
|
||||
@@ -425,6 +484,201 @@ test('blob', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('doc sync peer stops retrying a doc when remote denies permission', async () => {
|
||||
const local = new IndexedDBDocStorage({
|
||||
id: 'ws-denied',
|
||||
flavour: 'local-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const syncMetadata = new IndexedDBDocSyncStorage({
|
||||
id: 'ws-denied',
|
||||
flavour: 'local-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remote = new PermissionDeniedRemoteDocStorage('ws-denied');
|
||||
const peer = new DocSyncPeer('remote-denied', local, syncMetadata, remote);
|
||||
const abort = new AbortController();
|
||||
|
||||
local.connection.connect();
|
||||
syncMetadata.connection.connect();
|
||||
await local.connection.waitForConnected();
|
||||
await syncMetadata.connection.waitForConnected();
|
||||
|
||||
const doc = new YDoc();
|
||||
doc.getMap('test').set('hello', 'world');
|
||||
await local.pushDocUpdate({
|
||||
docId: 'doc-denied',
|
||||
bin: encodeStateAsUpdate(doc),
|
||||
});
|
||||
|
||||
try {
|
||||
void peer.mainLoop(abort.signal);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remote.pushCount).toBe(1);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
let state:
|
||||
| {
|
||||
syncing: boolean;
|
||||
synced: boolean;
|
||||
retrying: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.docState$('doc-denied').subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
syncing: false,
|
||||
synced: false,
|
||||
retrying: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
let state:
|
||||
| {
|
||||
synced: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.peerState$.subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
synced: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(remote.pushCount).toBe(1);
|
||||
} finally {
|
||||
abort.abort();
|
||||
local.connection.disconnect();
|
||||
syncMetadata.connection.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('doc sync peer stops retrying when remote connection denies permission', async () => {
|
||||
const local = new IndexedDBDocStorage({
|
||||
id: 'ws-connection-denied',
|
||||
flavour: 'local-connection-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const syncMetadata = new IndexedDBDocSyncStorage({
|
||||
id: 'ws-connection-denied',
|
||||
flavour: 'local-connection-denied',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remote = new PermissionDeniedConnectionDocStorage(
|
||||
'ws-connection-denied'
|
||||
);
|
||||
const peer = new DocSyncPeer(
|
||||
'remote-connection-denied',
|
||||
local,
|
||||
syncMetadata,
|
||||
remote
|
||||
);
|
||||
const abort = new AbortController();
|
||||
|
||||
local.connection.connect();
|
||||
syncMetadata.connection.connect();
|
||||
await local.connection.waitForConnected();
|
||||
await syncMetadata.connection.waitForConnected();
|
||||
|
||||
try {
|
||||
void peer.mainLoop(abort.signal);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(remote.connection.waitCount).toBe(1);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
let state:
|
||||
| {
|
||||
retrying: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.peerState$.subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
retrying: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(remote.connection.waitCount).toBe(1);
|
||||
} finally {
|
||||
abort.abort();
|
||||
local.connection.disconnect();
|
||||
syncMetadata.connection.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('doc sync peer resolves on terminal permission error without abort signal', async () => {
|
||||
const local = new IndexedDBDocStorage({
|
||||
id: 'ws-connection-denied-no-signal',
|
||||
flavour: 'local-connection-denied-no-signal',
|
||||
type: 'workspace',
|
||||
});
|
||||
const syncMetadata = new IndexedDBDocSyncStorage({
|
||||
id: 'ws-connection-denied-no-signal',
|
||||
flavour: 'local-connection-denied-no-signal',
|
||||
type: 'workspace',
|
||||
});
|
||||
const remote = new PermissionDeniedConnectionDocStorage(
|
||||
'ws-connection-denied-no-signal'
|
||||
);
|
||||
const peer = new DocSyncPeer(
|
||||
'remote-connection-denied-no-signal',
|
||||
local,
|
||||
syncMetadata,
|
||||
remote
|
||||
);
|
||||
|
||||
local.connection.connect();
|
||||
syncMetadata.connection.connect();
|
||||
await local.connection.waitForConnected();
|
||||
await syncMetadata.connection.waitForConnected();
|
||||
|
||||
try {
|
||||
await expect(peer.mainLoop()).resolves.toBeUndefined();
|
||||
expect(remote.connection.waitCount).toBe(1);
|
||||
|
||||
let state:
|
||||
| {
|
||||
retrying: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const dispose = peer.peerState$.subscribe(next => {
|
||||
state = next;
|
||||
});
|
||||
dispose.unsubscribe();
|
||||
|
||||
expect(state).toMatchObject({
|
||||
retrying: false,
|
||||
errorMessage: expect.stringContaining('No permission'),
|
||||
});
|
||||
} finally {
|
||||
local.connection.disconnect();
|
||||
syncMetadata.connection.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('indexer defers indexed clock persistence until a refresh happens on delayed refresh storages', async () => {
|
||||
const calls: string[] = [];
|
||||
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);
|
||||
|
||||
@@ -22,6 +22,12 @@ interface CloudDocStorageOptions extends DocStorageOptions {
|
||||
type: SpaceType;
|
||||
}
|
||||
|
||||
function createWebsocketError(error: { name: string; message: string }) {
|
||||
const err = new Error(error.message);
|
||||
err.name = error.name;
|
||||
return err;
|
||||
}
|
||||
|
||||
export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
static readonly identifier = 'CloudDocStorage';
|
||||
|
||||
@@ -88,7 +94,7 @@ export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
return null;
|
||||
}
|
||||
// TODO: use [UserFriendlyError]
|
||||
throw new Error(response.error.message);
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -111,7 +117,7 @@ export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
return null;
|
||||
}
|
||||
// TODO: use [UserFriendlyError]
|
||||
throw new Error(response.error.message);
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -132,7 +138,7 @@ export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
|
||||
if ('error' in response) {
|
||||
// TODO(@forehalo): use [UserFriendlyError]
|
||||
throw new Error(response.error.message);
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -153,7 +159,7 @@ export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
|
||||
if ('error' in response) {
|
||||
// TODO: use [UserFriendlyError]
|
||||
throw new Error(response.error.message);
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -174,7 +180,7 @@ export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
|
||||
if ('error' in response) {
|
||||
// TODO(@forehalo): use [UserFriendlyError]
|
||||
throw new Error(response.error.message);
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
|
||||
return Object.entries(response.data).reduce((ret, [docId, timestamp]) => {
|
||||
@@ -184,11 +190,16 @@ export class CloudDocStorage extends DocStorageBase<CloudDocStorageOptions> {
|
||||
}
|
||||
|
||||
override async deleteDoc(docId: string) {
|
||||
this.socket.emit('space:delete-doc', {
|
||||
const response = await this.socket.emitWithAck('space:delete-doc', {
|
||||
spaceType: this.spaceType,
|
||||
spaceId: this.spaceId,
|
||||
docId: this.idConverter.newIdToOldId(docId),
|
||||
});
|
||||
|
||||
if ('error' in response) {
|
||||
// TODO(@forehalo): use [UserFriendlyError]
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
protected async setDocSnapshot() {
|
||||
@@ -224,7 +235,7 @@ class CloudDocStorageConnection extends SocketConnection {
|
||||
});
|
||||
|
||||
if ('error' in res) {
|
||||
throw new Error(res.error.message);
|
||||
throw createWebsocketError(res.error);
|
||||
}
|
||||
|
||||
if (!this.idConverter) {
|
||||
@@ -272,7 +283,7 @@ class CloudDocStorageConnection extends SocketConnection {
|
||||
return null;
|
||||
}
|
||||
// TODO: use [UserFriendlyError]
|
||||
throw new Error(response.error.message);
|
||||
throw createWebsocketError(response.error);
|
||||
}
|
||||
|
||||
return base64ToUint8Array(response.data.missing);
|
||||
|
||||
@@ -121,7 +121,10 @@ interface ClientEvents {
|
||||
timestamp: number;
|
||||
},
|
||||
];
|
||||
'space:delete-doc': { spaceType: string; spaceId: string; docId: string };
|
||||
'space:delete-doc': [
|
||||
{ spaceType: string; spaceId: string; docId: string },
|
||||
{ success?: true },
|
||||
];
|
||||
|
||||
'telemetry:batch': [TelemetryBatch, TelemetryAck];
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ type Job =
|
||||
interface Status {
|
||||
docs: Set<string>;
|
||||
connectedDocs: Set<string>;
|
||||
docErrors: Map<string, string>;
|
||||
jobDocQueue: AsyncPriorityQueue;
|
||||
jobMap: Map<string, Job[]>;
|
||||
remoteClocks: ClockMap;
|
||||
@@ -78,9 +79,12 @@ function createJobErrorCatcher<
|
||||
await fn(docId, ...args);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw new Error(
|
||||
const wrapped = new Error(
|
||||
`Error in job "${k}": ${err.stack || err.message}`
|
||||
);
|
||||
wrapped.name = err.name;
|
||||
(wrapped as Error & { cause?: unknown }).cause = err;
|
||||
throw wrapped;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -91,6 +95,14 @@ function createJobErrorCatcher<
|
||||
) as Jobs;
|
||||
}
|
||||
|
||||
function isRemotePermissionError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const name = error.name.toUpperCase();
|
||||
return name === 'DOC_ACTION_DENIED' || name === 'SPACE_ACCESS_DENIED';
|
||||
}
|
||||
|
||||
function isEqualUint8Arrays(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
@@ -155,6 +167,7 @@ export class DocSyncPeer {
|
||||
private status: Status = {
|
||||
docs: new Set<string>(),
|
||||
connectedDocs: new Set<string>(),
|
||||
docErrors: new Map<string, string>(),
|
||||
jobDocQueue: new AsyncPriorityQueue(),
|
||||
jobMap: new Map(),
|
||||
remoteClocks: new ClockMap(new Map()),
|
||||
@@ -165,6 +178,14 @@ export class DocSyncPeer {
|
||||
};
|
||||
private readonly statusUpdatedSubject$ = new Subject<string | true>();
|
||||
|
||||
private get currentErrorMessage() {
|
||||
return (
|
||||
this.status.errorMessage ??
|
||||
this.status.docErrors.values().next().value ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
peerState$ = new Observable<PeerState>(subscribe => {
|
||||
const next = () => {
|
||||
if (this.status.skipped) {
|
||||
@@ -182,7 +203,7 @@ export class DocSyncPeer {
|
||||
syncing: this.status.docs.size,
|
||||
synced: false,
|
||||
retrying: this.status.retrying,
|
||||
errorMessage: this.status.errorMessage,
|
||||
errorMessage: this.currentErrorMessage,
|
||||
});
|
||||
} else {
|
||||
const syncing = this.status.jobMap.size;
|
||||
@@ -190,8 +211,8 @@ export class DocSyncPeer {
|
||||
total: this.status.docs.size,
|
||||
syncing: syncing,
|
||||
retrying: this.status.retrying,
|
||||
errorMessage: this.status.errorMessage,
|
||||
synced: syncing === 0,
|
||||
errorMessage: this.currentErrorMessage,
|
||||
synced: syncing === 0 && this.status.docErrors.size === 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -211,6 +232,7 @@ export class DocSyncPeer {
|
||||
docState$(docId: string) {
|
||||
return new Observable<PeerDocState>(subscribe => {
|
||||
const next = () => {
|
||||
const docErrorMessage = this.status.docErrors.get(docId) ?? null;
|
||||
if (this.status.skipped) {
|
||||
subscribe.next({
|
||||
syncing: false,
|
||||
@@ -218,14 +240,16 @@ export class DocSyncPeer {
|
||||
retrying: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
subscribe.next({
|
||||
syncing:
|
||||
!this.status.connectedDocs.has(docId) ||
|
||||
this.status.jobMap.has(docId),
|
||||
synced: !this.status.jobMap.has(docId),
|
||||
!docErrorMessage &&
|
||||
(!this.status.connectedDocs.has(docId) ||
|
||||
this.status.jobMap.has(docId)),
|
||||
synced: !docErrorMessage && !this.status.jobMap.has(docId),
|
||||
retrying: this.status.retrying,
|
||||
errorMessage: this.status.errorMessage,
|
||||
errorMessage: docErrorMessage ?? this.status.errorMessage,
|
||||
});
|
||||
};
|
||||
next();
|
||||
@@ -469,6 +493,9 @@ export class DocSyncPeer {
|
||||
|
||||
private readonly actions = {
|
||||
updateRemoteClock: (docId: string, remoteClock: Date) => {
|
||||
if (this.status.docErrors.has(docId)) {
|
||||
return;
|
||||
}
|
||||
this.status.remoteClocks.setIfBigger(docId, remoteClock);
|
||||
this.statusUpdatedSubject$.next(docId);
|
||||
},
|
||||
@@ -494,6 +521,10 @@ export class DocSyncPeer {
|
||||
update: Uint8Array;
|
||||
clock: Date;
|
||||
}) => {
|
||||
if (this.status.docErrors.has(docId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// try add doc for new doc
|
||||
this.actions.addDoc(docId);
|
||||
|
||||
@@ -514,6 +545,10 @@ export class DocSyncPeer {
|
||||
update: Uint8Array;
|
||||
remoteClock: Date;
|
||||
}) => {
|
||||
if (this.status.docErrors.has(docId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// try add doc for new doc
|
||||
this.actions.addDoc(docId);
|
||||
this.actions.updateRemoteClock(docId, remoteClock);
|
||||
@@ -530,33 +565,45 @@ export class DocSyncPeer {
|
||||
|
||||
async mainLoop(signal?: AbortSignal) {
|
||||
while (true) {
|
||||
let shouldRetry = true;
|
||||
try {
|
||||
await this.retryLoop(signal);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
console.warn('Sync error, retry in 5s', err);
|
||||
shouldRetry = !isRemotePermissionError(err);
|
||||
console.warn(
|
||||
shouldRetry
|
||||
? 'Sync error, retry in 5s'
|
||||
: 'Sync stopped due to remote permission error',
|
||||
err
|
||||
);
|
||||
this.status.errorMessage =
|
||||
err instanceof Error ? err.message : `${err}`;
|
||||
this.status.retrying = shouldRetry;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
} finally {
|
||||
// reset all status
|
||||
this.status = {
|
||||
docs: new Set(),
|
||||
connectedDocs: new Set(),
|
||||
docErrors: new Map(),
|
||||
jobDocQueue: new AsyncPriorityQueue(),
|
||||
jobMap: new Map(),
|
||||
remoteClocks: new ClockMap(new Map()),
|
||||
syncing: false,
|
||||
skipped: false,
|
||||
// tell ui to show retrying status
|
||||
retrying: true,
|
||||
retrying: shouldRetry,
|
||||
// error message from last retry
|
||||
errorMessage: this.status.errorMessage,
|
||||
};
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
if (!shouldRetry) {
|
||||
return;
|
||||
}
|
||||
// wait for 5s before next retry
|
||||
await Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
@@ -725,29 +772,53 @@ export class DocSyncPeer {
|
||||
|
||||
const connect = remove(jobs, j => j.type === 'connect');
|
||||
if (connect && connect.length > 0) {
|
||||
await this.jobs.connect(docId, signal);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.connect(docId, signal)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const pullAndPush = remove(jobs, j => j.type === 'pullAndPush');
|
||||
if (pullAndPush && pullAndPush.length > 0) {
|
||||
await this.jobs.pullAndPush(docId, signal);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.pullAndPush(docId, signal)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const pull = remove(jobs, j => j.type === 'pull');
|
||||
if (pull && pull.length > 0) {
|
||||
await this.jobs.pull(docId, signal);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.pull(docId, signal)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const push = remove(jobs, j => j.type === 'push');
|
||||
if (push && push.length > 0) {
|
||||
await this.jobs.push(
|
||||
docId,
|
||||
push as (Job & { type: 'push' })[],
|
||||
signal
|
||||
);
|
||||
if (
|
||||
!(await this.runRemoteDocJob(docId, () =>
|
||||
this.jobs.push(
|
||||
docId,
|
||||
push as (Job & { type: 'push' })[],
|
||||
signal
|
||||
)
|
||||
))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -771,7 +842,40 @@ export class DocSyncPeer {
|
||||
}
|
||||
}
|
||||
|
||||
private async runRemoteDocJob(docId: string, job: () => Promise<void>) {
|
||||
try {
|
||||
await job();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!isRemotePermissionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('Sync skipped for doc due to remote permission error', {
|
||||
docId,
|
||||
error,
|
||||
});
|
||||
this.status.docErrors.set(docId, message);
|
||||
this.status.connectedDocs.delete(docId);
|
||||
this.status.jobMap.delete(docId);
|
||||
this.statusUpdatedSubject$.next(docId);
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(job: Job) {
|
||||
if (
|
||||
this.status.docErrors.has(job.docId) &&
|
||||
(job.type === 'connect' ||
|
||||
job.type === 'push' ||
|
||||
job.type === 'pull' ||
|
||||
job.type === 'pullAndPush')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = this.prioritySettings.get(job.docId) ?? 0;
|
||||
this.status.jobDocQueue.push(job.docId, priority);
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
[package]
|
||||
authors = [
|
||||
"DarkSky <darksky2048@gmail.com>",
|
||||
"forehalo <forehalo@gmail.com>",
|
||||
"x1a0t <405028157@qq.com>",
|
||||
"Brooklyn <lynweklm@gmail.com>",
|
||||
]
|
||||
description = "High-performance and thread-safe CRDT implementation compatible with Yjs"
|
||||
edition = "2024"
|
||||
homepage = "https://github.com/toeverything/y-octo"
|
||||
include = ["src/**/*", "benches/**/*", "bin/**/*", "LICENSE", "README.md"]
|
||||
keywords = ["collaboration", "crdt", "crdts", "yjs", "yata"]
|
||||
license = "MIT"
|
||||
name = "y-octo"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/toeverything/y-octo"
|
||||
version = "0.0.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
ahash = { workspace = true }
|
||||
byteorder = { workspace = true }
|
||||
log = { workspace = true }
|
||||
nanoid = { workspace = true }
|
||||
nom = { workspace = true }
|
||||
ordered-float = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
rand_chacha = { workspace = true }
|
||||
rand_distr = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
smol_str = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[features]
|
||||
bench = []
|
||||
debug = []
|
||||
default = []
|
||||
events = []
|
||||
large_refs = []
|
||||
serde_json = []
|
||||
subscribe = []
|
||||
|
||||
[target.'cfg(fuzzing)'.dependencies]
|
||||
arbitrary = { workspace = true }
|
||||
ordered-float = { workspace = true, features = ["arbitrary"] }
|
||||
|
||||
[target.'cfg(loom)'.dependencies]
|
||||
loom = { workspace = true }
|
||||
# override the dev-dependencies feature
|
||||
async-lock = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert-json-diff = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
lib0 = { workspace = true }
|
||||
ordered-float = { workspace = true, features = ["proptest"] }
|
||||
path-ext = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
proptest-derive = { workspace = true }
|
||||
yrs = { workspace = true }
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = [
|
||||
'cfg(debug)',
|
||||
'cfg(fuzzing)',
|
||||
'cfg(loom)',
|
||||
] }
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "array_ops_benchmarks"
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "codec_benchmarks"
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "map_ops_benchmarks"
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "text_ops_benchmarks"
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "apply_benchmarks"
|
||||
|
||||
[[bench]]
|
||||
harness = false
|
||||
name = "update_benchmarks"
|
||||
|
||||
[lib]
|
||||
bench = true
|
||||
@@ -1,9 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022-present TOEVERYTHING PTE. LTD. and its affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,100 +0,0 @@
|
||||
# Y-Octo
|
||||
|
||||
[](https://github.com/toeverything/y-octo/actions/workflows/y-octo.yml)
|
||||
[![docs]](https://docs.rs/y-octo/latest/y_octo)
|
||||
[![crates]](https://crates.io/crates/y-octo)
|
||||
[![codecov]](https://codecov.io/gh/toeverything/y-octo)
|
||||
|
||||
Y-Octo is a high-performance CRDT implementation compatible with [yjs].
|
||||
|
||||
## Introduction
|
||||
|
||||
Y-Octo is a tiny, ultra-fast CRDT collaboration library built for all major platforms. Developers can use Y-Octo as the [Single source of truth](https://en.wikipedia.org/wiki/Single_source_of_truth) for their application state, naturally turning the application into a [local-first](https://www.inkandswitch.com/local-first/) collaborative app.
|
||||
|
||||
Y-Octo also has interoperability and binary compatibility with [yjs]. Developers can use [yjs] to develop local-first web applications and collaborate with Y-Octo in native apps alongside web apps.
|
||||
|
||||
## Who are using
|
||||
|
||||
<a href="https://affine.pro"><img src="./assets/affine.svg" /></a>
|
||||
|
||||
[AFFiNE](https://affine.pro) is using y-octo in production. There are [Electron](https://affine.pro/download) app and [Node.js server](https://github.com/toeverything/AFFiNE/tree/canary/packages/backend/native) using y-octo in production.
|
||||
|
||||
<a href="https://www.mysc.app/"><img src="https://www.mysc.app/images/logo_blk.webp" width="120px" /></a>
|
||||
|
||||
[Mysc](https://www.mysc.app/) is using y-octo in the Rust server, and the iOS/Android client via the Swift/Kotlin bindings (Official bindings coming soon).
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Collaborative Text
|
||||
- ✅ Read and write styled Unicode compatible data.
|
||||
- 🚧 Add, modify and delete text styles.
|
||||
- 🚧 Embedded JS data types and collaborative types.
|
||||
- ✅ Collaborative types of thread-safe.
|
||||
- Collaborative Array
|
||||
- ✅ Add, modify, and delete basic JS data types.
|
||||
- ✅ Recursively add, modify, and delete collaborative types.
|
||||
- ✅ Collaborative types of thread-safe.
|
||||
- 🚧 Recursive event subscription
|
||||
- Collaborative Map
|
||||
- ✅ Add, modify, and delete basic JS data types.
|
||||
- ✅ Recursively add, modify, and delete collaborative types.
|
||||
- ✅ Collaborative types of thread-safe.
|
||||
- 🚧 Recursive event subscription
|
||||
- 🚧 Collaborative Xml (Fragment / Element)
|
||||
- ✅ Collaborative Doc Container
|
||||
- ✅ YATA CRDT state apply/diff compatible with [yjs]
|
||||
- ✅ State sync of thread-safe.
|
||||
- ✅ Store all collaborative types and JS data types
|
||||
- ✅ Update event subscription.
|
||||
- 🚧 Sub Document.
|
||||
- ✅ Yjs binary encoding
|
||||
- ✅ Awareness encoding.
|
||||
- ✅ Primitive type encoding.
|
||||
- ✅ Sync Protocol encoding.
|
||||
- ✅ Yjs update v1 encoding.
|
||||
- 🚧 Yjs update v2 encoding.
|
||||
|
||||
## Testing & Linting
|
||||
|
||||
Put everything to the test! We've established various test suites, but we're continually striving to enhance our coverage:
|
||||
|
||||
- Rust Tests
|
||||
- Unit tests
|
||||
- [Loom](https://docs.rs/loom/latest/loom/) multi-threading tests
|
||||
- [Miri](https://github.com/rust-lang/miri) undefined behavior tests
|
||||
- [Address Sanitizer](https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html) memory error detections
|
||||
- [Fuzzing](https://github.com/rust-fuzz/cargo-fuzz) fuzzing tests
|
||||
- Node Tests
|
||||
- Smoke Tests
|
||||
- Eslint, Clippy
|
||||
|
||||
## Related projects
|
||||
|
||||
- [OctoBase]: The open-source embedded database based on Y-Octo.
|
||||
- [yjs]: Shared data types for building collaborative software in web.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- [DarkSky](https://github.com/darkskygit)
|
||||
- [liuyi](https://github.com/forehalo)
|
||||
- [LongYinan](https://github.com/Brooooooklyn)
|
||||
|
||||
## Why not [yrs](https://github.com/y-crdt/y-crdt/)
|
||||
|
||||
See [Why we're not using yrs](./y-octo-utils/yrs-is-unsafe/README.md)
|
||||
|
||||
## License
|
||||
|
||||
Y-Octo are [MIT licensed].
|
||||
|
||||
[codecov]: https://codecov.io/gh/toeverything/y-octo/graph/badge.svg?token=9AQY5Q1BYH
|
||||
[crates]: https://img.shields.io/crates/v/y-octo.svg
|
||||
[docs]: https://img.shields.io/docsrs/y-octo.svg
|
||||
[test]: https://github.com/toeverything/y-octo/actions/workflows/y-octo.yml/badge.svg
|
||||
[yjs]: https://github.com/yjs/yjs
|
||||
[Address Sanitizer]: https://github.com/toeverything/y-octo/actions/workflows/y-octo-asan.yml/badge.svg
|
||||
[Memory Leak Detect]: https://github.com/toeverything/y-octo/actions/workflows/y-octo-memory-test.yml/badge.svg
|
||||
[OctoBase]: https://github.com/toeverything/octobase
|
||||
[BlockSuite]: https://github.com/toeverything/blocksuite
|
||||
[AFFiNE]: https://github.com/toeverything/affine
|
||||
[MIT licensed]: ./LICENSE
|
||||
@@ -1,34 +0,0 @@
|
||||
mod utils;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||
use path_ext::PathExt;
|
||||
use utils::Files;
|
||||
|
||||
fn apply(c: &mut Criterion) {
|
||||
let files = Files::load();
|
||||
|
||||
let mut group = c.benchmark_group("apply");
|
||||
group.measurement_time(Duration::from_secs(15));
|
||||
|
||||
for file in &files.files {
|
||||
group.throughput(Throughput::Bytes(file.content.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("apply with jwst", file.path.name_str()),
|
||||
&file.content,
|
||||
|b, content| {
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let mut doc = Doc::new();
|
||||
doc.apply_update_from_binary_v1(content.clone()).unwrap()
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, apply);
|
||||
criterion_main!(benches);
|
||||
@@ -1,71 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
fn operations(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ops/array");
|
||||
group.measurement_time(Duration::from_secs(15));
|
||||
|
||||
group.bench_function("jwst/insert", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9";
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(1234);
|
||||
|
||||
let idxs = (0..99)
|
||||
.map(|_| rng.random_range(0..base_text.len() as u64))
|
||||
.collect::<Vec<_>>();
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut array = doc.get_or_create_array("test").unwrap();
|
||||
for c in base_text.chars() {
|
||||
array.push(c.to_string()).unwrap();
|
||||
}
|
||||
for idx in &idxs {
|
||||
array.insert(*idx, "test").unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("jwst/insert range", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9";
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(1234);
|
||||
|
||||
let idxs = (0..99)
|
||||
.map(|_| rng.random_range(0..base_text.len() as u64))
|
||||
.collect::<Vec<_>>();
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut array = doc.get_or_create_array("test").unwrap();
|
||||
for c in base_text.chars() {
|
||||
array.push(c.to_string()).unwrap();
|
||||
}
|
||||
for idx in &idxs {
|
||||
array.insert(*idx, "test1").unwrap();
|
||||
array.insert(idx + 1, "test2").unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("jwst/remove", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9";
|
||||
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut array = doc.get_or_create_array("test").unwrap();
|
||||
for c in base_text.chars() {
|
||||
array.push(c.to_string()).unwrap();
|
||||
}
|
||||
for idx in (0..base_text.len() as u64).rev() {
|
||||
array.remove(idx, 1).unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, operations);
|
||||
criterion_main!(benches);
|
||||
@@ -1,91 +0,0 @@
|
||||
use criterion::{Criterion, SamplingMode, criterion_group, criterion_main};
|
||||
use y_octo::{read_var_i32, read_var_u64, write_var_i32, write_var_u64};
|
||||
|
||||
const BENCHMARK_SIZE: u32 = 100000;
|
||||
|
||||
fn codec(c: &mut Criterion) {
|
||||
let mut codec_group = c.benchmark_group("codec");
|
||||
codec_group.sampling_mode(SamplingMode::Flat);
|
||||
|
||||
{
|
||||
codec_group.bench_function("jwst encode var_int (32 bit)", |b| {
|
||||
b.iter(|| {
|
||||
let mut encoder = Vec::with_capacity(BENCHMARK_SIZE as usize * 8);
|
||||
for i in 0..(BENCHMARK_SIZE as i32) {
|
||||
write_var_i32(&mut encoder, i).unwrap();
|
||||
}
|
||||
})
|
||||
});
|
||||
codec_group.bench_function("jwst decode var_int (32 bit)", |b| {
|
||||
let mut encoder = Vec::with_capacity(BENCHMARK_SIZE as usize * 8);
|
||||
for i in 0..(BENCHMARK_SIZE as i32) {
|
||||
write_var_i32(&mut encoder, i).unwrap();
|
||||
}
|
||||
|
||||
b.iter(|| {
|
||||
let mut decoder = encoder.as_slice();
|
||||
for i in 0..(BENCHMARK_SIZE as i32) {
|
||||
let (tail, num) = read_var_i32(decoder).unwrap();
|
||||
decoder = tail;
|
||||
assert_eq!(num, i);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
codec_group.bench_function("jwst encode var_uint (32 bit)", |b| {
|
||||
b.iter(|| {
|
||||
let mut encoder = Vec::with_capacity(BENCHMARK_SIZE as usize * 8);
|
||||
for i in 0..BENCHMARK_SIZE {
|
||||
write_var_u64(&mut encoder, i as u64).unwrap();
|
||||
}
|
||||
})
|
||||
});
|
||||
codec_group.bench_function("jwst decode var_uint (32 bit)", |b| {
|
||||
let mut encoder = Vec::with_capacity(BENCHMARK_SIZE as usize * 8);
|
||||
for i in 0..BENCHMARK_SIZE {
|
||||
write_var_u64(&mut encoder, i as u64).unwrap();
|
||||
}
|
||||
|
||||
b.iter(|| {
|
||||
let mut decoder = encoder.as_slice();
|
||||
for i in 0..BENCHMARK_SIZE {
|
||||
let (tail, num) = read_var_u64(decoder).unwrap();
|
||||
decoder = tail;
|
||||
assert_eq!(num as u32, i);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
codec_group.bench_function("jwst encode var_uint (64 bit)", |b| {
|
||||
b.iter(|| {
|
||||
let mut encoder = Vec::with_capacity(BENCHMARK_SIZE as usize * 8);
|
||||
for i in 0..(BENCHMARK_SIZE as u64) {
|
||||
write_var_u64(&mut encoder, i).unwrap();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
codec_group.bench_function("jwst decode var_uint (64 bit)", |b| {
|
||||
let mut encoder = Vec::with_capacity(BENCHMARK_SIZE as usize * 8);
|
||||
for i in 0..(BENCHMARK_SIZE as u64) {
|
||||
write_var_u64(&mut encoder, i).unwrap();
|
||||
}
|
||||
|
||||
b.iter(|| {
|
||||
let mut decoder = encoder.as_slice();
|
||||
for i in 0..(BENCHMARK_SIZE as u64) {
|
||||
let (tail, num) = read_var_u64(decoder).unwrap();
|
||||
decoder = tail;
|
||||
assert_eq!(num, i);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group!(benches, codec);
|
||||
criterion_main!(benches);
|
||||
@@ -1,65 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
|
||||
fn operations(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ops/map");
|
||||
group.measurement_time(Duration::from_secs(15));
|
||||
|
||||
group.bench_function("jwst/insert", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9"
|
||||
.split(' ')
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut map = doc.get_or_create_map("test").unwrap();
|
||||
for (idx, key) in base_text.iter().enumerate() {
|
||||
map.insert(key.to_string(), idx).unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("jwst/get", |b| {
|
||||
use y_octo::*;
|
||||
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9"
|
||||
.split(' ')
|
||||
.collect::<Vec<_>>();
|
||||
let doc = Doc::default();
|
||||
let mut map = doc.get_or_create_map("test").unwrap();
|
||||
for (idx, key) in base_text.iter().enumerate() {
|
||||
map.insert(key.to_string(), idx).unwrap();
|
||||
}
|
||||
|
||||
b.iter(|| {
|
||||
for key in &base_text {
|
||||
map.get(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("jwst/remove", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9"
|
||||
.split(' ')
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut map = doc.get_or_create_map("test").unwrap();
|
||||
for (idx, key) in base_text.iter().enumerate() {
|
||||
map.insert(key.to_string(), idx).unwrap();
|
||||
}
|
||||
for key in &base_text {
|
||||
map.remove(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, operations);
|
||||
criterion_main!(benches);
|
||||
@@ -1,50 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
fn operations(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ops/text");
|
||||
group.measurement_time(Duration::from_secs(15));
|
||||
|
||||
group.bench_function("jwst/insert", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9";
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(1234);
|
||||
|
||||
let idxs = (0..99)
|
||||
.map(|_| rng.random_range(0..base_text.len() as u64))
|
||||
.collect::<Vec<_>>();
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut text = doc.get_or_create_text("test").unwrap();
|
||||
|
||||
text.insert(0, base_text).unwrap();
|
||||
for idx in &idxs {
|
||||
text.insert(*idx, "test").unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("jwst/remove", |b| {
|
||||
let base_text = "test1 test2 test3 test4 test5 test6 test7 test8 test9";
|
||||
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let doc = Doc::default();
|
||||
let mut text = doc.get_or_create_text("test").unwrap();
|
||||
|
||||
text.insert(0, base_text).unwrap();
|
||||
text.insert(0, base_text).unwrap();
|
||||
text.insert(0, base_text).unwrap();
|
||||
for idx in (0..base_text.len() as u64).rev() {
|
||||
text.remove(idx, 1).unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, operations);
|
||||
criterion_main!(benches);
|
||||
@@ -1,34 +0,0 @@
|
||||
mod utils;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||
use path_ext::PathExt;
|
||||
use utils::Files;
|
||||
|
||||
fn update(c: &mut Criterion) {
|
||||
let files = Files::load();
|
||||
|
||||
let mut group = c.benchmark_group("update");
|
||||
group.measurement_time(Duration::from_secs(15));
|
||||
|
||||
for file in &files.files {
|
||||
group.throughput(Throughput::Bytes(file.content.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("parse with jwst", file.path.name_str()),
|
||||
&file.content,
|
||||
|b, content| {
|
||||
b.iter(|| {
|
||||
use y_octo::*;
|
||||
let mut decoder = RawDecoder::new(content);
|
||||
Update::read(&mut decoder).unwrap()
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, update);
|
||||
criterion_main!(benches);
|
||||
@@ -1,42 +0,0 @@
|
||||
use std::{
|
||||
fs::{read, read_dir},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use path_ext::PathExt;
|
||||
|
||||
pub struct File {
|
||||
pub path: PathBuf,
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
const BASE: &str = "src/fixtures/";
|
||||
|
||||
impl File {
|
||||
fn new(path: &Path) -> Self {
|
||||
let content = read(path).unwrap();
|
||||
Self {
|
||||
path: path.into(),
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Files {
|
||||
pub files: Vec<File>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
pub fn load() -> Self {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(BASE);
|
||||
|
||||
let files = read_dir(path).unwrap();
|
||||
let files = files
|
||||
.flatten()
|
||||
.filter(|f| f.path().is_file() && f.path().ext_str() == "bin")
|
||||
.map(|f| File::new(&f.path()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Self { files }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mod files;
|
||||
|
||||
pub use files::Files;
|
||||
@@ -1,78 +0,0 @@
|
||||
use std::io::{Error, Write};
|
||||
|
||||
use nom::bytes::complete::take;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn read_var_buffer(input: &[u8]) -> IResult<&[u8], &[u8]> {
|
||||
let (tail, len) = read_var_u64(input)?;
|
||||
let (tail, val) = take(len as usize)(tail)?;
|
||||
Ok((tail, val))
|
||||
}
|
||||
|
||||
pub fn write_var_buffer<W: Write>(buffer: &mut W, data: &[u8]) -> Result<(), Error> {
|
||||
write_var_u64(buffer, data.len() as u64)?;
|
||||
buffer.write_all(data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nom::{
|
||||
AsBytes, Err,
|
||||
error::{Error, ErrorKind},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_read_var_buffer() {
|
||||
// Test case 1: valid input, buffer length = 5
|
||||
let input = [0x05, 0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let expected_output = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let result = read_var_buffer(&input);
|
||||
assert_eq!(result, Ok((&[][..], &expected_output[..])));
|
||||
|
||||
// Test case 2: truncated input, missing buffer
|
||||
let input = [0x05, 0x01, 0x02, 0x03];
|
||||
let result = read_var_buffer(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[1..], ErrorKind::Eof))));
|
||||
|
||||
// Test case 3: invalid input
|
||||
let input = [0xFF, 0x01, 0x02, 0x03];
|
||||
let result = read_var_buffer(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[2..], ErrorKind::Eof))));
|
||||
|
||||
// Test case 4: invalid var int encoding
|
||||
let input = [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01];
|
||||
let result = read_var_buffer(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[7..], ErrorKind::Eof))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_var_buf_codec() {
|
||||
test_var_buf_enc_dec(&[]);
|
||||
test_var_buf_enc_dec(&[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
test_var_buf_enc_dec(b"test_var_buf_enc_dec");
|
||||
|
||||
#[cfg(not(miri))]
|
||||
{
|
||||
use rand::{Rng, rng};
|
||||
let mut rng = rng();
|
||||
for _ in 0..100 {
|
||||
test_var_buf_enc_dec(&{
|
||||
let mut bytes = vec![0u8; rng.random_range(0..u16::MAX as usize)];
|
||||
rng.fill(&mut bytes[..]);
|
||||
bytes
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_var_buf_enc_dec(data: &[u8]) {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
write_var_buffer(&mut buf, data).unwrap();
|
||||
let result = read_var_buffer(buf.as_bytes());
|
||||
assert_eq!(result, Ok((&[][..], data)));
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
use std::io::{Error, Write};
|
||||
|
||||
use byteorder::WriteBytesExt;
|
||||
use nom::Needed;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn read_var_u64(input: &[u8]) -> IResult<&[u8], u64> {
|
||||
// parse the first byte
|
||||
if let Some(next_byte) = input.first() {
|
||||
let mut shift = 7;
|
||||
let mut curr_byte = *next_byte;
|
||||
let mut rest = &input[1..];
|
||||
|
||||
// same logic in loop, but enable early exit when dealing with small numbers
|
||||
let mut num = (curr_byte & 0b0111_1111) as u64;
|
||||
|
||||
// if the sign bit is set, we need more bits
|
||||
while (curr_byte >> 7) & 0b1 != 0 {
|
||||
if let Some(next_byte) = rest.first() {
|
||||
curr_byte = *next_byte;
|
||||
// add the remaining 7 bits to the number
|
||||
num |= ((curr_byte & 0b0111_1111) as u64).wrapping_shl(shift);
|
||||
shift += 7;
|
||||
rest = &rest[1..];
|
||||
} else {
|
||||
return Err(nom::Err::Incomplete(Needed::new(input.len() + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((rest, num))
|
||||
} else {
|
||||
Err(nom::Err::Incomplete(Needed::new(1)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_var_u64<W: Write>(buffer: &mut W, mut num: u64) -> Result<(), Error> {
|
||||
// bit or 0b1000_0000 pre 7 bit if has more bits
|
||||
while num >= 0b10000000 {
|
||||
buffer.write_u8(num as u8 & 0b0111_1111 | 0b10000000)?;
|
||||
num >>= 7;
|
||||
}
|
||||
|
||||
buffer.write_u8((num & 0b01111111) as u8)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_var_i32(input: &[u8]) -> IResult<&[u8], i32> {
|
||||
// parse the first byte
|
||||
if let Some(next_byte) = input.first() {
|
||||
let mut shift = 6;
|
||||
let mut curr_byte = *next_byte;
|
||||
let mut rest: &[u8] = &input[1..];
|
||||
|
||||
// get the sign bit and the first 6 bits of the number
|
||||
let sign_bit = (curr_byte >> 6) & 0b1;
|
||||
let mut num = (curr_byte & 0b0011_1111) as i64;
|
||||
|
||||
// if the sign bit is set, we need more bits
|
||||
while (curr_byte >> 7) & 0b1 != 0 {
|
||||
if let Some(next_byte) = rest.first() {
|
||||
curr_byte = *next_byte;
|
||||
// add the remaining 7 bits to the number
|
||||
num |= ((curr_byte & 0b0111_1111) as i64).wrapping_shl(shift);
|
||||
shift += 7;
|
||||
rest = &rest[1..];
|
||||
} else {
|
||||
return Err(nom::Err::Incomplete(Needed::new(input.len() + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
// negate the number if the sign bit is set
|
||||
if sign_bit == 1 {
|
||||
num = -num;
|
||||
}
|
||||
|
||||
Ok((rest, num as i32))
|
||||
} else {
|
||||
Err(nom::Err::Incomplete(Needed::new(1)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_var_i32<W: Write>(buffer: &mut W, num: i32) -> Result<(), Error> {
|
||||
let mut num = num as i64;
|
||||
let is_negative = num < 0;
|
||||
if is_negative {
|
||||
num = -num;
|
||||
}
|
||||
|
||||
buffer.write_u8(
|
||||
// bit or 0b1000_0000 if has more bits
|
||||
if num > 0b00111111 { 0b10000000 } else { 0 }
|
||||
// bit or 0b0100_0000 if negative
|
||||
| if is_negative { 0b0100_0000 } else { 0 }
|
||||
// store last 6 bits
|
||||
| num as u8 & 0b0011_1111,
|
||||
)?;
|
||||
num >>= 6;
|
||||
while num > 0 {
|
||||
buffer.write_u8(
|
||||
// bit or 0b1000_0000 pre 7 bit if has more bits
|
||||
if num > 0b01111111 { 0b10000000 } else { 0 }
|
||||
// store last 7 bits
|
||||
| num as u8 & 0b0111_1111,
|
||||
)?;
|
||||
num >>= 7;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_var_uint_enc_dec(num: u64) {
|
||||
let mut buf = Vec::new();
|
||||
write_var_u64(&mut buf, num).unwrap();
|
||||
|
||||
let (rest, decoded_num) = read_var_u64(&buf).unwrap();
|
||||
assert_eq!(num, decoded_num);
|
||||
assert_eq!(rest.len(), 0);
|
||||
}
|
||||
|
||||
fn test_var_int_enc_dec(num: i32) {
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
write_var_i32(&mut buf, num).unwrap();
|
||||
|
||||
let (rest, decoded_num) = read_var_i32(&buf).unwrap();
|
||||
assert_eq!(num, decoded_num);
|
||||
assert_eq!(rest.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_var_uint_codec() {
|
||||
test_var_uint_enc_dec(0);
|
||||
test_var_uint_enc_dec(1);
|
||||
test_var_uint_enc_dec(127);
|
||||
test_var_uint_enc_dec(0b1000_0000);
|
||||
test_var_uint_enc_dec(0b1_0000_0000);
|
||||
test_var_uint_enc_dec(0b1_1111_1111);
|
||||
test_var_uint_enc_dec(0b10_0000_0000);
|
||||
test_var_uint_enc_dec(0b11_1111_1111);
|
||||
test_var_uint_enc_dec(0x7fff_ffff_ffff_ffff);
|
||||
test_var_uint_enc_dec(u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_var_int() {
|
||||
test_var_int_enc_dec(0);
|
||||
test_var_int_enc_dec(1);
|
||||
test_var_int_enc_dec(-1);
|
||||
test_var_int_enc_dec(63);
|
||||
test_var_int_enc_dec(-63);
|
||||
test_var_int_enc_dec(64);
|
||||
test_var_int_enc_dec(-64);
|
||||
test_var_int_enc_dec(i32::MAX);
|
||||
test_var_int_enc_dec(i32::MIN);
|
||||
test_var_int_enc_dec(((1 << 20) - 1) * 8);
|
||||
test_var_int_enc_dec(-((1 << 20) - 1) * 8);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
mod buffer;
|
||||
mod integer;
|
||||
mod string;
|
||||
|
||||
pub use buffer::{read_var_buffer, write_var_buffer};
|
||||
pub use integer::{read_var_i32, read_var_u64, write_var_i32, write_var_u64};
|
||||
pub use string::{read_var_string, write_var_string};
|
||||
|
||||
use super::*;
|
||||
@@ -1,75 +0,0 @@
|
||||
use std::io::{Error, Write};
|
||||
|
||||
use nom::{Parser, combinator::map_res};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn read_var_string(input: &[u8]) -> IResult<&[u8], String> {
|
||||
map_res(read_var_buffer, |s| String::from_utf8(s.to_vec())).parse(input)
|
||||
}
|
||||
|
||||
pub fn write_var_string<W: Write, S: AsRef<str>>(buffer: &mut W, input: S) -> Result<(), Error> {
|
||||
let bytes = input.as_ref().as_bytes();
|
||||
write_var_buffer(buffer, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nom::{
|
||||
AsBytes, Err,
|
||||
error::{Error, ErrorKind},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_read_var_string() {
|
||||
// Test case 1: valid input, string length = 5
|
||||
let input = [0x05, 0x68, 0x65, 0x6C, 0x6C, 0x6F];
|
||||
let expected_output = "hello".to_string();
|
||||
let result = read_var_string(&input);
|
||||
assert_eq!(result, Ok((&[][..], expected_output)));
|
||||
|
||||
// Test case 2: missing string length
|
||||
let input = [0x68, 0x65, 0x6C, 0x6C, 0x6F];
|
||||
let result = read_var_string(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[1..], ErrorKind::Eof))));
|
||||
|
||||
// Test case 3: truncated input
|
||||
let input = [0x05, 0x68, 0x65, 0x6C, 0x6C];
|
||||
let result = read_var_string(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[1..], ErrorKind::Eof))));
|
||||
|
||||
// Test case 4: invalid input
|
||||
let input = [0xFF, 0x01, 0x02, 0x03, 0x04];
|
||||
let result = read_var_string(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[2..], ErrorKind::Eof))));
|
||||
|
||||
// Test case 5: invalid var int encoding
|
||||
let input = [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01];
|
||||
let result = read_var_string(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[7..], ErrorKind::Eof))));
|
||||
|
||||
// Test case 6: invalid input, invalid UTF-8 encoding
|
||||
let input = [0x05, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||
let result = read_var_string(&input);
|
||||
assert_eq!(result, Err(Err::Error(Error::new(&input[..], ErrorKind::MapRes))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_var_str_codec() {
|
||||
test_var_str_enc_dec("".to_string());
|
||||
test_var_str_enc_dec(" ".to_string());
|
||||
test_var_str_enc_dec("abcde".to_string());
|
||||
test_var_str_enc_dec("🃒🃓🃟☗🀥🀫∺∼≂≇⓵➎⓷➏".to_string());
|
||||
}
|
||||
|
||||
fn test_var_str_enc_dec(input: String) {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
write_var_string(&mut buf, input.clone()).unwrap();
|
||||
let (rest, decoded_str) = read_var_string(buf.as_bytes()).unwrap();
|
||||
assert_eq!(decoded_str, input);
|
||||
assert_eq!(rest.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
use std::{cmp::max, collections::hash_map::Entry};
|
||||
|
||||
use super::*;
|
||||
use crate::sync::Arc;
|
||||
|
||||
pub type AwarenessCallback = Arc<dyn Fn(&Awareness, AwarenessEvent) + Send + Sync + 'static>;
|
||||
|
||||
pub struct Awareness {
|
||||
awareness: AwarenessStates,
|
||||
callback: Option<AwarenessCallback>,
|
||||
local_id: u64,
|
||||
}
|
||||
|
||||
impl Awareness {
|
||||
pub fn new(local_id: u64) -> Self {
|
||||
Self {
|
||||
awareness: AwarenessStates::new(),
|
||||
callback: None,
|
||||
local_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_id(&self) -> u64 {
|
||||
self.local_id
|
||||
}
|
||||
|
||||
pub fn on_update(&mut self, f: impl Fn(&Awareness, AwarenessEvent) + Send + Sync + 'static) {
|
||||
self.callback = Some(Arc::new(f));
|
||||
}
|
||||
|
||||
pub fn get_states(&self) -> &AwarenessStates {
|
||||
&self.awareness
|
||||
}
|
||||
|
||||
pub fn get_local_state(&self) -> Option<String> {
|
||||
self.awareness.get(&self.local_id).map(|state| state.content.clone())
|
||||
}
|
||||
|
||||
fn mut_local_state(&mut self) -> &mut AwarenessState {
|
||||
self.awareness.entry(self.local_id).or_default()
|
||||
}
|
||||
|
||||
pub fn set_local_state(&mut self, content: String) {
|
||||
self.mut_local_state().set_content(content);
|
||||
if let Some(cb) = self.callback.as_ref() {
|
||||
cb(self, AwarenessEventBuilder::new().update(self.local_id).build());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_local_state(&mut self) {
|
||||
self.mut_local_state().delete();
|
||||
if let Some(cb) = self.callback.as_ref() {
|
||||
cb(self, AwarenessEventBuilder::new().remove(self.local_id).build());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_update(&mut self, update: AwarenessStates) {
|
||||
let mut event = AwarenessEventBuilder::new();
|
||||
|
||||
for (client_id, state) in update {
|
||||
match self.awareness.entry(client_id) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let prev_state = entry.get_mut();
|
||||
if client_id == self.local_id {
|
||||
// ignore remote update about local client and
|
||||
// add clock to overwrite remote data
|
||||
prev_state.set_clock(max(prev_state.clock, state.clock) + 1);
|
||||
event.update(client_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if prev_state.clock < state.clock {
|
||||
if state.is_deleted() {
|
||||
prev_state.delete();
|
||||
event.remove(client_id);
|
||||
} else {
|
||||
*prev_state = state;
|
||||
event.update(client_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(state);
|
||||
event.add(client_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cb) = self.callback.as_ref() {
|
||||
cb(self, event.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AwarenessEvent {
|
||||
added: Vec<u64>,
|
||||
updated: Vec<u64>,
|
||||
removed: Vec<u64>,
|
||||
}
|
||||
|
||||
impl AwarenessEvent {
|
||||
pub fn get_updated(&self, states: &AwarenessStates) -> AwarenessStates {
|
||||
states
|
||||
.iter()
|
||||
.filter(|(id, _)| self.added.contains(id) || self.updated.contains(id) || self.removed.contains(id))
|
||||
.map(|(id, state)| (*id, state.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct AwarenessEventBuilder {
|
||||
added: Vec<u64>,
|
||||
updated: Vec<u64>,
|
||||
removed: Vec<u64>,
|
||||
}
|
||||
|
||||
impl AwarenessEventBuilder {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
added: Vec::new(),
|
||||
updated: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, client_id: u64) -> &mut Self {
|
||||
self.added.push(client_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn update(&mut self, client_id: u64) -> &mut Self {
|
||||
self.updated.push(client_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn remove(&mut self, client_id: u64) -> &mut Self {
|
||||
self.removed.push(client_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn build(&mut self) -> AwarenessEvent {
|
||||
AwarenessEvent {
|
||||
added: self.added.clone(),
|
||||
updated: self.updated.clone(),
|
||||
removed: self.removed.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sync::{Mutex, MutexGuard};
|
||||
|
||||
#[test]
|
||||
fn test_awareness() {
|
||||
loom_model!({
|
||||
let mut awareness = Awareness::new(0);
|
||||
|
||||
{
|
||||
// init state
|
||||
assert_eq!(awareness.local_id, 0);
|
||||
assert_eq!(awareness.awareness.len(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
// local state
|
||||
awareness.set_local_state("test".to_string());
|
||||
assert_eq!(awareness.get_local_state(), Some("test".to_string()));
|
||||
awareness.clear_local_state();
|
||||
assert_eq!(awareness.get_local_state(), Some("null".to_string()));
|
||||
}
|
||||
|
||||
{
|
||||
// apply remote update
|
||||
let mut states = AwarenessStates::new();
|
||||
states.insert(0, AwarenessState::new(2, "test0".to_string()));
|
||||
states.insert(1, AwarenessState::new(2, "test1".to_string()));
|
||||
awareness.apply_update(states);
|
||||
assert!(awareness.get_states().contains_key(&1));
|
||||
|
||||
// local state will not apply
|
||||
assert_eq!(awareness.get_states().get(&0).unwrap().content, "null".to_string());
|
||||
assert_eq!(awareness.get_states().get(&1).unwrap().content, "test1".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
// callback
|
||||
let values: Arc<Mutex<Vec<AwarenessEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let callback_values = Arc::clone(&values);
|
||||
awareness.on_update(move |_, event| {
|
||||
let mut values = callback_values.lock().unwrap();
|
||||
values.push(event);
|
||||
});
|
||||
|
||||
let mut new_states = AwarenessStates::new();
|
||||
// exists in local awareness: update
|
||||
new_states.insert(1, AwarenessState::new(3, "test update".to_string()));
|
||||
// not exists in local awareness: add
|
||||
new_states.insert(2, AwarenessState::new(1, "test update".to_string()));
|
||||
// not exists in local awareness: add
|
||||
new_states.insert(3, AwarenessState::new(1, "null".to_string()));
|
||||
// not exists in local awareness: add
|
||||
new_states.insert(4, AwarenessState::new(1, "test update".to_string()));
|
||||
awareness.apply_update(new_states);
|
||||
|
||||
let mut new_states = AwarenessStates::new();
|
||||
// exists in local awareness: delete
|
||||
new_states.insert(4, AwarenessState::new(2, "null".to_string()));
|
||||
awareness.apply_update(new_states);
|
||||
|
||||
awareness.set_local_state("test".to_string());
|
||||
awareness.clear_local_state();
|
||||
|
||||
let values: MutexGuard<Vec<AwarenessEvent>> = values.lock().unwrap();
|
||||
assert_eq!(values.len(), 4);
|
||||
let event = values.first().unwrap();
|
||||
|
||||
let mut added = event.added.clone();
|
||||
added.sort();
|
||||
assert_eq!(added, [2, 3, 4]);
|
||||
assert_eq!(event.updated, [1]);
|
||||
|
||||
assert_eq!(
|
||||
event.get_updated(awareness.get_states()).get(&1).unwrap(),
|
||||
&AwarenessState::new(3, "test update".to_string())
|
||||
);
|
||||
|
||||
let event = values.get(1).unwrap();
|
||||
assert_eq!(event.removed, [4]);
|
||||
|
||||
let event = values.get(2).unwrap();
|
||||
assert_eq!(event.updated, [0]);
|
||||
|
||||
let event = values.get(3).unwrap();
|
||||
assert_eq!(event.removed, [0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Batch {
|
||||
doc: Doc,
|
||||
before_state: StateVector,
|
||||
after_state: StateVector,
|
||||
changed: HashMap<YTypeRef, Vec<SmolStr>>,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
pub fn new(doc: Doc) -> Self {
|
||||
let current_state = doc.get_state_vector();
|
||||
|
||||
Batch {
|
||||
doc,
|
||||
before_state: current_state.clone(),
|
||||
after_state: current_state,
|
||||
changed: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_batch<T, F>(&mut self, f: F) -> T
|
||||
where
|
||||
F: FnOnce(Doc) -> T,
|
||||
{
|
||||
let ret = f(self.doc.clone());
|
||||
for (k, v) in self.doc.get_changed() {
|
||||
self.changed.entry(k).or_default().extend(v.iter().cloned());
|
||||
}
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_commit<T, F>(mut doc: Doc, f: F) -> Option<T>
|
||||
where
|
||||
F: FnOnce(Doc) -> T,
|
||||
{
|
||||
// Initialize batch cleanups list
|
||||
let mut batch_cleanups = vec![];
|
||||
|
||||
// Initial call and result initialization
|
||||
let mut initial_call = false;
|
||||
|
||||
{
|
||||
if doc.batch.is_none() {
|
||||
initial_call = true;
|
||||
|
||||
// Start a new batch
|
||||
let batch = Batch::new(doc.clone());
|
||||
doc.batch = Somr::new(batch);
|
||||
batch_cleanups.push(doc.batch.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let batch = doc.batch.get_mut()?;
|
||||
let result = Some(batch.with_batch(f));
|
||||
|
||||
if initial_call
|
||||
&& let Some(current_batch) = doc.batch.get()
|
||||
&& Some(current_batch) == batch_cleanups[0].get()
|
||||
{
|
||||
// Process observer calls and perform cleanup if this is the initial call
|
||||
cleanup_batches(&mut batch_cleanups);
|
||||
doc.batch.swap_take();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn cleanup_batches(batch_cleanups: &mut Vec<Somr<Batch>>) {
|
||||
for batch in batch_cleanups.drain(..) {
|
||||
if let Some(batch) = batch.get() {
|
||||
println!("changed: {:?}", batch.changed);
|
||||
} else {
|
||||
panic!("Batch not initialized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_get_changed_items() {
|
||||
loom_model!({
|
||||
let doc = DocOptions::new().with_client_id(1).build();
|
||||
|
||||
batch_commit(doc.clone(), |d| {
|
||||
let mut arr = d.get_or_create_array("arr").unwrap();
|
||||
let mut text = d.create_text().unwrap();
|
||||
let mut map = d.create_map().unwrap();
|
||||
|
||||
batch_commit(doc.clone(), |_| {
|
||||
arr.insert(0, Value::from(text.clone())).unwrap();
|
||||
arr.insert(1, Value::from(map.clone())).unwrap();
|
||||
});
|
||||
|
||||
batch_commit(doc.clone(), |_| {
|
||||
text.insert(0, "hello world").unwrap();
|
||||
text.remove(5, 6).unwrap();
|
||||
});
|
||||
|
||||
batch_commit(doc.clone(), |_| {
|
||||
map.insert("key".into(), 123).unwrap();
|
||||
});
|
||||
|
||||
batch_commit(doc.clone(), |_| {
|
||||
map.remove("key");
|
||||
});
|
||||
|
||||
batch_commit(doc.clone(), |_| {
|
||||
arr.remove(0, 1).unwrap();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
ops::RangeInclusive,
|
||||
};
|
||||
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use super::*;
|
||||
|
||||
const MAX_JS_INT: i64 = 0x001F_FFFF_FFFF_FFFF;
|
||||
// The smallest int in js number.
|
||||
const MIN_JS_INT: i64 = -MAX_JS_INT;
|
||||
pub const JS_INT_RANGE: RangeInclusive<i64> = MIN_JS_INT..=MAX_JS_INT;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[cfg_attr(fuzzing, derive(arbitrary::Arbitrary))]
|
||||
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
|
||||
pub enum Any {
|
||||
Undefined,
|
||||
Null,
|
||||
Integer(i32),
|
||||
Float32(OrderedFloat<f32>),
|
||||
Float64(OrderedFloat<f64>),
|
||||
BigInt64(i64),
|
||||
False,
|
||||
True,
|
||||
String(String),
|
||||
// FIXME: due to macro's overflow evaluating, we can't use proptest here
|
||||
#[cfg_attr(test, proptest(skip))]
|
||||
Object(HashMap<String, Any>),
|
||||
#[cfg_attr(test, proptest(skip))]
|
||||
Array(Vec<Any>),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
impl<R: CrdtReader> CrdtRead<R> for Any {
|
||||
fn read(reader: &mut R) -> JwstCodecResult<Self> {
|
||||
let index = reader.read_u8()?;
|
||||
match 127u8.overflowing_sub(index).0 {
|
||||
0 => Ok(Any::Undefined),
|
||||
1 => Ok(Any::Null),
|
||||
// in yjs implementation, flag 2 only save 32bit integer
|
||||
2 => Ok(Any::Integer(reader.read_var_i32()?)), // Integer
|
||||
3 => Ok(Any::Float32(reader.read_f32_be()?.into())), // Float32
|
||||
4 => Ok(Any::Float64(reader.read_f64_be()?.into())), // Float64
|
||||
5 => Ok(Any::BigInt64(reader.read_i64_be()?)), // BigInt64
|
||||
6 => Ok(Any::False),
|
||||
7 => Ok(Any::True),
|
||||
8 => Ok(Any::String(reader.read_var_string()?)), // String
|
||||
9 => {
|
||||
let len = reader.read_var_u64()?;
|
||||
let object = (0..len)
|
||||
.map(|_| Self::read_key_value(reader))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Any::Object(object.into_iter().collect()))
|
||||
} // Object
|
||||
10 => {
|
||||
let len = reader.read_var_u64()?;
|
||||
let any = (0..len).map(|_| Self::read(reader)).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Any::Array(any))
|
||||
} // Array
|
||||
11 => {
|
||||
let binary = reader.read_var_buffer()?;
|
||||
Ok(Any::Binary(binary.to_vec()))
|
||||
} // Binary
|
||||
_ => Ok(Any::Undefined),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: CrdtWriter> CrdtWrite<W> for Any {
|
||||
fn write(&self, writer: &mut W) -> JwstCodecResult {
|
||||
match self {
|
||||
Any::Undefined => writer.write_u8(127)?,
|
||||
Any::Null => writer.write_u8(127 - 1)?,
|
||||
Any::Integer(value) => {
|
||||
writer.write_u8(127 - 2)?;
|
||||
writer.write_var_i32(*value)?;
|
||||
}
|
||||
Any::Float32(value) => {
|
||||
writer.write_u8(127 - 3)?;
|
||||
writer.write_f32_be(value.into_inner())?;
|
||||
}
|
||||
Any::Float64(value) => {
|
||||
writer.write_u8(127 - 4)?;
|
||||
writer.write_f64_be(value.into_inner())?;
|
||||
}
|
||||
Any::BigInt64(value) => {
|
||||
writer.write_u8(127 - 5)?;
|
||||
writer.write_i64_be(*value)?;
|
||||
}
|
||||
Any::False => writer.write_u8(127 - 6)?,
|
||||
Any::True => writer.write_u8(127 - 7)?,
|
||||
Any::String(value) => {
|
||||
writer.write_u8(127 - 8)?;
|
||||
writer.write_var_string(value)?;
|
||||
}
|
||||
Any::Object(value) => {
|
||||
writer.write_u8(127 - 9)?;
|
||||
writer.write_var_u64(value.len() as u64)?;
|
||||
for (key, value) in value {
|
||||
Self::write_key_value(writer, key, value)?;
|
||||
}
|
||||
}
|
||||
Any::Array(values) => {
|
||||
writer.write_u8(127 - 10)?;
|
||||
writer.write_var_u64(values.len() as u64)?;
|
||||
for value in values {
|
||||
value.write(writer)?;
|
||||
}
|
||||
}
|
||||
Any::Binary(value) => {
|
||||
writer.write_u8(127 - 11)?;
|
||||
writer.write_var_buffer(value)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Any {
|
||||
fn read_key_value<R: CrdtReader>(reader: &mut R) -> JwstCodecResult<(String, Any)> {
|
||||
let key = reader.read_var_string()?;
|
||||
let value = Self::read(reader)?;
|
||||
|
||||
Ok((key, value))
|
||||
}
|
||||
|
||||
fn write_key_value<W: CrdtWriter>(writer: &mut W, key: &str, value: &Any) -> JwstCodecResult {
|
||||
writer.write_var_string(key)?;
|
||||
value.write(writer)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn read_multiple<R: CrdtReader>(reader: &mut R) -> JwstCodecResult<Vec<Any>> {
|
||||
let len = reader.read_var_u64()? as usize;
|
||||
let mut vec = Vec::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
vec.push(Any::read(reader)?);
|
||||
}
|
||||
|
||||
Ok(vec)
|
||||
}
|
||||
|
||||
pub(crate) fn write_multiple<W: CrdtWriter>(writer: &mut W, any: &[Any]) -> JwstCodecResult {
|
||||
writer.write_var_u64(any.len() as u64)?;
|
||||
for value in any {
|
||||
value.write(writer)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_primitive_from {
|
||||
(unsigned, $($ty: ty),*) => {
|
||||
$(
|
||||
impl From<$ty> for Any {
|
||||
fn from(value: $ty) -> Self {
|
||||
// INFO: i64::MAX > value > u64::MAX will cut down
|
||||
// yjs binary does not consider the case that the int size exceeds i64
|
||||
let int: i64 = value as i64;
|
||||
// handle the behavior same as yjs
|
||||
if JS_INT_RANGE.contains(&int) {
|
||||
if int <= i32::MAX as i64 {
|
||||
Self::Integer(int as i32)
|
||||
} else if int as f32 as i64 == int {
|
||||
Self::Float32((int as f32).into())
|
||||
} else {
|
||||
Self::Float64((int as f64).into())
|
||||
}
|
||||
} else {
|
||||
Self::BigInt64(int)
|
||||
}
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
(signed, $($ty: ty),*) => {
|
||||
$(
|
||||
impl From<$ty> for Any {
|
||||
fn from(value: $ty) -> Self {
|
||||
let int: i64 = value.into();
|
||||
// handle the behavior same as yjs
|
||||
if JS_INT_RANGE.contains(&int) {
|
||||
if int <= i32::MAX as i64 {
|
||||
Self::Integer(int as i32)
|
||||
} else if int as f32 as i64 == int {
|
||||
Self::Float32((int as f32).into())
|
||||
} else {
|
||||
Self::Float64((int as f64).into())
|
||||
}
|
||||
} else {
|
||||
Self::BigInt64(int)
|
||||
}
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
(string, $($ty: ty),*) => {
|
||||
$(
|
||||
impl From<$ty> for Any {
|
||||
fn from(value: $ty) -> Self {
|
||||
Self::String(value.into())
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
impl_primitive_from!(unsigned, u8, u16, u32, u64);
|
||||
impl_primitive_from!(signed, i8, i16, i32, i64);
|
||||
impl_primitive_from!(string, String, &str);
|
||||
|
||||
impl From<usize> for Any {
|
||||
fn from(value: usize) -> Self {
|
||||
(value as u64).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<isize> for Any {
|
||||
fn from(value: isize) -> Self {
|
||||
(value as i64).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f32> for Any {
|
||||
fn from(value: f32) -> Self {
|
||||
Self::Float32(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Any {
|
||||
fn from(value: f64) -> Self {
|
||||
if value.trunc() == value {
|
||||
(value as i64).into()
|
||||
} else if value as f32 as f64 == value {
|
||||
Self::Float32((value as f32).into())
|
||||
} else {
|
||||
Self::Float64(value.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for Any {
|
||||
fn from(value: bool) -> Self {
|
||||
if value { Self::True } else { Self::False }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Any> for String {
|
||||
type Error = JwstCodecError;
|
||||
|
||||
fn try_from(value: Any) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
Any::String(s) => Ok(s),
|
||||
_ => Err(JwstCodecError::UnexpectedType("String")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Any> for HashMap<String, Any> {
|
||||
type Error = JwstCodecError;
|
||||
|
||||
fn try_from(value: Any) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
Any::Object(map) => Ok(map),
|
||||
_ => Err(JwstCodecError::UnexpectedType("Object")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Any> for Vec<Any> {
|
||||
type Error = JwstCodecError;
|
||||
|
||||
fn try_from(value: Any) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
Any::Array(vec) => Ok(vec),
|
||||
_ => Err(JwstCodecError::UnexpectedType("Array")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Any> for bool {
|
||||
type Error = JwstCodecError;
|
||||
|
||||
fn try_from(value: Any) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
Any::True => Ok(true),
|
||||
Any::False => Ok(false),
|
||||
_ => Err(JwstCodecError::UnexpectedType("Boolean")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<Any> for Any {
|
||||
fn from_iter<I: IntoIterator<Item = Any>>(iter: I) -> Self {
|
||||
Self::Array(iter.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FromIterator<&'a Any> for Any {
|
||||
fn from_iter<I: IntoIterator<Item = &'a Any>>(iter: I) -> Self {
|
||||
Self::Array(iter.into_iter().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(String, Any)> for Any {
|
||||
fn from_iter<I: IntoIterator<Item = (String, Any)>>(iter: I) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
map.extend(iter);
|
||||
Self::Object(map)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HashMap<String, Any>> for Any {
|
||||
fn from(value: HashMap<String, Any>) -> Self {
|
||||
Self::Object(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<u8>> for Any {
|
||||
fn from(value: Vec<u8>) -> Self {
|
||||
Self::Binary(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[u8]> for Any {
|
||||
fn from(value: &[u8]) -> Self {
|
||||
Self::Binary(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: impl for Any::Undefined
|
||||
impl<T: Into<Any>> From<Option<T>> for Any {
|
||||
fn from(value: Option<T>) -> Self {
|
||||
if let Some(val) = value { val.into() } else { Any::Null }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde_json")]
|
||||
impl From<serde_json::Value> for Any {
|
||||
fn from(value: serde_json::Value) -> Self {
|
||||
match value {
|
||||
serde_json::Value::Null => Self::Null,
|
||||
serde_json::Value::Bool(b) => {
|
||||
if b {
|
||||
Self::True
|
||||
} else {
|
||||
Self::False
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(n) => {
|
||||
if n.is_f64() {
|
||||
Self::Float64(n.as_f64().unwrap().into())
|
||||
} else if n.is_i64() {
|
||||
Self::Integer(n.as_i64().unwrap() as i32)
|
||||
} else {
|
||||
Self::Integer(n.as_u64().unwrap() as i32)
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Self::String(s),
|
||||
serde_json::Value::Array(vec) => Self::Array(vec.into_iter().map(|v| v.into()).collect::<Vec<_>>()),
|
||||
serde_json::Value::Object(obj) => Self::Object(obj.into_iter().map(|(k, v)| (k, v.into())).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for Any {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{Error, MapAccess, SeqAccess, Visitor};
|
||||
struct ValueVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for ValueVisitor {
|
||||
type Value = Any;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("any valid JSON value")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_bool<E>(self, value: bool) -> Result<Any, E> {
|
||||
Ok(if value { Any::True } else { Any::False })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_i64<E>(self, value: i64) -> Result<Any, E> {
|
||||
Ok(Any::BigInt64(value))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_u64<E>(self, value: u64) -> Result<Any, E> {
|
||||
Ok((value as i64).into())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_f64<E>(self, value: f64) -> Result<Any, E> {
|
||||
Ok(Any::Float64(OrderedFloat(value)))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_str<E>(self, value: &str) -> Result<Any, E>
|
||||
where
|
||||
E: Error,
|
||||
{
|
||||
self.visit_string(String::from(value))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_string<E>(self, value: String) -> Result<Any, E> {
|
||||
Ok(Any::String(value))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_none<E>(self) -> Result<Any, E> {
|
||||
Ok(Any::Null)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_some<D>(self, deserializer: D) -> Result<Any, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
serde::Deserialize::deserialize(deserializer)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_unit<E>(self) -> Result<Any, E> {
|
||||
Ok(Any::Null)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn visit_seq<V>(self, mut visitor: V) -> Result<Any, V::Error>
|
||||
where
|
||||
V: SeqAccess<'de>,
|
||||
{
|
||||
let mut vec = Vec::new();
|
||||
|
||||
while let Some(elem) = visitor.next_element()? {
|
||||
vec.push(elem);
|
||||
}
|
||||
|
||||
Ok(Any::Array(vec))
|
||||
}
|
||||
|
||||
fn visit_map<V>(self, mut visitor: V) -> Result<Any, V::Error>
|
||||
where
|
||||
V: MapAccess<'de>,
|
||||
{
|
||||
match visitor.next_key::<String>()? {
|
||||
Some(k) => {
|
||||
let mut values = HashMap::new();
|
||||
|
||||
values.insert(k, visitor.next_value()?);
|
||||
while let Some((key, value)) = visitor.next_entry()? {
|
||||
values.insert(key, value);
|
||||
}
|
||||
|
||||
Ok(Any::Object(values))
|
||||
}
|
||||
None => Ok(Any::Object(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(ValueVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for Any {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::{SerializeMap, SerializeSeq};
|
||||
|
||||
match self {
|
||||
Any::Null => serializer.serialize_none(),
|
||||
Any::Undefined => serializer.serialize_none(),
|
||||
Any::True => serializer.serialize_bool(true),
|
||||
Any::False => serializer.serialize_bool(false),
|
||||
Any::Float32(value) => serializer.serialize_f32(value.0),
|
||||
Any::Float64(value) => serializer.serialize_f64(value.0),
|
||||
Any::Integer(value) => serializer.serialize_i32(*value),
|
||||
Any::BigInt64(value) => serializer.serialize_i64(*value),
|
||||
Any::String(value) => serializer.serialize_str(value.as_ref()),
|
||||
Any::Array(values) => {
|
||||
let mut seq = serializer.serialize_seq(Some(values.len()))?;
|
||||
for value in values.iter() {
|
||||
seq.serialize_element(value)?;
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
Any::Object(entries) => {
|
||||
let mut map = serializer.serialize_map(Some(entries.len()))?;
|
||||
for (key, value) in entries.iter() {
|
||||
map.serialize_entry(key, value)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
Any::Binary(buf) => serializer.serialize_bytes(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Any {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::True => write!(f, "true"),
|
||||
Self::False => write!(f, "false"),
|
||||
Self::String(s) => write!(f, "\"{s}\""),
|
||||
Self::Integer(i) => write!(f, "{i}"),
|
||||
Self::Float32(v) => write!(f, "{v}"),
|
||||
Self::Float64(v) => write!(f, "{v}"),
|
||||
Self::BigInt64(v) => write!(f, "{v}"),
|
||||
Self::Object(map) => {
|
||||
write!(f, "{{")?;
|
||||
for (i, (key, value)) in map.iter().enumerate() {
|
||||
if i > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{key}: {value}")?;
|
||||
}
|
||||
write!(f, "}}")
|
||||
}
|
||||
Self::Array(vec) => {
|
||||
write!(f, "[")?;
|
||||
for (i, value) in vec.iter().enumerate() {
|
||||
if i > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{value}")?;
|
||||
}
|
||||
write!(f, "]")
|
||||
}
|
||||
Self::Binary(buf) => write!(f, "{buf:?}"),
|
||||
Self::Undefined => write!(f, "undefined"),
|
||||
Self::Null => write!(f, "null"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::{collection::vec, prelude::*};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_any_codec() {
|
||||
let any = Any::Object(
|
||||
vec![
|
||||
("name".to_string(), Any::String("Alice".to_string())),
|
||||
("age".to_string(), Any::Integer(25)),
|
||||
(
|
||||
"contacts".to_string(),
|
||||
Any::Array(vec![
|
||||
Any::Object(
|
||||
vec![
|
||||
("type".to_string(), Any::String("Mobile".to_string())),
|
||||
("number".to_string(), Any::String("1234567890".to_string())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
Any::Object(
|
||||
vec![
|
||||
("type".to_string(), Any::String("Email".to_string())),
|
||||
("address".to_string(), Any::String("alice@example.com".to_string())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
Any::Undefined,
|
||||
]),
|
||||
),
|
||||
(
|
||||
"standard_data".to_string(),
|
||||
Any::Array(vec![
|
||||
Any::Undefined,
|
||||
Any::Null,
|
||||
Any::Integer(114514),
|
||||
Any::Float32(114.514.into()),
|
||||
Any::Float64(115.514.into()),
|
||||
Any::BigInt64(-1145141919810),
|
||||
Any::False,
|
||||
Any::True,
|
||||
Any::Object(
|
||||
vec![
|
||||
("name".to_string(), Any::String("tadokoro".to_string())),
|
||||
("age".to_string(), Any::String("24".to_string())),
|
||||
("profession".to_string(), Any::String("student".to_string())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
Any::Binary(vec![1, 2, 3, 4, 5]),
|
||||
]),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let mut encoder = RawEncoder::default();
|
||||
any.write(&mut encoder).unwrap();
|
||||
let encoded = encoder.into_inner();
|
||||
|
||||
let mut decoder = RawDecoder::new(&encoded);
|
||||
let decoded = Any::read(&mut decoder).unwrap();
|
||||
|
||||
assert_eq!(any, decoded);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
#[cfg_attr(miri, ignore)]
|
||||
fn test_random_any(any in vec(any::<Any>(), 0..100)) {
|
||||
for any in &any {
|
||||
let mut encoder = RawEncoder::default();
|
||||
any.write(&mut encoder).unwrap();
|
||||
let encoded = encoder.into_inner();
|
||||
|
||||
let mut decoder = RawDecoder::new(&encoded);
|
||||
let decoded = Any::read(&mut decoder).unwrap();
|
||||
|
||||
assert_eq!(any, &decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_to_any() {
|
||||
let any: Vec<Any> = vec![
|
||||
42u8.into(),
|
||||
42u16.into(),
|
||||
42u32.into(),
|
||||
42u64.into(),
|
||||
114.514f32.into(),
|
||||
1919.810f64.into(),
|
||||
(-42i8).into(),
|
||||
(-42i16).into(),
|
||||
(-42i32).into(),
|
||||
(-42i64).into(),
|
||||
false.into(),
|
||||
true.into(),
|
||||
"JWST".to_string().into(),
|
||||
"OctoBase".into(),
|
||||
vec![1u8, 9, 1, 9].into(),
|
||||
(&[8u8, 1, 0][..]).into(),
|
||||
[Any::True, 42u8.into()].iter().collect(),
|
||||
];
|
||||
assert_eq!(
|
||||
any,
|
||||
vec![
|
||||
Any::Integer(42),
|
||||
Any::Integer(42),
|
||||
Any::Integer(42),
|
||||
Any::Integer(42),
|
||||
Any::Float32(114.514.into()),
|
||||
Any::Float64(1919.810.into()),
|
||||
Any::Integer(-42),
|
||||
Any::Integer(-42),
|
||||
Any::Integer(-42),
|
||||
Any::Integer(-42),
|
||||
Any::False,
|
||||
Any::True,
|
||||
Any::String("JWST".to_string()),
|
||||
Any::String("OctoBase".to_string()),
|
||||
Any::Binary(vec![1, 9, 1, 9]),
|
||||
Any::Binary(vec![8, 1, 0]),
|
||||
Any::Array(vec![Any::True, Any::Integer(42)])
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
vec![("key".to_string(), 10u64.into())].into_iter().collect::<Any>(),
|
||||
Any::Object(HashMap::from_iter(vec![("key".to_string(), Any::Integer(10))]))
|
||||
);
|
||||
|
||||
let any: Any = 10u64.into();
|
||||
assert_eq!([any].iter().collect::<Any>(), Any::Array(vec![Any::Integer(10)]));
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
|
||||
pub(crate) enum Content {
|
||||
Deleted(u64),
|
||||
Json(Vec<Option<String>>),
|
||||
Binary(Vec<u8>),
|
||||
String(String),
|
||||
#[cfg_attr(test, proptest(skip))]
|
||||
Embed(Any),
|
||||
#[cfg_attr(test, proptest(skip))]
|
||||
Format {
|
||||
key: String,
|
||||
value: Any,
|
||||
},
|
||||
#[cfg_attr(test, proptest(skip))]
|
||||
Type(YTypeRef),
|
||||
Any(Vec<Any>),
|
||||
Doc {
|
||||
guid: String,
|
||||
opts: Any,
|
||||
},
|
||||
}
|
||||
|
||||
unsafe impl Send for Content {}
|
||||
unsafe impl Sync for Content {}
|
||||
|
||||
impl From<Any> for Content {
|
||||
fn from(value: Any) -> Self {
|
||||
match value {
|
||||
Any::Undefined
|
||||
| Any::Null
|
||||
| Any::Integer(_)
|
||||
| Any::Float32(_)
|
||||
| Any::Float64(_)
|
||||
| Any::BigInt64(_)
|
||||
| Any::False
|
||||
| Any::True
|
||||
| Any::String(_)
|
||||
| Any::Object(_) => Content::Any(vec![value; 1]),
|
||||
Any::Array(v) => Content::Any(v),
|
||||
Any::Binary(b) => Content::Binary(b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Content {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Deleted(len1), Self::Deleted(len2)) => len1 == len2,
|
||||
(Self::Json(vec1), Self::Json(vec2)) => vec1 == vec2,
|
||||
(Self::Binary(vec1), Self::Binary(vec2)) => vec1 == vec2,
|
||||
(Self::String(str1), Self::String(str2)) => str1 == str2,
|
||||
(Self::Embed(json1), Self::Embed(json2)) => json1 == json2,
|
||||
(
|
||||
Self::Format {
|
||||
key: key1,
|
||||
value: value1,
|
||||
},
|
||||
Self::Format {
|
||||
key: key2,
|
||||
value: value2,
|
||||
},
|
||||
) => key1 == key2 && value1 == value2,
|
||||
(Self::Any(any1), Self::Any(any2)) => any1 == any2,
|
||||
(Self::Doc { guid: guid1, .. }, Self::Doc { guid: guid2, .. }) => guid1 == guid2,
|
||||
(Self::Type(ty1), Self::Type(ty2)) => ty1 == ty2,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Content {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Deleted(arg0) => f.debug_tuple("Deleted").field(arg0).finish(),
|
||||
Self::Json(arg0) => f
|
||||
.debug_tuple("JSON")
|
||||
.field(&format!("Vec [len: {}]", arg0.len()))
|
||||
.finish(),
|
||||
Self::Binary(arg0) => f
|
||||
.debug_tuple("Binary")
|
||||
.field(&format!("Binary [len: {}]", arg0.len()))
|
||||
.finish(),
|
||||
Self::String(arg0) => f.debug_tuple("String").field(arg0).finish(),
|
||||
Self::Embed(arg0) => f.debug_tuple("Embed").field(arg0).finish(),
|
||||
Self::Format { key, value } => f
|
||||
.debug_struct("Format")
|
||||
.field("key", key)
|
||||
.field("value", value)
|
||||
.finish(),
|
||||
Self::Type(arg0) => f.debug_tuple("Type").field(&arg0.ty().unwrap().kind()).finish(),
|
||||
Self::Any(arg0) => f.debug_tuple("Any").field(arg0).finish(),
|
||||
Self::Doc { guid, opts } => f.debug_struct("Doc").field("guid", guid).field("opts", opts).finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub(crate) fn read<R: CrdtReader>(decoder: &mut R, tag_type: u8) -> JwstCodecResult<Self> {
|
||||
match tag_type {
|
||||
1 => Ok(Self::Deleted(decoder.read_var_u64()?)), // Deleted
|
||||
2 => {
|
||||
let len = decoder.read_var_u64()?;
|
||||
let strings = (0..len)
|
||||
.map(|_| decoder.read_var_string().map(|s| (s != "undefined").then_some(s)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Self::Json(strings))
|
||||
} // JSON
|
||||
3 => Ok(Self::Binary(decoder.read_var_buffer()?.to_vec())), // Binary
|
||||
4 => Ok(Self::String(decoder.read_var_string()?)), // String
|
||||
5 => {
|
||||
let string = decoder.read_var_string()?;
|
||||
let json = serde_json::from_str(&string).map_err(|_| JwstCodecError::DamagedDocumentJson)?;
|
||||
|
||||
Ok(Self::Embed(json))
|
||||
} // Embed
|
||||
6 => {
|
||||
let key = decoder.read_var_string()?;
|
||||
let value = decoder.read_var_string()?;
|
||||
let value = serde_json::from_str(&value).map_err(|_| JwstCodecError::DamagedDocumentJson)?;
|
||||
|
||||
Ok(Self::Format { key, value })
|
||||
} // Format
|
||||
7 => {
|
||||
let type_ref = decoder.read_var_u64()?;
|
||||
let kind = YTypeKind::from(type_ref);
|
||||
let tag_name = match kind {
|
||||
YTypeKind::XMLElement | YTypeKind::XMLHook => Some(decoder.read_var_string()?),
|
||||
YTypeKind::Unknown => {
|
||||
return Err(JwstCodecError::IncompleteDocument(format!(
|
||||
"Unknown y type: {type_ref}"
|
||||
)));
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(Self::Type(YTypeRef::new(kind, tag_name)))
|
||||
} // YType
|
||||
8 => Ok(Self::Any(Any::read_multiple(decoder)?)), // Any
|
||||
9 => {
|
||||
let guid = decoder.read_var_string()?;
|
||||
let opts = Any::read(decoder)?;
|
||||
Ok(Self::Doc { guid, opts })
|
||||
} // Doc
|
||||
tag_type => Err(JwstCodecError::IncompleteDocument(format!(
|
||||
"Unknown content type: {tag_type}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_info(&self) -> u8 {
|
||||
match self {
|
||||
Self::Deleted(_) => 1,
|
||||
Self::Json(_) => 2,
|
||||
Self::Binary(_) => 3,
|
||||
Self::String(_) => 4,
|
||||
Self::Embed(_) => 5,
|
||||
Self::Format { .. } => 6,
|
||||
Self::Type(_) => 7,
|
||||
Self::Any(_) => 8,
|
||||
Self::Doc { .. } => 9,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write<W: CrdtWriter>(&self, encoder: &mut W) -> JwstCodecResult {
|
||||
match self {
|
||||
Self::Deleted(len) => {
|
||||
encoder.write_var_u64(*len)?;
|
||||
}
|
||||
Self::Json(strings) => {
|
||||
encoder.write_var_u64(strings.len() as u64)?;
|
||||
for string in strings {
|
||||
match string {
|
||||
Some(string) => encoder.write_var_string(string)?,
|
||||
None => encoder.write_var_string("undefined")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Binary(buffer) => {
|
||||
encoder.write_var_buffer(buffer)?;
|
||||
}
|
||||
Self::String(string) => {
|
||||
encoder.write_var_string(string)?;
|
||||
}
|
||||
Self::Embed(val) => {
|
||||
encoder.write_var_string(serde_json::to_string(val).map_err(|_| JwstCodecError::DamagedDocumentJson)?)?;
|
||||
}
|
||||
Self::Format { key, value } => {
|
||||
encoder.write_var_string(key)?;
|
||||
encoder.write_var_string(serde_json::to_string(value).map_err(|_| JwstCodecError::DamagedDocumentJson)?)?;
|
||||
}
|
||||
Self::Type(ty) => {
|
||||
if let Some(ty) = ty.ty() {
|
||||
let type_ref = u64::from(ty.kind());
|
||||
encoder.write_var_u64(type_ref)?;
|
||||
|
||||
if matches!(ty.kind(), YTypeKind::XMLElement | YTypeKind::XMLHook) {
|
||||
encoder.write_var_string(ty.name.as_ref().unwrap())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Any(any) => {
|
||||
Any::write_multiple(encoder, any)?;
|
||||
}
|
||||
Self::Doc { guid, opts } => {
|
||||
encoder.write_var_string(guid)?;
|
||||
opts.write(encoder)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clock_len(&self) -> u64 {
|
||||
match self {
|
||||
Self::Deleted(len) => *len,
|
||||
Self::Json(strings) => strings.len() as u64,
|
||||
// TODO: need a custom wrapper with length cached, this cost too much
|
||||
Self::String(string) => string.chars().map(|c| c.len_utf16()).sum::<usize>() as u64,
|
||||
Self::Any(any) => any.len() as u64,
|
||||
Self::Binary(_) | Self::Embed(_) | Self::Format { .. } | Self::Type(_) | Self::Doc { .. } => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn countable(&self) -> bool {
|
||||
!matches!(self, Content::Format { .. } | Content::Deleted(_))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn splittable(&self) -> bool {
|
||||
matches!(self, Self::String { .. } | Self::Any { .. } | Self::Json { .. })
|
||||
}
|
||||
|
||||
pub fn split(&self, diff: u64) -> JwstCodecResult<(Self, Self)> {
|
||||
match self {
|
||||
Self::String(str) => {
|
||||
let (left, right) = Self::split_as_utf16_str(str.as_str(), diff);
|
||||
Ok((Self::String(left.to_string()), Self::String(right.to_string())))
|
||||
}
|
||||
Self::Json(vec) => {
|
||||
let (left, right) = vec.split_at(diff as usize);
|
||||
Ok((Self::Json(left.to_owned()), Self::Json(right.to_owned())))
|
||||
}
|
||||
Self::Any(vec) => {
|
||||
let (left, right) = vec.split_at(diff as usize);
|
||||
Ok((Self::Any(left.to_owned()), Self::Any(right.to_owned())))
|
||||
}
|
||||
Self::Deleted(len) => {
|
||||
let (left, right) = (diff, *len - diff);
|
||||
|
||||
Ok((Self::Deleted(left), Self::Deleted(right)))
|
||||
}
|
||||
_ => Err(JwstCodecError::ContentSplitNotSupport(diff)),
|
||||
}
|
||||
}
|
||||
|
||||
/// consider `offset` as a utf-16 encoded string offset
|
||||
fn split_as_utf16_str(s: &str, offset: u64) -> (&str, &str) {
|
||||
let mut utf_16_offset = 0;
|
||||
let mut utf_8_offset = 0;
|
||||
for ch in s.chars() {
|
||||
utf_16_offset += ch.len_utf16();
|
||||
utf_8_offset += ch.len_utf8();
|
||||
if utf_16_offset as u64 >= offset {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
s.split_at(utf_8_offset)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::{collection::vec, prelude::*};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn content_round_trip(content: &Content) -> JwstCodecResult {
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_u8(content.get_info())?;
|
||||
content.write(&mut writer)?;
|
||||
let update = writer.into_inner();
|
||||
|
||||
let mut reader = RawDecoder::new(&update);
|
||||
let tag_type = reader.read_u8()?;
|
||||
let decoded = Content::read(&mut reader, tag_type)?;
|
||||
match (&decoded, content) {
|
||||
(Content::Type(decoded_ty), Content::Type(original_ty)) => {
|
||||
let decoded_ty = decoded_ty.ty().expect("decoded ytype must exist");
|
||||
let original_ty = original_ty.ty().expect("original ytype must exist");
|
||||
assert_eq!(decoded_ty.kind(), original_ty.kind());
|
||||
assert_eq!(decoded_ty.name.as_deref(), original_ty.name.as_deref());
|
||||
}
|
||||
_ => assert_eq!(decoded, *content),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content() {
|
||||
loom_model!({
|
||||
let contents = [
|
||||
Content::Deleted(42),
|
||||
Content::Json(vec![None, Some("test_1".to_string()), Some("test_2".to_string())]),
|
||||
Content::Binary(vec![1, 2, 3]),
|
||||
Content::String("hello".to_string()),
|
||||
Content::Embed(Any::True),
|
||||
Content::Format {
|
||||
key: "key".to_string(),
|
||||
value: Any::Integer(42),
|
||||
},
|
||||
Content::Type(YTypeRef::new(YTypeKind::Array, None)),
|
||||
Content::Type(YTypeRef::new(YTypeKind::Map, None)),
|
||||
Content::Type(YTypeRef::new(YTypeKind::Text, None)),
|
||||
Content::Type(YTypeRef::new(YTypeKind::XMLElement, Some("test".to_string()))),
|
||||
Content::Type(YTypeRef::new(YTypeKind::XMLFragment, None)),
|
||||
Content::Type(YTypeRef::new(YTypeKind::XMLHook, Some("test".to_string()))),
|
||||
Content::Type(YTypeRef::new(YTypeKind::XMLText, None)),
|
||||
Content::Any(vec![Any::BigInt64(42), Any::String("Test Any".to_string())]),
|
||||
Content::Doc {
|
||||
guid: "my_guid".to_string(),
|
||||
opts: Any::BigInt64(42),
|
||||
},
|
||||
];
|
||||
|
||||
for content in &contents {
|
||||
content_round_trip(content).unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_split() {
|
||||
let contents = [
|
||||
Content::String("hello".to_string()),
|
||||
Content::Json(vec![None, Some("test_1".to_string()), Some("test_2".to_string())]),
|
||||
Content::Any(vec![Any::BigInt64(42), Any::String("Test Any".to_string())]),
|
||||
Content::Binary(vec![]),
|
||||
];
|
||||
|
||||
{
|
||||
let (left, right) = contents[0].split(1).unwrap();
|
||||
assert!(contents[0].splittable());
|
||||
assert_eq!(left, Content::String("h".to_string()));
|
||||
assert_eq!(right, Content::String("ello".to_string()));
|
||||
}
|
||||
|
||||
{
|
||||
let (left, right) = contents[1].split(1).unwrap();
|
||||
assert!(contents[1].splittable());
|
||||
assert_eq!(left, Content::Json(vec![None]));
|
||||
assert_eq!(
|
||||
right,
|
||||
Content::Json(vec![Some("test_1".to_string()), Some("test_2".to_string())])
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let (left, right) = contents[2].split(1).unwrap();
|
||||
assert!(contents[2].splittable());
|
||||
assert_eq!(left, Content::Any(vec![Any::BigInt64(42)]));
|
||||
assert_eq!(right, Content::Any(vec![Any::String("Test Any".to_string())]));
|
||||
}
|
||||
|
||||
{
|
||||
assert!(!contents[3].splittable());
|
||||
assert_eq!(contents[3].split(2), Err(JwstCodecError::ContentSplitNotSupport(2)));
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
#[cfg_attr(miri, ignore)]
|
||||
fn test_random_content(contents in vec(any::<Content>(), 0..10)) {
|
||||
for content in &contents {
|
||||
content_round_trip(content).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
use std::{
|
||||
collections::{VecDeque, hash_map::Entry},
|
||||
ops::{Deref, DerefMut, Range},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::doc::OrderRange;
|
||||
|
||||
impl<R: CrdtReader> CrdtRead<R> for Range<u64> {
|
||||
fn read(decoder: &mut R) -> JwstCodecResult<Self> {
|
||||
let clock = decoder.read_var_u64()?;
|
||||
let len = decoder.read_var_u64()?;
|
||||
Ok(clock..clock + len)
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: CrdtWriter> CrdtWrite<W> for Range<u64> {
|
||||
fn write(&self, encoder: &mut W) -> JwstCodecResult {
|
||||
encoder.write_var_u64(self.start)?;
|
||||
encoder.write_var_u64(self.end - self.start)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: CrdtReader> CrdtRead<R> for OrderRange {
|
||||
fn read(decoder: &mut R) -> JwstCodecResult<Self> {
|
||||
let num_of_deletes = decoder.read_var_u64()? as usize;
|
||||
if num_of_deletes == 1 {
|
||||
Ok(OrderRange::Range(Range::<u64>::read(decoder)?))
|
||||
} else {
|
||||
let mut deletes = VecDeque::with_capacity(num_of_deletes);
|
||||
|
||||
for _ in 0..num_of_deletes {
|
||||
deletes.push_back(Range::<u64>::read(decoder)?);
|
||||
}
|
||||
|
||||
Ok(OrderRange::Fragment(deletes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: CrdtWriter> CrdtWrite<W> for OrderRange {
|
||||
fn write(&self, encoder: &mut W) -> JwstCodecResult {
|
||||
match self {
|
||||
OrderRange::Range(range) => {
|
||||
encoder.write_var_u64(1)?;
|
||||
range.write(encoder)?;
|
||||
}
|
||||
OrderRange::Fragment(ranges) => {
|
||||
encoder.write_var_u64(ranges.len() as u64)?;
|
||||
for range in ranges {
|
||||
range.write(encoder)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct DeleteSet(pub ClientMap<OrderRange>);
|
||||
|
||||
impl Deref for DeleteSet {
|
||||
type Target = ClientMap<OrderRange>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> From<[(Client, Vec<Range<u64>>); N]> for DeleteSet {
|
||||
fn from(value: [(Client, Vec<Range<u64>>); N]) -> Self {
|
||||
let mut map = ClientMap::with_capacity(N);
|
||||
for (client, ranges) in value {
|
||||
map.insert(client, ranges.into());
|
||||
}
|
||||
Self(map)
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for DeleteSet {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteSet {
|
||||
pub fn add(&mut self, client: Client, from: Clock, len: Clock) {
|
||||
self.add_range(client, from..from + len);
|
||||
}
|
||||
|
||||
pub fn add_range(&mut self, client: Client, range: Range<u64>) {
|
||||
match self.0.entry(client) {
|
||||
Entry::Occupied(e) => {
|
||||
let r = e.into_mut();
|
||||
if r.is_empty() {
|
||||
*r = range.into();
|
||||
} else {
|
||||
r.push(range);
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(range.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_add_ranges(&mut self, client: Client, ranges: Vec<Range<u64>>) {
|
||||
match self.0.entry(client) {
|
||||
Entry::Occupied(e) => {
|
||||
e.into_mut().extend(ranges);
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(ranges.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &Self) {
|
||||
for (client, range) in &other.0 {
|
||||
match self.0.entry(*client) {
|
||||
Entry::Occupied(e) => {
|
||||
e.into_mut().merge(range.clone());
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(range.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: CrdtReader> CrdtRead<R> for DeleteSet {
|
||||
fn read(decoder: &mut R) -> JwstCodecResult<Self> {
|
||||
let num_of_clients = decoder.read_var_u64()? as usize;
|
||||
// See: [HASHMAP_SAFE_CAPACITY]
|
||||
let mut map = ClientMap::with_capacity(num_of_clients.min(HASHMAP_SAFE_CAPACITY));
|
||||
|
||||
for _ in 0..num_of_clients {
|
||||
let client = decoder.read_var_u64()?;
|
||||
let deletes = OrderRange::read(decoder)?;
|
||||
map.insert(client, deletes);
|
||||
}
|
||||
|
||||
map.shrink_to_fit();
|
||||
Ok(DeleteSet(map))
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: CrdtWriter> CrdtWrite<W> for DeleteSet {
|
||||
fn write(&self, encoder: &mut W) -> JwstCodecResult {
|
||||
encoder.write_var_u64(self.len() as u64)?;
|
||||
let mut clients = self.keys().copied().collect::<Vec<_>>();
|
||||
|
||||
// Descending
|
||||
clients.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
for client in clients {
|
||||
encoder.write_var_u64(client)?;
|
||||
self.get(&client).unwrap().write(encoder)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_delete_set_add() {
|
||||
let delete_set = DeleteSet::from([
|
||||
(1, vec![0..10, 20..30]),
|
||||
(2, vec![0..5, 10..20]),
|
||||
(3, vec![15..20, 30..35]),
|
||||
(4, vec![0..10]),
|
||||
]);
|
||||
|
||||
{
|
||||
let mut delete_set = delete_set.clone();
|
||||
delete_set.add(1, 5, 25);
|
||||
assert_eq!(delete_set.get(&1), Some(&OrderRange::Range(0..30)));
|
||||
}
|
||||
|
||||
{
|
||||
let mut delete_set = delete_set;
|
||||
delete_set.add(1, 5, 10);
|
||||
assert_eq!(delete_set.get(&1), Some(&OrderRange::from(vec![0..15, 20..30])));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_set_batch_push() {
|
||||
let delete_set = DeleteSet::from([
|
||||
(1, vec![0..10, 20..30]),
|
||||
(2, vec![0..5, 10..20]),
|
||||
(3, vec![15..20, 30..35]),
|
||||
(4, vec![0..10]),
|
||||
]);
|
||||
|
||||
{
|
||||
let mut delete_set = delete_set.clone();
|
||||
delete_set.batch_add_ranges(1, vec![0..5, 10..20]);
|
||||
assert_eq!(delete_set.get(&1), Some(&OrderRange::Range(0..30)));
|
||||
}
|
||||
|
||||
{
|
||||
let mut delete_set = delete_set;
|
||||
delete_set.batch_add_ranges(1, vec![40..50, 10..20]);
|
||||
assert_eq!(delete_set.get(&1), Some(&OrderRange::from(vec![0..30, 40..50])));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_decode() {
|
||||
let delete_set = DeleteSet::from([(1, vec![0..10, 20..30]), (2, vec![0..5, 10..20])]);
|
||||
let mut encoder = RawEncoder::default();
|
||||
delete_set.write(&mut encoder).unwrap();
|
||||
let update = encoder.into_inner();
|
||||
let mut decoder = RawDecoder::new(&update);
|
||||
let decoded = DeleteSet::read(&mut decoder).unwrap();
|
||||
assert_eq!(delete_set, decoded);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use std::{
|
||||
fmt::Display,
|
||||
hash::Hash,
|
||||
ops::{Add, Sub},
|
||||
};
|
||||
|
||||
pub type Client = u64;
|
||||
pub type Clock = u64;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
|
||||
#[cfg_attr(fuzzing, derive(arbitrary::Arbitrary))]
|
||||
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
|
||||
pub struct Id {
|
||||
pub client: Client,
|
||||
pub clock: Clock,
|
||||
}
|
||||
|
||||
impl Id {
|
||||
pub fn new(client: Client, clock: Clock) -> Self {
|
||||
Self { client, clock }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(Client, Clock)> for Id {
|
||||
fn from((client, clock): (Client, Clock)) -> Self {
|
||||
Id::new(client, clock)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<Clock> for Id {
|
||||
type Output = Id;
|
||||
|
||||
fn sub(self, rhs: Clock) -> Self::Output {
|
||||
(self.client, self.clock - rhs).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<Clock> for Id {
|
||||
type Output = Id;
|
||||
|
||||
fn add(self, rhs: Clock) -> Self::Output {
|
||||
(self.client, self.clock + rhs).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Id {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "({}, {})", self.client, self.clock)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_id_operation() {
|
||||
let id_with_different_client_1 = Id::new(1, 1);
|
||||
let id_with_different_client_2 = Id::new(2, 1);
|
||||
|
||||
assert_ne!(id_with_different_client_1, id_with_different_client_2);
|
||||
assert_eq!(Id::new(1, 1), Id::new(1, 1));
|
||||
|
||||
let clock = 2;
|
||||
assert_eq!(Id::new(1, 1) + clock, (1, 3).into());
|
||||
assert_eq!(Id::new(1, 3) - clock, (1, 1).into());
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[inline]
|
||||
pub fn read_with_cursor<T, F>(buffer: &mut Cursor<&[u8]>, f: F) -> JwstCodecResult<T>
|
||||
where
|
||||
F: FnOnce(&[u8]) -> IResult<&[u8], T>,
|
||||
{
|
||||
// TODO: use remaining_slice() instead after it is stabilized
|
||||
let input = buffer.get_ref();
|
||||
let rest_pos = buffer.position().min(input.len() as u64) as usize;
|
||||
let input = &input[rest_pos..];
|
||||
|
||||
let (tail, result) = f(input).map_err(|e| e.map_input(|u| u.len()))?;
|
||||
|
||||
buffer.set_position((rest_pos + input.len() - tail.len()) as u64);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// compatible with ydoc v1
|
||||
#[derive(Clone)]
|
||||
pub struct RawDecoder<'b> {
|
||||
pub(super) buffer: Cursor<&'b [u8]>,
|
||||
}
|
||||
|
||||
impl<'b> RawDecoder<'b> {
|
||||
pub fn new(buffer: &'b [u8]) -> Self {
|
||||
Self {
|
||||
buffer: Cursor::new(buffer),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rest_ref(&self) -> &[u8] {
|
||||
let pos = self.buffer.position();
|
||||
let buf = self.buffer.get_ref();
|
||||
|
||||
if pos == 0 {
|
||||
buf
|
||||
} else {
|
||||
&buf[(pos as usize).min(buf.len())..]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drain(self) -> &'b [u8] {
|
||||
let pos = self.buffer.position() as usize;
|
||||
let buf = self.buffer.into_inner();
|
||||
|
||||
if pos == 0 { buf } else { &buf[pos..] }
|
||||
}
|
||||
}
|
||||
|
||||
impl CrdtReader for RawDecoder<'_> {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.buffer.position() >= self.buffer.get_ref().len() as u64
|
||||
}
|
||||
|
||||
fn len(&self) -> u64 {
|
||||
self.buffer.get_ref().len() as u64 - self.buffer.position()
|
||||
}
|
||||
|
||||
fn read_var_u64(&mut self) -> JwstCodecResult<u64> {
|
||||
read_with_cursor(&mut self.buffer, read_var_u64)
|
||||
}
|
||||
|
||||
fn read_var_i32(&mut self) -> JwstCodecResult<i32> {
|
||||
read_with_cursor(&mut self.buffer, read_var_i32)
|
||||
}
|
||||
|
||||
fn read_var_string(&mut self) -> JwstCodecResult<String> {
|
||||
read_with_cursor(&mut self.buffer, read_var_string)
|
||||
}
|
||||
|
||||
fn read_var_buffer(&mut self) -> JwstCodecResult<Vec<u8>> {
|
||||
read_with_cursor(&mut self.buffer, |i| {
|
||||
read_var_buffer(i).map(|(tail, val)| (tail, val.to_vec()))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u8(&mut self) -> JwstCodecResult<u8> {
|
||||
self.buffer.read_u8().map_err(reader::map_read_error)
|
||||
}
|
||||
|
||||
fn read_f32_be(&mut self) -> JwstCodecResult<f32> {
|
||||
self.buffer.read_f32::<BigEndian>().map_err(reader::map_read_error)
|
||||
}
|
||||
|
||||
fn read_f64_be(&mut self) -> JwstCodecResult<f64> {
|
||||
self.buffer.read_f64::<BigEndian>().map_err(reader::map_read_error)
|
||||
}
|
||||
|
||||
fn read_i64_be(&mut self) -> JwstCodecResult<i64> {
|
||||
self.buffer.read_i64::<BigEndian>().map_err(reader::map_read_error)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_info(&mut self) -> JwstCodecResult<u8> {
|
||||
self.read_u8()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_item_id(&mut self) -> JwstCodecResult<Id> {
|
||||
let client = self.read_var_u64()?;
|
||||
let clock = self.read_var_u64()?;
|
||||
Ok(Id::new(client, clock))
|
||||
}
|
||||
}
|
||||
|
||||
// compatible with ydoc v1
|
||||
#[derive(Default)]
|
||||
pub struct RawEncoder {
|
||||
buffer: Cursor<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl RawEncoder {
|
||||
pub fn into_inner(self) -> Vec<u8> {
|
||||
self.buffer.into_inner()
|
||||
}
|
||||
}
|
||||
|
||||
impl CrdtWriter for RawEncoder {
|
||||
fn write_var_u64(&mut self, num: u64) -> JwstCodecResult {
|
||||
write_var_u64(&mut self.buffer, num).map_err(writer::map_write_error)
|
||||
}
|
||||
fn write_var_i32(&mut self, num: i32) -> JwstCodecResult {
|
||||
write_var_i32(&mut self.buffer, num).map_err(writer::map_write_error)
|
||||
}
|
||||
fn write_var_string<S: AsRef<str>>(&mut self, s: S) -> JwstCodecResult {
|
||||
write_var_string(&mut self.buffer, s).map_err(writer::map_write_error)
|
||||
}
|
||||
fn write_var_buffer(&mut self, buf: &[u8]) -> JwstCodecResult {
|
||||
write_var_buffer(&mut self.buffer, buf).map_err(writer::map_write_error)
|
||||
}
|
||||
fn write_u8(&mut self, num: u8) -> JwstCodecResult {
|
||||
self.buffer.write_u8(num).map_err(writer::map_write_error)?;
|
||||
Ok(())
|
||||
}
|
||||
fn write_f32_be(&mut self, num: f32) -> JwstCodecResult {
|
||||
self.buffer.write_f32::<BigEndian>(num).map_err(writer::map_write_error)
|
||||
}
|
||||
fn write_f64_be(&mut self, num: f64) -> JwstCodecResult {
|
||||
self.buffer.write_f64::<BigEndian>(num).map_err(writer::map_write_error)
|
||||
}
|
||||
fn write_i64_be(&mut self, num: i64) -> JwstCodecResult {
|
||||
self.buffer.write_i64::<BigEndian>(num).map_err(writer::map_write_error)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn write_info(&mut self, num: u8) -> JwstCodecResult {
|
||||
self.write_u8(num)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn write_item_id(&mut self, id: &Id) -> JwstCodecResult {
|
||||
self.write_var_u64(id.client)?;
|
||||
self.write_var_u64(id.clock)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::approx_constant)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_crdt_reader() {
|
||||
{
|
||||
let mut reader = RawDecoder::new(&[0xf2, 0x5]);
|
||||
assert_eq!(reader.read_var_u64().unwrap(), 754);
|
||||
}
|
||||
{
|
||||
let mut reader = RawDecoder::new(&[0x5, b'h', b'e', b'l', b'l', b'o']);
|
||||
|
||||
assert_eq!(reader.clone().read_var_string().unwrap(), "hello");
|
||||
assert_eq!(reader.clone().read_var_buffer().unwrap().as_slice(), b"hello");
|
||||
|
||||
assert_eq!(reader.read_u8().unwrap(), 5);
|
||||
assert_eq!(reader.read_u8().unwrap(), b'h');
|
||||
assert_eq!(reader.read_u8().unwrap(), b'e');
|
||||
assert_eq!(reader.read_u8().unwrap(), b'l');
|
||||
assert_eq!(reader.read_u8().unwrap(), b'l');
|
||||
assert_eq!(reader.read_u8().unwrap(), b'o');
|
||||
}
|
||||
{
|
||||
let mut reader = RawDecoder::new(&[0x40, 0x49, 0x0f, 0xdb]);
|
||||
assert_eq!(reader.read_f32_be().unwrap(), 3.1415927);
|
||||
}
|
||||
{
|
||||
let mut reader = RawDecoder::new(&[0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18]);
|
||||
assert_eq!(reader.read_f64_be().unwrap(), 3.141592653589793);
|
||||
}
|
||||
{
|
||||
let mut reader = RawDecoder::new(&[0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
|
||||
assert_eq!(reader.read_i64_be().unwrap(), i64::MAX);
|
||||
}
|
||||
{
|
||||
let mut reader = RawDecoder::new(&[0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(reader.read_i64_be().unwrap(), i64::MIN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crdt_writer() {
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_var_u64(754).unwrap();
|
||||
assert_eq!(writer.into_inner(), vec![0xf2, 0x5]);
|
||||
}
|
||||
{
|
||||
let ret = vec![0x5, b'h', b'e', b'l', b'l', b'o'];
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_var_string("hello").unwrap();
|
||||
assert_eq!(writer.into_inner(), ret);
|
||||
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_var_buffer(b"hello").unwrap();
|
||||
assert_eq!(writer.into_inner(), ret);
|
||||
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_u8(5).unwrap();
|
||||
writer.write_u8(b'h').unwrap();
|
||||
writer.write_u8(b'e').unwrap();
|
||||
writer.write_u8(b'l').unwrap();
|
||||
writer.write_u8(b'l').unwrap();
|
||||
writer.write_u8(b'o').unwrap();
|
||||
assert_eq!(writer.into_inner(), ret);
|
||||
}
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_f32_be(3.1415927).unwrap();
|
||||
assert_eq!(writer.into_inner(), vec![0x40, 0x49, 0x0f, 0xdb]);
|
||||
}
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_f64_be(3.141592653589793).unwrap();
|
||||
assert_eq!(
|
||||
writer.into_inner(),
|
||||
vec![0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18]
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_i64_be(i64::MAX).unwrap();
|
||||
assert_eq!(
|
||||
writer.into_inner(),
|
||||
vec![0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_i64_be(i64::MIN).unwrap();
|
||||
assert_eq!(
|
||||
writer.into_inner(),
|
||||
vec![0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_info(0x80).unwrap();
|
||||
assert_eq!(writer.into_inner(), vec![0x80]);
|
||||
}
|
||||
{
|
||||
let mut writer = RawEncoder::default();
|
||||
writer.write_item_id(&Id::new(1, 2)).unwrap();
|
||||
assert_eq!(writer.into_inner(), vec![0x1, 0x2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
mod codec_v1;
|
||||
mod reader;
|
||||
mod writer;
|
||||
|
||||
pub use codec_v1::{RawDecoder, RawEncoder};
|
||||
pub use reader::{CrdtRead, CrdtReader};
|
||||
pub use writer::{CrdtWrite, CrdtWriter};
|
||||
|
||||
use super::*;
|
||||
@@ -1,30 +0,0 @@
|
||||
use std::io::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[inline]
|
||||
pub fn map_read_error(e: Error) -> JwstCodecError {
|
||||
JwstCodecError::IncompleteDocument(e.to_string())
|
||||
}
|
||||
|
||||
pub trait CrdtReader {
|
||||
fn is_empty(&self) -> bool;
|
||||
fn len(&self) -> u64;
|
||||
fn read_var_u64(&mut self) -> JwstCodecResult<u64>;
|
||||
fn read_var_i32(&mut self) -> JwstCodecResult<i32>;
|
||||
fn read_var_string(&mut self) -> JwstCodecResult<String>;
|
||||
fn read_var_buffer(&mut self) -> JwstCodecResult<Vec<u8>>;
|
||||
fn read_u8(&mut self) -> JwstCodecResult<u8>;
|
||||
fn read_f32_be(&mut self) -> JwstCodecResult<f32>;
|
||||
fn read_f64_be(&mut self) -> JwstCodecResult<f64>;
|
||||
fn read_i64_be(&mut self) -> JwstCodecResult<i64>;
|
||||
|
||||
fn read_info(&mut self) -> JwstCodecResult<u8>;
|
||||
fn read_item_id(&mut self) -> JwstCodecResult<Id>;
|
||||
}
|
||||
|
||||
pub trait CrdtRead<R: CrdtReader> {
|
||||
fn read(reader: &mut R) -> JwstCodecResult<Self>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use std::io::Error;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[inline]
|
||||
pub fn map_write_error(e: Error) -> JwstCodecError {
|
||||
JwstCodecError::InvalidWriteBuffer(e.to_string())
|
||||
}
|
||||
|
||||
pub trait CrdtWriter {
|
||||
fn write_var_u64(&mut self, num: u64) -> JwstCodecResult;
|
||||
fn write_var_i32(&mut self, num: i32) -> JwstCodecResult;
|
||||
fn write_var_string<S: AsRef<str>>(&mut self, s: S) -> JwstCodecResult;
|
||||
fn write_var_buffer(&mut self, buf: &[u8]) -> JwstCodecResult;
|
||||
fn write_u8(&mut self, num: u8) -> JwstCodecResult;
|
||||
fn write_f32_be(&mut self, num: f32) -> JwstCodecResult;
|
||||
fn write_f64_be(&mut self, num: f64) -> JwstCodecResult;
|
||||
fn write_i64_be(&mut self, num: i64) -> JwstCodecResult;
|
||||
|
||||
fn write_info(&mut self, num: u8) -> JwstCodecResult;
|
||||
fn write_item_id(&mut self, id: &Id) -> JwstCodecResult;
|
||||
}
|
||||
|
||||
pub trait CrdtWrite<W: CrdtWriter> {
|
||||
fn write(&self, writer: &mut W) -> JwstCodecResult
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
|
||||
pub(crate) enum Parent {
|
||||
#[cfg_attr(test, proptest(skip))]
|
||||
Type(YTypeRef),
|
||||
#[cfg_attr(test, proptest(value = "Parent::String(SmolStr::default())"))]
|
||||
String(SmolStr),
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(all(test, not(loom)), derive(proptest_derive::Arbitrary))]
|
||||
pub(crate) struct Item {
|
||||
pub id: Id,
|
||||
pub origin_left_id: Option<Id>,
|
||||
pub origin_right_id: Option<Id>,
|
||||
#[cfg_attr(all(test, not(loom)), proptest(value = "Somr::none()"))]
|
||||
pub left: ItemRef,
|
||||
#[cfg_attr(all(test, not(loom)), proptest(value = "Somr::none()"))]
|
||||
pub right: ItemRef,
|
||||
pub parent: Option<Parent>,
|
||||
#[cfg_attr(all(test, not(loom)), proptest(value = "Option::<SmolStr>::None"))]
|
||||
pub parent_sub: Option<SmolStr>,
|
||||
pub content: Content,
|
||||
#[cfg_attr(all(test, not(loom)), proptest(value = "ItemFlag::default()"))]
|
||||
pub flags: ItemFlag,
|
||||
}
|
||||
|
||||
// make all Item readonly
|
||||
pub(crate) type ItemRef = Somr<Item>;
|
||||
|
||||
impl PartialEq for Item {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Item {}
|
||||
|
||||
impl std::fmt::Debug for Item {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut dbg = f.debug_struct("Item");
|
||||
dbg
|
||||
.field("id", &self.id)
|
||||
.field("origin_left_id", &self.origin_left_id)
|
||||
.field("origin_right_id", &self.origin_right_id);
|
||||
|
||||
if let Some(left) = self.left.get() {
|
||||
dbg.field("left", &left.id);
|
||||
}
|
||||
|
||||
if let Some(right) = self.right.get() {
|
||||
dbg.field("right", &right.id);
|
||||
}
|
||||
|
||||
dbg
|
||||
.field(
|
||||
"parent",
|
||||
&self.parent.as_ref().map(|p| match p {
|
||||
Parent::Type(_) => "[Type]".to_string(),
|
||||
Parent::String(name) => format!("Parent({name})"),
|
||||
Parent::Id(id) => format!("({}, {})", id.client, id.clock),
|
||||
}),
|
||||
)
|
||||
.field("parent_sub", &self.parent_sub)
|
||||
.field("content", &self.content)
|
||||
.field("flags", &self.flags)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Item {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Item{}: [{:?}]", self.id, self.content)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Item {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: Id::default(),
|
||||
origin_left_id: None,
|
||||
origin_right_id: None,
|
||||
left: Somr::none(),
|
||||
right: Somr::none(),
|
||||
parent: None,
|
||||
parent_sub: None,
|
||||
content: Content::Deleted(0),
|
||||
flags: ItemFlag::from(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn new(
|
||||
id: Id,
|
||||
content: Content,
|
||||
left: Somr<Item>,
|
||||
right: Somr<Item>,
|
||||
parent: Option<Parent>,
|
||||
parent_sub: Option<SmolStr>,
|
||||
) -> Self {
|
||||
let flags = ItemFlag::from(if content.countable() {
|
||||
item_flags::ITEM_COUNTABLE
|
||||
} else {
|
||||
0
|
||||
});
|
||||
|
||||
Self {
|
||||
id,
|
||||
origin_left_id: left.get().map(|left| left.last_id()),
|
||||
left,
|
||||
origin_right_id: right.get().map(|right| right.id),
|
||||
right,
|
||||
parent,
|
||||
parent_sub,
|
||||
content,
|
||||
flags,
|
||||
}
|
||||
}
|
||||
|
||||
// find a note that has parent info
|
||||
// in crdt tree, not all node has parent info
|
||||
// so we need to check left and right node if they have parent info
|
||||
pub fn find_node_with_parent_info(&self) -> Option<Item> {
|
||||
if self.parent.is_some() {
|
||||
return Some(self.clone());
|
||||
} else if let Some(item) = self.left.get() {
|
||||
if item.parent.is_none() {
|
||||
if let Some(item) = item.right.get() {
|
||||
return Some(item.clone());
|
||||
}
|
||||
} else {
|
||||
return Some(item.clone());
|
||||
}
|
||||
} else if let Some(item) = self.right.get() {
|
||||
return Some(item.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
self.content.clock_len()
|
||||
}
|
||||
|
||||
pub fn deleted(&self) -> bool {
|
||||
self.flags.deleted()
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> bool {
|
||||
if self.deleted() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.flags.set_deleted();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn countable(&self) -> bool {
|
||||
self.flags.countable()
|
||||
}
|
||||
|
||||
pub fn keep(&self) -> bool {
|
||||
self.flags.keep()
|
||||
}
|
||||
|
||||
pub fn indexable(&self) -> bool {
|
||||
self.countable() && !self.deleted()
|
||||
}
|
||||
|
||||
pub fn last_id(&self) -> Id {
|
||||
let Id { client, clock } = self.id;
|
||||
|
||||
Id::new(client, clock + self.len() - 1)
|
||||
}
|
||||
|
||||
pub fn split_at(&self, offset: u64) -> JwstCodecResult<(Self, Self)> {
|
||||
debug_assert!(offset > 0 && self.len() > 1 && offset < self.len());
|
||||
let id = self.id;
|
||||
let right_id = Id::new(id.client, id.clock + offset);
|
||||
let (left_content, right_content) = self.content.split(offset)?;
|
||||
|
||||
let left_item = Item::new(
|
||||
id,
|
||||
left_content,
|
||||
// let caller connect left <-> node <-> right
|
||||
Somr::none(),
|
||||
Somr::none(),
|
||||
self.parent.clone(),
|
||||
self.parent_sub.clone(),
|
||||
);
|
||||
|
||||
let right_item = Item::new(
|
||||
right_id,
|
||||
right_content,
|
||||
// let caller connect left <-> node <-> right
|
||||
Somr::none(),
|
||||
Somr::none(),
|
||||
self.parent.clone(),
|
||||
self.parent_sub.clone(),
|
||||
);
|
||||
|
||||
if left_item.deleted() {
|
||||
left_item.flags.set_deleted();
|
||||
}
|
||||
if left_item.keep() {
|
||||
left_item.flags.set_keep();
|
||||
}
|
||||
|
||||
Ok((left_item, right_item))
|
||||
}
|
||||
|
||||
fn get_info(&self) -> u8 {
|
||||
let mut info = self.content.get_info();
|
||||
|
||||
if self.origin_left_id.is_some() {
|
||||
info |= item_flags::ITEM_HAS_LEFT_ID;
|
||||
}
|
||||
if self.origin_right_id.is_some() {
|
||||
info |= item_flags::ITEM_HAS_RIGHT_ID;
|
||||
}
|
||||
if self.parent_sub.is_some() {
|
||||
info |= item_flags::ITEM_HAS_PARENT_SUB;
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let has_id = self.origin_left_id.is_some() || self.origin_right_id.is_some();
|
||||
!has_id && self.parent.is_some() || has_id && self.parent.is_none() && self.parent_sub.is_none()
|
||||
}
|
||||
|
||||
pub fn read<R: CrdtReader>(decoder: &mut R, id: Id, info: u8, first_5_bit: u8) -> JwstCodecResult<Self> {
|
||||
let flags: ItemFlag = info.into();
|
||||
let has_left_id = flags.check(item_flags::ITEM_HAS_LEFT_ID);
|
||||
let has_right_id = flags.check(item_flags::ITEM_HAS_RIGHT_ID);
|
||||
let has_parent_sub = flags.check(item_flags::ITEM_HAS_PARENT_SUB);
|
||||
let has_not_sibling = flags.not(item_flags::ITEM_HAS_SIBLING);
|
||||
|
||||
// NOTE: read order must keep the same as the order in yjs
|
||||
// TODO: this data structure design will break the cpu OOE, need to be optimized
|
||||
let item = Self {
|
||||
id,
|
||||
origin_left_id: if has_left_id {
|
||||
Some(decoder.read_item_id()?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
origin_right_id: if has_right_id {
|
||||
Some(decoder.read_item_id()?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
parent: {
|
||||
if has_not_sibling {
|
||||
let has_parent = decoder.read_var_u64()? == 1;
|
||||
Some(if has_parent {
|
||||
Parent::String(SmolStr::new(decoder.read_var_string()?))
|
||||
} else {
|
||||
Parent::Id(decoder.read_item_id()?)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
parent_sub: if has_not_sibling && has_parent_sub {
|
||||
Some(SmolStr::new(decoder.read_var_string()?))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
content: {
|
||||
// tag must not GC or Skip, this must process in parse_struct
|
||||
debug_assert_ne!(first_5_bit, 0);
|
||||
debug_assert_ne!(first_5_bit, 10);
|
||||
Content::read(decoder, first_5_bit)?
|
||||
},
|
||||
left: Somr::none(),
|
||||
right: Somr::none(),
|
||||
flags: ItemFlag::from(0),
|
||||
};
|
||||
|
||||
if item.content.countable() {
|
||||
item.flags.set_countable();
|
||||
}
|
||||
|
||||
if matches!(item.content, Content::Deleted(_)) {
|
||||
item.flags.set_deleted();
|
||||
}
|
||||
|
||||
debug_assert!(item.is_valid());
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub fn write<W: CrdtWriter>(&self, encoder: &mut W) -> JwstCodecResult {
|
||||
let info = self.get_info();
|
||||
let has_not_sibling = info & item_flags::ITEM_HAS_SIBLING == 0;
|
||||
|
||||
encoder.write_info(info)?;
|
||||
|
||||
if let Some(left_id) = self.origin_left_id {
|
||||
encoder.write_item_id(&left_id)?;
|
||||
}
|
||||
if let Some(right_id) = self.origin_right_id {
|
||||
encoder.write_item_id(&right_id)?;
|
||||
}
|
||||
|
||||
if has_not_sibling {
|
||||
if let Some(parent) = &self.parent {
|
||||
match parent {
|
||||
Parent::String(s) => {
|
||||
encoder.write_var_u64(1)?;
|
||||
encoder.write_var_string(s)?;
|
||||
}
|
||||
Parent::Id(id) => {
|
||||
encoder.write_var_u64(0)?;
|
||||
encoder.write_item_id(id)?;
|
||||
}
|
||||
Parent::Type(ty) => {
|
||||
if let Some(ty) = ty.ty() {
|
||||
if let Some(item) = ty.item.get() {
|
||||
encoder.write_var_u64(0)?;
|
||||
encoder.write_item_id(&item.id)?;
|
||||
} else if let Some(name) = &ty.root_name {
|
||||
encoder.write_var_u64(1)?;
|
||||
encoder.write_var_string(name)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if item delete, it must not exists in crdt state tree
|
||||
debug_assert!(!self.deleted());
|
||||
return Err(JwstCodecError::ParentNotFound);
|
||||
}
|
||||
|
||||
if let Some(parent_sub) = &self.parent_sub {
|
||||
encoder.write_var_string(parent_sub)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.content.write(encoder)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deep_compare(&self, other: &Self) -> bool {
|
||||
if self.id != other.id
|
||||
|| self.deleted() != other.deleted()
|
||||
|| self.len() != other.len()
|
||||
|| self.left.get().map(|l| l.last_id()) != other.left.get().map(|l| l.last_id())
|
||||
|| self.right.get().map(|r| r.id) != other.right.get().map(|r| r.id)
|
||||
|| self.origin_left_id != other.origin_left_id
|
||||
|| self.origin_right_id != other.origin_right_id
|
||||
|| self.parent_sub != other.parent_sub
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(any(debug, test))]
|
||||
impl Item {
|
||||
pub fn print_left(&self) {
|
||||
let mut ret = vec![format!("Self{}: [{:?}]", self.id, self.content)];
|
||||
let mut left: Somr<Item> = self.left.clone();
|
||||
|
||||
while let Some(item) = left.get() {
|
||||
ret.push(format!("{item}"));
|
||||
left = item.left.clone();
|
||||
}
|
||||
ret.reverse();
|
||||
|
||||
println!("{}", ret.join(" <- "));
|
||||
}
|
||||
|
||||
pub fn print_right(&self) {
|
||||
let mut ret = vec![format!("Self{}: [{:?}]", self.id, self.content)];
|
||||
let mut right = self.right.clone();
|
||||
|
||||
while let Some(item) = right.get() {
|
||||
ret.push(format!("{item}"));
|
||||
right = item.right.clone();
|
||||
}
|
||||
|
||||
println!("{}", ret.join(" -> "));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(not(loom))]
|
||||
use proptest::{collection::vec, prelude::*};
|
||||
|
||||
#[cfg(not(loom))]
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(loom))]
|
||||
fn item_round_trip(item: &mut Item) -> JwstCodecResult {
|
||||
if !item.is_valid() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if item.content.countable() {
|
||||
item.flags.set_countable();
|
||||
}
|
||||
|
||||
let mut encoder = RawEncoder::default();
|
||||
item.write(&mut encoder)?;
|
||||
|
||||
let update = encoder.into_inner();
|
||||
let mut decoder = RawDecoder::new(&update);
|
||||
|
||||
let info = decoder.read_info()?;
|
||||
let first_5_bit = info & 0b11111;
|
||||
let decoded_item = Item::read(&mut decoder, item.id, info, first_5_bit)?;
|
||||
|
||||
assert_eq!(item, &decoded_item);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(loom))]
|
||||
proptest! {
|
||||
#[test]
|
||||
#[cfg_attr(miri, ignore)]
|
||||
fn test_random_content(mut items in vec(any::<Item>(), 0..10)) {
|
||||
for item in &mut items {
|
||||
item_round_trip(item).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
#[rustfmt::skip]
|
||||
#[allow(dead_code)]
|
||||
pub mod item_flags {
|
||||
pub const ITEM_KEEP : u8 = 0b0000_0001;
|
||||
pub const ITEM_COUNTABLE : u8 = 0b0000_0010;
|
||||
pub const ITEM_DELETED : u8 = 0b0000_0100;
|
||||
pub const ITEM_MARKED : u8 = 0b0000_1000;
|
||||
pub const ITEM_HAS_PARENT_SUB : u8 = 0b0010_0000;
|
||||
pub const ITEM_HAS_RIGHT_ID : u8 = 0b0100_0000;
|
||||
pub const ITEM_HAS_LEFT_ID : u8 = 0b1000_0000;
|
||||
pub const ITEM_HAS_SIBLING : u8 = 0b1100_0000;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ItemFlag(pub(self) AtomicU8);
|
||||
|
||||
impl Default for ItemFlag {
|
||||
fn default() -> Self {
|
||||
Self(AtomicU8::new(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ItemFlag {
|
||||
fn clone(&self) -> Self {
|
||||
Self(AtomicU8::new(self.0.load(Ordering::Acquire)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u8> for ItemFlag {
|
||||
fn from(flags: u8) -> Self {
|
||||
Self(AtomicU8::new(flags))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ItemFlag {
|
||||
#[inline(always)]
|
||||
pub fn set(&self, flag: u8) {
|
||||
self.0.fetch_or(flag, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn clear(&self, flag: u8) {
|
||||
self.0.fetch_and(!flag, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn check(&self, flag: u8) -> bool {
|
||||
self.0.load(Ordering::Acquire) & flag == flag
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn not(&self, flag: u8) -> bool {
|
||||
self.0.load(Ordering::Acquire) & flag == 0
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn keep(&self) -> bool {
|
||||
self.check(item_flags::ITEM_KEEP)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn set_keep(&self) {
|
||||
self.set(item_flags::ITEM_KEEP);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn clear_keep(&self) {
|
||||
self.clear(item_flags::ITEM_KEEP);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn countable(&self) -> bool {
|
||||
self.check(item_flags::ITEM_COUNTABLE)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn set_countable(&self) {
|
||||
self.set(item_flags::ITEM_COUNTABLE);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn clear_countable(&self) {
|
||||
self.clear(item_flags::ITEM_COUNTABLE);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn deleted(&self) -> bool {
|
||||
self.check(item_flags::ITEM_DELETED)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn set_deleted(&self) {
|
||||
self.set(item_flags::ITEM_DELETED);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn clear_deleted(&self) {
|
||||
self.clear(item_flags::ITEM_DELETED);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_flag_set_and_clear() {
|
||||
{
|
||||
let flag = super::ItemFlag::default();
|
||||
assert!(!flag.keep());
|
||||
flag.set_keep();
|
||||
assert!(flag.keep());
|
||||
flag.clear_keep();
|
||||
assert!(!flag.keep());
|
||||
assert_eq!(
|
||||
flag.0.load(Ordering::SeqCst),
|
||||
ItemFlag::default().0.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let flag = super::ItemFlag::default();
|
||||
assert!(!flag.countable());
|
||||
flag.set_countable();
|
||||
assert!(flag.countable());
|
||||
flag.clear_countable();
|
||||
assert!(!flag.countable());
|
||||
assert_eq!(
|
||||
flag.0.load(Ordering::SeqCst),
|
||||
ItemFlag::default().0.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let flag = super::ItemFlag::default();
|
||||
assert!(!flag.deleted());
|
||||
flag.set_deleted();
|
||||
assert!(flag.deleted());
|
||||
flag.clear_deleted();
|
||||
assert!(!flag.deleted());
|
||||
assert_eq!(
|
||||
flag.0.load(Ordering::SeqCst),
|
||||
ItemFlag::default().0.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let flag = super::ItemFlag::default();
|
||||
flag.set_keep();
|
||||
flag.set_countable();
|
||||
flag.set_deleted();
|
||||
assert!(flag.keep());
|
||||
assert!(flag.countable());
|
||||
assert!(flag.deleted());
|
||||
flag.clear_keep();
|
||||
flag.clear_countable();
|
||||
flag.clear_deleted();
|
||||
assert!(!flag.keep());
|
||||
assert!(!flag.countable());
|
||||
assert!(!flag.deleted());
|
||||
assert_eq!(
|
||||
flag.0.load(Ordering::SeqCst),
|
||||
ItemFlag::default().0.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
mod any;
|
||||
mod content;
|
||||
mod delete_set;
|
||||
mod id;
|
||||
mod io;
|
||||
mod item;
|
||||
mod item_flag;
|
||||
mod refs;
|
||||
mod update;
|
||||
#[cfg(test)]
|
||||
mod utils;
|
||||
|
||||
pub use any::Any;
|
||||
pub(crate) use content::Content;
|
||||
pub use delete_set::DeleteSet;
|
||||
pub use id::{Client, Clock, Id};
|
||||
pub use io::{CrdtRead, CrdtReader, CrdtWrite, CrdtWriter, RawDecoder, RawEncoder};
|
||||
pub(crate) use item::{Item, ItemRef, Parent};
|
||||
pub(crate) use item_flag::{ItemFlag, item_flags};
|
||||
pub(crate) use refs::Node;
|
||||
pub use update::Update;
|
||||
#[cfg(test)]
|
||||
pub(crate) use utils::*;
|
||||
|
||||
use super::*;
|
||||
@@ -1,488 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// make fields Copy + Clone without much effort
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(all(test, not(loom)), derive(proptest_derive::Arbitrary))]
|
||||
pub(crate) enum Node {
|
||||
GC(Box<NodeLen>),
|
||||
Skip(Box<NodeLen>),
|
||||
Item(ItemRef),
|
||||
}
|
||||
|
||||
/// Simple representation of id and len struct used by GC and Skip node.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(all(test, not(loom)), derive(proptest_derive::Arbitrary))]
|
||||
pub(crate) struct NodeLen {
|
||||
pub id: Id,
|
||||
pub len: u64,
|
||||
}
|
||||
|
||||
impl<W: CrdtWriter> CrdtWrite<W> for Node {
|
||||
fn write(&self, writer: &mut W) -> JwstCodecResult {
|
||||
match self {
|
||||
Node::GC(item) => {
|
||||
writer.write_info(0)?;
|
||||
writer.write_var_u64(item.len)
|
||||
}
|
||||
Node::Skip(item) => {
|
||||
writer.write_info(10)?;
|
||||
writer.write_var_u64(item.len)
|
||||
}
|
||||
Node::Item(item) => item.get().unwrap().write(writer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Node {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Node::GC(left), Node::GC(right)) => left.id == right.id,
|
||||
(Node::Skip(left), Node::Skip(right)) => left.id == right.id,
|
||||
(Node::Item(item1), Node::Item(item2)) => item1.get() == item2.get(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Node {}
|
||||
|
||||
impl From<Item> for Node {
|
||||
fn from(value: Item) -> Self {
|
||||
Self::Item(Somr::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn new_skip(id: Id, len: u64) -> Self {
|
||||
Self::Skip(Box::new(NodeLen { id, len }))
|
||||
}
|
||||
|
||||
pub fn new_gc(id: Id, len: u64) -> Self {
|
||||
Self::GC(Box::new(NodeLen { id, len }))
|
||||
}
|
||||
|
||||
pub fn read<R: CrdtReader>(decoder: &mut R, id: Id) -> JwstCodecResult<Self> {
|
||||
let info = decoder.read_info()?;
|
||||
let first_5_bit = info & 0b11111;
|
||||
|
||||
match first_5_bit {
|
||||
0 => {
|
||||
let len = decoder.read_var_u64()?;
|
||||
Ok(Node::new_gc(id, len))
|
||||
}
|
||||
10 => {
|
||||
let len = decoder.read_var_u64()?;
|
||||
Ok(Node::new_skip(id, len))
|
||||
}
|
||||
_ => {
|
||||
let item = Somr::new(Item::read(decoder, id, info, first_5_bit)?);
|
||||
|
||||
if let Content::Type(ty) = &item.get().unwrap().content
|
||||
&& let Some(mut ty) = ty.ty_mut()
|
||||
{
|
||||
ty.item = item.clone();
|
||||
}
|
||||
|
||||
Ok(Node::Item(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Id {
|
||||
match self {
|
||||
Node::GC(item) => item.id,
|
||||
Node::Skip(item) => item.id,
|
||||
Node::Item(item) => unsafe { item.get_unchecked() }.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client(&self) -> Client {
|
||||
self.id().client
|
||||
}
|
||||
|
||||
pub fn clock(&self) -> Clock {
|
||||
self.id().clock
|
||||
}
|
||||
|
||||
pub fn len(&self) -> u64 {
|
||||
match self {
|
||||
Self::GC(item) => item.len,
|
||||
Self::Skip(item) => item.len,
|
||||
Self::Item(item) => unsafe { item.get_unchecked() }.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_gc(&self) -> bool {
|
||||
matches!(self, Self::GC { .. })
|
||||
}
|
||||
|
||||
pub fn is_skip(&self) -> bool {
|
||||
matches!(self, Self::Skip { .. })
|
||||
}
|
||||
|
||||
pub fn is_item(&self) -> bool {
|
||||
matches!(self, Self::Item(_))
|
||||
}
|
||||
|
||||
pub fn as_item(&self) -> Somr<Item> {
|
||||
if let Self::Item(item) = self {
|
||||
item.clone()
|
||||
} else {
|
||||
Somr::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left(&self) -> Option<Self> {
|
||||
if let Node::Item(item) = self {
|
||||
item.get().map(|item| Node::Item(item.left.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn right(&self) -> Option<Self> {
|
||||
if let Node::Item(item) = self {
|
||||
item.get().map(|item| Node::Item(item.right.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn head(&self) -> Self {
|
||||
let mut cur = self.clone();
|
||||
|
||||
while let Some(left) = cur.left() {
|
||||
if left.is_item() {
|
||||
cur = left
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cur
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tail(&self) -> Self {
|
||||
let mut cur = self.clone();
|
||||
|
||||
while let Some(right) = cur.right() {
|
||||
if right.is_item() {
|
||||
cur = right
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cur
|
||||
}
|
||||
|
||||
pub fn flags(&self) -> ItemFlag {
|
||||
if let Node::Item(item) = self {
|
||||
item.get().unwrap().flags.clone()
|
||||
} else {
|
||||
// deleted
|
||||
ItemFlag::from(4)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_id(&self) -> Option<Id> {
|
||||
if let Node::Item(item) = self {
|
||||
item.get().map(|item| item.last_id())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split_at(&self, offset: u64) -> JwstCodecResult<(Self, Self)> {
|
||||
if let Self::Item(item) = self {
|
||||
let item = item.get().unwrap();
|
||||
debug_assert!(offset > 0 && item.len() > 1 && offset < item.len());
|
||||
let id = item.id;
|
||||
let right_id = Id::new(id.client, id.clock + offset);
|
||||
let (left_content, right_content) = item.content.split(offset)?;
|
||||
|
||||
let left_item = Somr::new(Item::new(
|
||||
id,
|
||||
left_content,
|
||||
// let caller connect left <-> node <-> right
|
||||
Somr::none(),
|
||||
Somr::none(),
|
||||
item.parent.clone(),
|
||||
item.parent_sub.clone(),
|
||||
));
|
||||
|
||||
let right_item = Somr::new(Item::new(
|
||||
right_id,
|
||||
right_content,
|
||||
// let caller connect left <-> node <-> right
|
||||
Somr::none(),
|
||||
Somr::none(),
|
||||
item.parent.clone(),
|
||||
item.parent_sub.clone(),
|
||||
));
|
||||
|
||||
Ok((Self::Item(left_item), Self::Item(right_item)))
|
||||
} else {
|
||||
Err(JwstCodecError::ItemSplitNotSupport)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
pub fn countable(&self) -> bool {
|
||||
self.flags().countable()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn deleted(&self) -> bool {
|
||||
self.flags().deleted()
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, right: Self) -> bool {
|
||||
match (self, right) {
|
||||
(Node::GC(left), Node::GC(right)) => {
|
||||
left.len += right.len;
|
||||
}
|
||||
(Node::Skip(left), Node::Skip(right)) => {
|
||||
left.len += right.len;
|
||||
}
|
||||
(Node::Item(lref), Node::Item(rref)) => {
|
||||
let mut litem = unsafe { lref.get_mut_unchecked() };
|
||||
let mut ritem = unsafe { rref.get_mut_unchecked() };
|
||||
let llen = litem.len();
|
||||
|
||||
let parent_kind = match &litem.parent {
|
||||
Some(Parent::Type(ty)) => ty.ty().map(|ty| ty.kind()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if litem.id.client != ritem.id.client
|
||||
// not same delete status
|
||||
|| litem.deleted() != ritem.deleted()
|
||||
// not clock continuous
|
||||
|| litem.id.clock + litem.len() != ritem.id.clock
|
||||
// not insertion continuous
|
||||
|| Some(litem.last_id()) != ritem.origin_left_id
|
||||
// not insertion continuous
|
||||
|| litem.origin_right_id != ritem.origin_right_id
|
||||
// not runtime continuous
|
||||
|| litem.right != rref
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
match (&mut litem.content, &mut ritem.content) {
|
||||
(Content::Deleted(l), Content::Deleted(r)) => {
|
||||
*l += *r;
|
||||
}
|
||||
(Content::Json(l), Content::Json(r)) => {
|
||||
l.extend(r.drain(0..));
|
||||
}
|
||||
(Content::String(l), Content::String(r)) => {
|
||||
let allow_merge_string = matches!(parent_kind, Some(YTypeKind::Text | YTypeKind::XMLText));
|
||||
|
||||
if !allow_merge_string {
|
||||
return false;
|
||||
}
|
||||
|
||||
*l += r;
|
||||
}
|
||||
(Content::Any(l), Content::Any(r)) => {
|
||||
l.extend(r.drain(0..));
|
||||
}
|
||||
_ => {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Parent::Type(p)) = &litem.parent
|
||||
&& let Some(parent) = p.ty_mut()
|
||||
&& let Some(markers) = &parent.markers
|
||||
{
|
||||
markers.replace_marker(rref.clone(), lref.clone(), -(llen as i64));
|
||||
}
|
||||
|
||||
if ritem.keep() {
|
||||
litem.flags.set_keep()
|
||||
}
|
||||
|
||||
litem.right = ritem.right.clone();
|
||||
unsafe {
|
||||
if litem.right.is_some() {
|
||||
litem.right.get_mut_unchecked().left = lref.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<Node>> for Somr<Item> {
|
||||
fn from(value: Option<Node>) -> Self {
|
||||
match value {
|
||||
Some(n) => n.as_item(),
|
||||
None => Somr::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Option<Node>> for Somr<Item> {
|
||||
fn from(value: &Option<Node>) -> Self {
|
||||
match value {
|
||||
Some(n) => n.as_item(),
|
||||
None => Somr::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<&Node>> for Somr<Item> {
|
||||
fn from(value: Option<&Node>) -> Self {
|
||||
match value {
|
||||
Some(n) => n.as_item(),
|
||||
None => Somr::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(not(loom))]
|
||||
use proptest::{collection::vec, prelude::*};
|
||||
|
||||
use super::{utils::ItemBuilder, *};
|
||||
|
||||
#[test]
|
||||
fn test_struct_info() {
|
||||
loom_model!({
|
||||
{
|
||||
let struct_info = Node::new_gc(Id::new(1, 0), 10);
|
||||
assert_eq!(struct_info.len(), 10);
|
||||
assert_eq!(struct_info.client(), 1);
|
||||
assert_eq!(struct_info.clock(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
let struct_info = Node::new_skip(Id::new(2, 0), 20);
|
||||
assert_eq!(struct_info.len(), 20);
|
||||
assert_eq!(struct_info.client(), 2);
|
||||
assert_eq!(struct_info.clock(), 0);
|
||||
}
|
||||
|
||||
{
|
||||
let item = ItemBuilder::new()
|
||||
.id((3, 0).into())
|
||||
.left_id(None)
|
||||
.right_id(None)
|
||||
.parent(Some(Parent::String(SmolStr::new_inline("parent"))))
|
||||
.parent_sub(None)
|
||||
.content(Content::String(String::from("content")))
|
||||
.build();
|
||||
let struct_info = Node::Item(Somr::new(item));
|
||||
|
||||
assert_eq!(struct_info.len(), 7);
|
||||
assert_eq!(struct_info.client(), 3);
|
||||
assert_eq!(struct_info.clock(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_write_struct_info() {
|
||||
loom_model!({
|
||||
let has_not_parent_id_and_has_parent = Node::Item(Somr::new(
|
||||
ItemBuilder::new()
|
||||
.id((0, 0).into())
|
||||
.left_id(None)
|
||||
.right_id(None)
|
||||
.parent(Some(Parent::String(SmolStr::new_inline("parent"))))
|
||||
.parent_sub(None)
|
||||
.content(Content::String(String::from("content")))
|
||||
.build(),
|
||||
));
|
||||
|
||||
let has_not_parent_id_and_has_parent_with_key = Node::Item(Somr::new(
|
||||
ItemBuilder::new()
|
||||
.id((0, 0).into())
|
||||
.left_id(None)
|
||||
.right_id(None)
|
||||
.parent(Some(Parent::String(SmolStr::new_inline("parent"))))
|
||||
.parent_sub(Some(SmolStr::new_inline("parent_sub")))
|
||||
.content(Content::String(String::from("content")))
|
||||
.build(),
|
||||
));
|
||||
|
||||
let has_parent_id = Node::Item(Somr::new(
|
||||
ItemBuilder::new()
|
||||
.id((0, 0).into())
|
||||
.left_id(Some((1, 2).into()))
|
||||
.right_id(Some((2, 5).into()))
|
||||
.parent(None)
|
||||
.parent_sub(None)
|
||||
.content(Content::String(String::from("content")))
|
||||
.build(),
|
||||
));
|
||||
|
||||
let struct_infos = vec![
|
||||
Node::new_gc((0, 0).into(), 42),
|
||||
Node::new_skip((0, 0).into(), 314),
|
||||
has_not_parent_id_and_has_parent,
|
||||
has_not_parent_id_and_has_parent_with_key,
|
||||
has_parent_id,
|
||||
];
|
||||
|
||||
for info in struct_infos {
|
||||
let mut encoder = RawEncoder::default();
|
||||
info.write(&mut encoder).unwrap();
|
||||
|
||||
let update = encoder.into_inner();
|
||||
let mut decoder = RawDecoder::new(&update);
|
||||
let decoded = Node::read(&mut decoder, info.id()).unwrap();
|
||||
|
||||
assert_eq!(info, decoded);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(loom))]
|
||||
fn struct_info_round_trip(info: &mut Node) -> JwstCodecResult {
|
||||
if let Node::Item(item) = info
|
||||
&& let Some(item) = item.get_mut()
|
||||
{
|
||||
if !item.is_valid() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if item.content.countable() {
|
||||
item.flags.set_countable();
|
||||
}
|
||||
}
|
||||
let mut encoder = RawEncoder::default();
|
||||
info.write(&mut encoder)?;
|
||||
|
||||
let ret = encoder.into_inner();
|
||||
let mut decoder = RawDecoder::new(&ret);
|
||||
|
||||
let decoded = Node::read(&mut decoder, info.id())?;
|
||||
|
||||
assert_eq!(info, &decoded);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(loom))]
|
||||
proptest! {
|
||||
#[test]
|
||||
#[cfg_attr(miri, ignore)]
|
||||
fn test_random_struct_info(mut infos in vec(any::<Node>(), 0..10)) {
|
||||
for info in &mut infos {
|
||||
struct_info_round_trip(info).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
use std::{collections::VecDeque, ops::Range};
|
||||
|
||||
use super::*;
|
||||
use crate::doc::StateVector;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Update {
|
||||
pub(crate) structs: ClientMap<VecDeque<Node>>,
|
||||
pub(crate) delete_set: DeleteSet,
|
||||
|
||||
/// all unapplicable items that we can't integrate into doc
|
||||
/// any item with inconsistent id clock or missing dependency will be put
|
||||
/// here
|
||||
pub(crate) pending_structs: ClientMap<VecDeque<Node>>,
|
||||
/// missing state vector after applying updates
|
||||
pub(crate) missing_state: StateVector,
|
||||
/// all unapplicable delete set
|
||||
pub(crate) pending_delete_set: DeleteSet,
|
||||
}
|
||||
|
||||
impl<R: CrdtReader> CrdtRead<R> for Update {
|
||||
fn read(decoder: &mut R) -> JwstCodecResult<Self> {
|
||||
let num_of_clients = decoder.read_var_u64()? as usize;
|
||||
|
||||
// See: [HASHMAP_SAFE_CAPACITY]
|
||||
let mut map = ClientMap::with_capacity(num_of_clients.min(HASHMAP_SAFE_CAPACITY));
|
||||
for _ in 0..num_of_clients {
|
||||
let num_of_structs = decoder.read_var_u64()? as usize;
|
||||
let client = decoder.read_var_u64()?;
|
||||
let mut clock = decoder.read_var_u64()?;
|
||||
|
||||
// same reason as above
|
||||
let mut structs = VecDeque::with_capacity(num_of_structs.min(HASHMAP_SAFE_CAPACITY));
|
||||
|
||||
for _ in 0..num_of_structs {
|
||||
let struct_info = Node::read(decoder, Id::new(client, clock))?;
|
||||
clock += struct_info.len();
|
||||
structs.push_back(struct_info);
|
||||
}
|
||||
|
||||
structs.shrink_to_fit();
|
||||
map.insert(client, structs);
|
||||
}
|
||||
|
||||
map.shrink_to_fit();
|
||||
|
||||
let delete_set = DeleteSet::read(decoder)?;
|
||||
|
||||
if !decoder.is_empty() {
|
||||
return Err(JwstCodecError::UpdateNotFullyConsumed(decoder.len() as usize));
|
||||
}
|
||||
|
||||
Ok(Update {
|
||||
structs: map,
|
||||
delete_set,
|
||||
..Update::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: CrdtWriter> CrdtWrite<W> for Update {
|
||||
fn write(&self, encoder: &mut W) -> JwstCodecResult {
|
||||
encoder.write_var_u64(self.structs.len() as u64)?;
|
||||
|
||||
let mut clients = self.structs.keys().copied().collect::<Vec<_>>();
|
||||
|
||||
// Descending
|
||||
clients.sort_by(|a, b| b.cmp(a));
|
||||
|
||||
for client in clients {
|
||||
let structs = self.structs.get(&client).unwrap();
|
||||
|
||||
encoder.write_var_u64(structs.len() as u64)?;
|
||||
encoder.write_var_u64(client)?;
|
||||
encoder.write_var_u64(structs.front().map(|s| s.clock()).unwrap_or(0))?;
|
||||
|
||||
for struct_info in structs {
|
||||
struct_info.write(encoder)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.delete_set.write(encoder)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Update {
|
||||
// decode from ydoc v1
|
||||
pub fn decode_v1<T: AsRef<[u8]>>(buffer: T) -> JwstCodecResult<Update> {
|
||||
Update::read(&mut RawDecoder::new(buffer.as_ref()))
|
||||
}
|
||||
|
||||
pub fn encode_v1(&self) -> JwstCodecResult<Vec<u8>> {
|
||||
let mut encoder = RawEncoder::default();
|
||||
self.write(&mut encoder)?;
|
||||
Ok(encoder.into_inner())
|
||||
}
|
||||
|
||||
pub(crate) fn iter(&mut self, state: StateVector) -> UpdateIterator<'_> {
|
||||
UpdateIterator::new(self, state)
|
||||
}
|
||||
|
||||
pub fn delete_set_iter(&mut self, state: StateVector) -> DeleteSetIterator<'_> {
|
||||
DeleteSetIterator::new(self, state)
|
||||
}
|
||||
|
||||
// take all pending structs and delete set to [self] update struct
|
||||
pub fn drain_pending_state(&mut self) {
|
||||
debug_assert!(self.is_empty());
|
||||
|
||||
std::mem::swap(&mut self.pending_structs, &mut self.structs);
|
||||
std::mem::swap(&mut self.pending_delete_set, &mut self.delete_set);
|
||||
}
|
||||
|
||||
pub fn merge<I: IntoIterator<Item = Update>>(updates: I) -> Update {
|
||||
let mut merged = Update::default();
|
||||
|
||||
Self::merge_into(&mut merged, updates);
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn merge_into<I: IntoIterator<Item = Update>>(target: &mut Update, updates: I) {
|
||||
for update in updates {
|
||||
target.delete_set.merge(&update.delete_set);
|
||||
|
||||
for (client, structs) in update.structs {
|
||||
let iter = structs.into_iter().filter(|p| !p.is_skip());
|
||||
if let Some(merged_structs) = target.structs.get_mut(&client) {
|
||||
merged_structs.extend(iter);
|
||||
} else {
|
||||
target.structs.insert(client, iter.collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for structs in target.structs.values_mut() {
|
||||
structs.make_contiguous().sort_by_key(|s| s.id().clock);
|
||||
|
||||
// insert [Node::Skip] if structs[index].id().clock + structs[index].len() <
|
||||
// structs[index + 1].id().clock
|
||||
let mut index = 0;
|
||||
let mut merged_index = vec![];
|
||||
while index < structs.len() - 1 {
|
||||
let cur = &structs[index];
|
||||
let next = &structs[index + 1];
|
||||
|
||||
let clock_end = cur.id().clock + cur.len();
|
||||
let next_clock = next.id().clock;
|
||||
|
||||
if next_clock > clock_end {
|
||||
structs.insert(
|
||||
index + 1,
|
||||
Node::new_skip((cur.id().client, clock_end).into(), next_clock - clock_end),
|
||||
);
|
||||
index += 1;
|
||||
} else if cur.id().clock == next_clock {
|
||||
if cur.deleted() == next.deleted()
|
||||
&& cur.last_id() == next.last_id()
|
||||
&& cur.left() == next.left()
|
||||
&& cur.right() == next.right()
|
||||
{
|
||||
// merge two nodes, mark the index
|
||||
merged_index.push(index + 1);
|
||||
} else {
|
||||
debug!("merge failed: {cur:?} {next:?}")
|
||||
}
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
{
|
||||
// prune the merged nodes
|
||||
let mut new_structs = VecDeque::with_capacity(structs.len() - merged_index.len());
|
||||
let mut next_remove_idx = 0;
|
||||
for (idx, val) in structs.drain(..).enumerate() {
|
||||
if next_remove_idx < merged_index.len() && idx == merged_index[next_remove_idx] {
|
||||
next_remove_idx += 1;
|
||||
} else {
|
||||
new_structs.push_back(val);
|
||||
}
|
||||
}
|
||||
structs.extend(new_structs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_content_empty(&self) -> bool {
|
||||
self.structs.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.structs.is_empty() && self.delete_set.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_pending_empty(&self) -> bool {
|
||||
self.pending_structs.is_empty() && self.pending_delete_set.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UpdateIterator<'a> {
|
||||
update: &'a mut Update,
|
||||
|
||||
// --- local iterator state ---
|
||||
/// current state vector from store
|
||||
state: StateVector,
|
||||
/// all client ids sorted ascending
|
||||
client_ids: Vec<Client>,
|
||||
/// current id of client of the updates we're processing
|
||||
cur_client_id: Option<Client>,
|
||||
/// stack of previous iterating item with higher priority than updates in
|
||||
/// next iteration
|
||||
stack: Vec<Node>,
|
||||
}
|
||||
|
||||
impl<'a> UpdateIterator<'a> {
|
||||
pub fn new(update: &'a mut Update, state: StateVector) -> Self {
|
||||
let mut client_ids = update.structs.keys().cloned().collect::<Vec<_>>();
|
||||
client_ids.sort();
|
||||
let cur_client_id = client_ids.pop();
|
||||
|
||||
UpdateIterator {
|
||||
update,
|
||||
state,
|
||||
client_ids,
|
||||
cur_client_id,
|
||||
stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// iterate the client ids until we find the next client with left updates
|
||||
/// that can be consumed
|
||||
///
|
||||
/// note:
|
||||
/// firstly we will check current client id as well to ensure current
|
||||
/// updates queue is not empty yet
|
||||
fn next_client(&mut self) -> Option<Client> {
|
||||
while let Some(client_id) = self.cur_client_id {
|
||||
match self.update.structs.get(&client_id) {
|
||||
Some(refs) if !refs.is_empty() => {
|
||||
self.cur_client_id.replace(client_id);
|
||||
return self.cur_client_id;
|
||||
}
|
||||
_ => {
|
||||
self.update.structs.remove(&client_id);
|
||||
self.cur_client_id = self.client_ids.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// update the missing state vector
|
||||
/// tell it the smallest clock that missed.
|
||||
fn update_missing_state(&mut self, client: Client, clock: Clock) {
|
||||
self.update.missing_state.set_min(client, clock);
|
||||
}
|
||||
|
||||
/// any time we can't apply an update during the iteration,
|
||||
/// we should put all items in pending stack to rest structs
|
||||
fn add_stack_to_rest(&mut self) {
|
||||
for s in self.stack.drain(..) {
|
||||
let client = s.id().client;
|
||||
let unapplicable_items = self.update.structs.remove(&client);
|
||||
if let Some(mut items) = unapplicable_items {
|
||||
items.push_front(s);
|
||||
self.update.pending_structs.insert(client, items);
|
||||
} else {
|
||||
self.update.pending_structs.insert(client, [s].into());
|
||||
}
|
||||
self.client_ids.retain(|&c| c != client);
|
||||
}
|
||||
}
|
||||
|
||||
/// tell if current update's dependencies(left, right, parent) has already
|
||||
/// been consumed and recorded and return the client of them if not.
|
||||
fn get_missing_dep(&self, struct_info: &Node) -> Option<Client> {
|
||||
if let Some(item) = struct_info.as_item().get() {
|
||||
let id = item.id;
|
||||
if let Some(left) = &item.origin_left_id
|
||||
&& left.client != id.client
|
||||
&& left.clock >= self.state.get(&left.client)
|
||||
{
|
||||
return Some(left.client);
|
||||
}
|
||||
|
||||
if let Some(right) = &item.origin_right_id
|
||||
&& right.client != id.client
|
||||
&& right.clock >= self.state.get(&right.client)
|
||||
{
|
||||
return Some(right.client);
|
||||
}
|
||||
|
||||
if let Some(parent) = &item.parent {
|
||||
match parent {
|
||||
Parent::Id(parent_id)
|
||||
if parent_id.client != id.client && parent_id.clock >= self.state.get(&parent_id.client) =>
|
||||
{
|
||||
return Some(parent_id.client);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn next_candidate(&mut self) -> Option<Node> {
|
||||
let mut cur = None;
|
||||
|
||||
if !self.stack.is_empty() {
|
||||
cur.replace(self.stack.pop().unwrap());
|
||||
} else if let Some(client) = self.next_client() {
|
||||
// Safety:
|
||||
// client index of updates and update length are both checked in next_client
|
||||
// safe to use unwrap
|
||||
cur.replace(self.update.structs.get_mut(&client).unwrap().pop_front().unwrap());
|
||||
}
|
||||
|
||||
cur
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for UpdateIterator<'_> {
|
||||
type Item = (Node, u64);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// fetch the first candidate from stack or updates
|
||||
let mut cur = self.next_candidate();
|
||||
|
||||
while let Some(cur_update) = cur.take() {
|
||||
let id = cur_update.id();
|
||||
if cur_update.is_skip() {
|
||||
cur = self.next_candidate();
|
||||
continue;
|
||||
} else if !self.state.contains(&id) {
|
||||
// missing local state of same client
|
||||
// can't apply the continuous updates from same client
|
||||
// push into the stack and put tell all the items in stack are unapplicable
|
||||
self.stack.push(cur_update);
|
||||
self.update_missing_state(id.client, id.clock - 1);
|
||||
self.add_stack_to_rest();
|
||||
} else {
|
||||
let id = cur_update.id();
|
||||
let dep = self.get_missing_dep(&cur_update);
|
||||
// some dependency is missing, we need to turn to iterate the dependency first.
|
||||
if let Some(dep) = dep {
|
||||
self.stack.push(cur_update);
|
||||
|
||||
match self.update.structs.get_mut(&dep) {
|
||||
Some(updates) if !updates.is_empty() => {
|
||||
// iterate the dependency client first
|
||||
cur.replace(updates.pop_front().unwrap());
|
||||
continue;
|
||||
}
|
||||
// but the dependency update is drained
|
||||
// need to move all stack item to unapplicable store
|
||||
_ => {
|
||||
self.update_missing_state(dep, self.state.get(&dep));
|
||||
self.add_stack_to_rest();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// we finally find the first applicable update
|
||||
let local_state = self.state.get(&id.client);
|
||||
// we've already check the local state is greater or equal to current update's
|
||||
// clock so offset here will never be negative
|
||||
let offset = local_state - id.clock;
|
||||
if offset == 0 || offset < cur_update.len() {
|
||||
self.state.set_max(id.client, id.clock + cur_update.len());
|
||||
return Some((cur_update, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cur = self.next_candidate();
|
||||
}
|
||||
|
||||
// we all done
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeleteSetIterator<'a> {
|
||||
update: &'a mut Update,
|
||||
/// current state vector from store
|
||||
state: StateVector,
|
||||
}
|
||||
|
||||
impl<'a> DeleteSetIterator<'a> {
|
||||
pub fn new(update: &'a mut Update, state: StateVector) -> Self {
|
||||
DeleteSetIterator { update, state }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for DeleteSetIterator<'_> {
|
||||
type Item = (Client, Range<u64>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while let Some(client) = self.update.delete_set.keys().next().cloned() {
|
||||
let deletes = self.update.delete_set.get_mut(&client).unwrap();
|
||||
let local_state = self.state.get(&client);
|
||||
|
||||
while let Some(range) = deletes.pop() {
|
||||
let start = range.start;
|
||||
let end = range.end;
|
||||
|
||||
if start < local_state {
|
||||
if local_state < end {
|
||||
// partially state missing
|
||||
// [start..end)
|
||||
// ^ local_state in between
|
||||
// // split
|
||||
// [start..local_state) [local_state..end)
|
||||
// ^^^^^ unapplicable
|
||||
self
|
||||
.update
|
||||
.pending_delete_set
|
||||
.add(client, local_state, end - local_state);
|
||||
|
||||
return Some((client, start..local_state));
|
||||
}
|
||||
|
||||
return Some((client, range));
|
||||
} else {
|
||||
// all state missing
|
||||
self.update.pending_delete_set.add(client, start, end - start);
|
||||
}
|
||||
}
|
||||
|
||||
self.update.delete_set.remove(&client);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{num::ParseIntError, path::PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::*;
|
||||
use crate::doc::common::OrderRange;
|
||||
|
||||
fn struct_item(id: (Client, Clock), len: usize) -> Node {
|
||||
Node::Item(Somr::new(
|
||||
ItemBuilder::new()
|
||||
.id(id.into())
|
||||
.content(Content::String("c".repeat(len)))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_doc_update(input: Vec<u8>) -> JwstCodecResult<Update> {
|
||||
Update::decode_v1(input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(any(miri, loom), ignore)]
|
||||
fn test_parse_doc() {
|
||||
let docs = [
|
||||
(include_bytes!("../../fixtures/basic.bin").to_vec(), 1, 188),
|
||||
(include_bytes!("../../fixtures/database.bin").to_vec(), 1, 149),
|
||||
(include_bytes!("../../fixtures/large.bin").to_vec(), 1, 9036),
|
||||
(include_bytes!("../../fixtures/with-subdoc.bin").to_vec(), 2, 30),
|
||||
(
|
||||
include_bytes!("../../fixtures/edge-case-left-right-same-node.bin").to_vec(),
|
||||
2,
|
||||
243,
|
||||
),
|
||||
];
|
||||
|
||||
for (doc, clients, structs) in docs {
|
||||
let update = parse_doc_update(doc).unwrap();
|
||||
|
||||
assert_eq!(update.structs.len(), clients);
|
||||
assert_eq!(update.structs.iter().map(|s| s.1.len()).sum::<usize>(), structs);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_hex(s: &str) -> Result<Vec<u8>, ParseIntError> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct Data {
|
||||
id: u64,
|
||||
workspace: String,
|
||||
timestamp: String,
|
||||
blob: String,
|
||||
}
|
||||
|
||||
#[ignore = "just for local data test"]
|
||||
#[test]
|
||||
fn test_parse_local_doc() {
|
||||
let json = serde_json::from_slice::<Vec<Data>>(include_bytes!("../../fixtures/local_docs.json")).unwrap();
|
||||
|
||||
for ws in json {
|
||||
let data = &ws.blob[5..=(ws.blob.len() - 2)];
|
||||
if let Ok(data) = decode_hex(data) {
|
||||
match parse_doc_update(data.clone()) {
|
||||
Ok(update) => {
|
||||
println!(
|
||||
"workspace: {}, global structs: {}, total structs: {}",
|
||||
ws.workspace,
|
||||
update.structs.len(),
|
||||
update.structs.iter().map(|s| s.1.len()).sum::<usize>()
|
||||
);
|
||||
}
|
||||
Err(_e) => {
|
||||
std::fs::write(
|
||||
PathBuf::from("./src/fixtures/invalid").join(format!("{}.ydoc", ws.workspace)),
|
||||
data,
|
||||
)
|
||||
.unwrap();
|
||||
println!("doc error: {}", ws.workspace);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("error origin data: {}", ws.workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_iterator() {
|
||||
loom_model!({
|
||||
let mut update = Update {
|
||||
structs: ClientMap::from_iter([
|
||||
(
|
||||
0,
|
||||
VecDeque::from([
|
||||
struct_item((0, 0), 1),
|
||||
struct_item((0, 1), 1),
|
||||
Node::new_skip((0, 2).into(), 1),
|
||||
]),
|
||||
),
|
||||
(
|
||||
1,
|
||||
VecDeque::from([
|
||||
struct_item((1, 0), 1),
|
||||
Node::Item(Somr::new(
|
||||
ItemBuilder::new()
|
||||
.id((1, 1).into())
|
||||
.left_id(Some((0, 1).into()))
|
||||
.content(Content::String("c".repeat(2)))
|
||||
.build(),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
..Update::default()
|
||||
};
|
||||
|
||||
let mut iter = update.iter(StateVector::default());
|
||||
assert_eq!(iter.next().unwrap().0.id(), (1, 0).into());
|
||||
assert_eq!(iter.next().unwrap().0.id(), (0, 0).into());
|
||||
assert_eq!(iter.next().unwrap().0.id(), (0, 1).into());
|
||||
assert_eq!(iter.next().unwrap().0.id(), (1, 1).into());
|
||||
assert_eq!(iter.next(), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_iterator_with_missing_state() {
|
||||
loom_model!({
|
||||
let mut update = Update {
|
||||
// an item with higher sequence id than local state
|
||||
structs: ClientMap::from_iter([(0, VecDeque::from([struct_item((0, 4), 1)]))]),
|
||||
..Update::default()
|
||||
};
|
||||
|
||||
let mut iter = update.iter(StateVector::from([(0, 3)]));
|
||||
assert_eq!(iter.next(), None);
|
||||
assert!(!update.pending_structs.is_empty());
|
||||
assert_eq!(
|
||||
update.pending_structs.get_mut(&0).unwrap().pop_front().unwrap().id(),
|
||||
(0, 4).into()
|
||||
);
|
||||
assert!(!update.missing_state.is_empty());
|
||||
assert_eq!(update.missing_state.get(&0), 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_set_iterator() {
|
||||
let mut update = Update {
|
||||
delete_set: DeleteSet::from([(0, vec![(0..2), (3..5)])]),
|
||||
..Update::default()
|
||||
};
|
||||
|
||||
let mut iter = update.delete_set_iter(StateVector::from([(0, 10)]));
|
||||
assert_eq!(iter.next().unwrap(), (0, 0..2));
|
||||
assert_eq!(iter.next().unwrap(), (0, 3..5));
|
||||
assert_eq!(iter.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_set_with_missing_state() {
|
||||
let mut update = Update {
|
||||
delete_set: DeleteSet::from([(0, vec![(3..5), (7..12), (13..15)])]),
|
||||
..Update::default()
|
||||
};
|
||||
|
||||
let mut iter = update.delete_set_iter(StateVector::from([(0, 10)]));
|
||||
assert_eq!(iter.next().unwrap(), (0, 3..5));
|
||||
assert_eq!(iter.next().unwrap(), (0, 7..10));
|
||||
assert_eq!(iter.next(), None);
|
||||
|
||||
assert!(!update.pending_delete_set.is_empty());
|
||||
assert_eq!(
|
||||
update.pending_delete_set.get(&0).unwrap(),
|
||||
&OrderRange::from(vec![(10..12), (13..15)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_add_skip_when_clock_not_continuous() {
|
||||
loom_model!({
|
||||
let update = Update {
|
||||
structs: ClientMap::from_iter([(
|
||||
0,
|
||||
VecDeque::from([
|
||||
struct_item((0, 0), 1),
|
||||
struct_item((0, 1), 1),
|
||||
struct_item((0, 10), 1),
|
||||
Node::new_gc((0, 20).into(), 10),
|
||||
]),
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = Update::merge([update]);
|
||||
|
||||
assert_eq!(
|
||||
merged.structs.get(&0).unwrap(),
|
||||
&VecDeque::from([
|
||||
struct_item((0, 0), 1),
|
||||
struct_item((0, 1), 1),
|
||||
Node::new_skip((0, 2).into(), 8),
|
||||
struct_item((0, 10), 1),
|
||||
Node::new_skip((0, 11).into(), 9),
|
||||
Node::new_gc((0, 20).into(), 10),
|
||||
])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_update_should_not_be_released_in_next_turn() {
|
||||
loom_model!({
|
||||
let update = Update {
|
||||
structs: ClientMap::from_iter([(
|
||||
0,
|
||||
VecDeque::from([
|
||||
struct_item((0, 0), 1),
|
||||
struct_item((0, 1), 1),
|
||||
struct_item((0, 10), 1),
|
||||
Node::new_gc((0, 20).into(), 10),
|
||||
]),
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = Update::merge([update]);
|
||||
|
||||
let update2 = Update {
|
||||
structs: ClientMap::from_iter([(
|
||||
0,
|
||||
VecDeque::from([struct_item((0, 30), 1), Node::new_gc((0, 32).into(), 1)]),
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged2 = Update::merge([update2, merged]);
|
||||
|
||||
assert_eq!(merged2.structs.get(&0).unwrap().len(), 9);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user