mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
Compare commits
2 Commits
v2026.2.23
...
darksky/mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e26c9fe69 | ||
|
|
d385514fca |
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,6 +3,6 @@ contact_links:
|
||||
- name: Something else?
|
||||
url: https://github.com/toeverything/AFFiNE/discussions
|
||||
about: Feel free to ask and answer questions over in GitHub Discussions
|
||||
- name: AFFiNE Community Support (Discord)
|
||||
url: https://affine.pro/redirect/discord
|
||||
- name: AFFiNE Community Support
|
||||
url: https://community.affine.pro
|
||||
about: AFFiNE Community - a place to ask, learn and engage with others
|
||||
|
||||
20
.github/actionlint.yaml
vendored
20
.github/actionlint.yaml
vendored
@@ -1,20 +0,0 @@
|
||||
self-hosted-runner:
|
||||
# Labels of self-hosted runner in array of strings.
|
||||
labels:
|
||||
- win-signer
|
||||
|
||||
# Configuration variables in array of strings defined in your repository or
|
||||
# organization. `null` means disabling configuration variables check.
|
||||
# Empty array means no configuration variable is allowed.
|
||||
config-variables: null
|
||||
|
||||
# Configuration for file paths. The keys are glob patterns to match to file
|
||||
# paths relative to the repository root. The values are the configurations for
|
||||
# the file paths. Note that the path separator is always '/'.
|
||||
# The following configurations are available.
|
||||
#
|
||||
# "ignore" is an array of regular expression patterns. Matched error messages
|
||||
# are ignored. This is similar to the "-ignore" command line option.
|
||||
paths:
|
||||
# .github/workflows/**/*.yml:
|
||||
# ignore: []
|
||||
1
.github/actions/setup-version/action.yml
vendored
1
.github/actions/setup-version/action.yml
vendored
@@ -7,6 +7,7 @@ inputs:
|
||||
ios-app-version:
|
||||
description: 'iOS App Store Version (Optional, use App version if empty)'
|
||||
required: false
|
||||
type: string
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
|
||||
100
.github/workflows/build-test.yml
vendored
100
.github/workflows/build-test.yml
vendored
@@ -68,26 +68,9 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Go (for actionlint)
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11
|
||||
- name: Run actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"$(go env GOPATH)/bin/actionlint"
|
||||
- name: Run oxlint
|
||||
# oxlint is fast, so wrong code will fail quickly
|
||||
run: |
|
||||
set -euo pipefail
|
||||
oxlint_version="$(node -e "console.log(require('./package.json').devDependencies.oxlint)")"
|
||||
yarn dlx "oxlint@${oxlint_version}" --deny-warnings
|
||||
run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'].replace('oxlint', 'oxlint@' + require('./package.json').devDependencies.oxlint))")
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
@@ -125,45 +108,20 @@ jobs:
|
||||
run: |
|
||||
yarn affine bs-docs build
|
||||
git checkout packages/frontend/i18n/src/i18n-completenesses.json
|
||||
if git status --porcelain | grep -q .; then
|
||||
git status --porcelain | grep . && {
|
||||
echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted"
|
||||
exit 1
|
||||
else
|
||||
} || {
|
||||
echo "All changes are submitted"
|
||||
fi
|
||||
|
||||
rust-test-filter:
|
||||
name: Rust test filter
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run-rust: ${{ steps.rust-filter.outputs.rust }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: rust-filter
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- '**/*.rs'
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
- '.cargo/**'
|
||||
- 'rust-toolchain*'
|
||||
- '.github/actions/build-rust/**'
|
||||
}
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package: 'affine'
|
||||
no-build: 'true'
|
||||
- name: fmt check
|
||||
run: |
|
||||
@@ -201,12 +159,12 @@ jobs:
|
||||
yarn affine i18n build
|
||||
yarn affine server genconfig
|
||||
git checkout packages/frontend/i18n/src/i18n-completenesses.json
|
||||
if git status --porcelain | grep -q .; then
|
||||
git status --porcelain | grep . && {
|
||||
echo "Run 'yarn affine init && yarn affine gql build && yarn affine i18n build && yarn affine server genconfig' and make sure all changes are submitted"
|
||||
exit 1
|
||||
else
|
||||
} || {
|
||||
echo "All changes are submitted"
|
||||
fi
|
||||
}
|
||||
|
||||
check-yarn-binary:
|
||||
name: Check yarn binary
|
||||
@@ -215,9 +173,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
yarn_version="$(node -e "console.log(require('./package.json').packageManager.split('@')[1])")"
|
||||
yarn set version "$yarn_version"
|
||||
yarn set version $(node -e "console.log(require('./package.json').packageManager.split('@')[1])")
|
||||
git diff --exit-code
|
||||
|
||||
e2e-blocksuite-test:
|
||||
@@ -232,7 +188,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: false
|
||||
@@ -260,7 +215,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium,firefox,webkit'
|
||||
electron-install: false
|
||||
@@ -344,7 +298,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-local @affine/web @affine/server
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: false
|
||||
@@ -376,7 +329,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-mobile @affine/mobile
|
||||
playwright-install: true
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
@@ -448,7 +400,7 @@ jobs:
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
run: |
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")"
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -487,7 +439,7 @@ jobs:
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
run: |
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -536,7 +488,7 @@ jobs:
|
||||
working-directory: ${{ env.DEV_DRIVE_WORKSPACE }}
|
||||
shell: bash
|
||||
run: |
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -584,7 +536,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/electron-renderer @affine/nbstore @toeverything/infra
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
- name: Build Electron renderer
|
||||
@@ -666,7 +617,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -747,7 +697,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -764,6 +713,8 @@ jobs:
|
||||
run: yarn affine @affine/server test:coverage "**/*/*elasticsearch.spec.ts" --forbid-only
|
||||
env:
|
||||
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
|
||||
CI_NODE_INDEX: ${{ matrix.node_index }}
|
||||
CI_NODE_TOTAL: ${{ matrix.total_nodes }}
|
||||
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v5
|
||||
@@ -810,7 +761,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -837,10 +787,7 @@ jobs:
|
||||
|
||||
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
|
||||
@@ -865,10 +812,7 @@ jobs:
|
||||
|
||||
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
|
||||
@@ -891,10 +835,7 @@ jobs:
|
||||
|
||||
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:
|
||||
@@ -930,10 +871,7 @@ jobs:
|
||||
|
||||
rust-test:
|
||||
name: Run native tests
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
@@ -941,7 +879,6 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package: 'affine'
|
||||
no-build: 'true'
|
||||
|
||||
@@ -1034,7 +971,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -1107,7 +1043,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud-copilot @affine/web @affine/server
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: false
|
||||
@@ -1190,10 +1125,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud @affine-test/affine-desktop-cloud @affine/web @affine/server @affine/electron @affine/electron-renderer @affine/nbstore @toeverything/infra
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: ${{ matrix.tests.shard == 'desktop' && 'true' || 'false' }}
|
||||
hard-link-nm: false
|
||||
|
||||
- name: Download server-native.node
|
||||
@@ -1272,8 +1204,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
|
||||
playwright-install: ${{ matrix.spec.test && 'true' || 'false' }}
|
||||
playwright-platform: 'chromium'
|
||||
playwright-install: true
|
||||
hard-link-nm: false
|
||||
enableScripts: false
|
||||
|
||||
@@ -1281,7 +1212,7 @@ jobs:
|
||||
id: filename
|
||||
shell: bash
|
||||
run: |
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download ${{ steps.filename.outputs.filename }}
|
||||
@@ -1388,7 +1319,6 @@ jobs:
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- rust-test
|
||||
- rust-test-filter
|
||||
- copilot-test-filter
|
||||
- copilot-api-test
|
||||
- copilot-e2e-test
|
||||
|
||||
10
.github/workflows/release-mobile.yml
vendored
10
.github/workflows/release-mobile.yml
vendored
@@ -128,9 +128,9 @@ jobs:
|
||||
- name: Testflight
|
||||
working-directory: packages/frontend/apps/ios/App
|
||||
run: |
|
||||
printf '%s' "$BUILD_PROVISION_PROFILE" | base64 --decode -o "$PP_PATH"
|
||||
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
cp "$PP_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
fastlane beta
|
||||
env:
|
||||
BUILD_TARGET: distribution
|
||||
@@ -160,9 +160,7 @@ jobs:
|
||||
- name: Load Google Service file
|
||||
env:
|
||||
DATA: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICE_JSON }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$DATA" | base64 -di > packages/frontend/apps/android/App/app/google-services.json
|
||||
run: echo $DATA | base64 -di > packages/frontend/apps/android/App/app/google-services.json
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
timeout-minutes: 10
|
||||
|
||||
2030
Cargo.lock
generated
2030
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
README.md
22
README.md
@@ -90,10 +90,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
|
||||
|
||||
## Contributing
|
||||
|
||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE's Discord](https://affine.pro/redirect/discord) |
|
||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
|
||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||
|
||||
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
||||
|
||||
@@ -101,9 +101,11 @@ Calling all developers, testers, tech writers and more! Contributions of all typ
|
||||
|
||||
For **bug reports**, **feature requests** and other **suggestions** you can also [create a new issue](https://github.com/toeverything/AFFiNE/issues/new/choose) and choose the most appropriate template for your feedback.
|
||||
|
||||
For **translation** and **language support** you can visit our [Discord](https://affine.pro/redirect/discord).
|
||||
For **translation** and **language support** you can visit our [i18n General Space](https://community.affine.pro/c/i18n-general).
|
||||
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [Discord](https://affine.pro/redirect/discord) where you can engage with other like-minded individuals.
|
||||
Looking for **other ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide range of support and resources.
|
||||
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
|
||||
|
||||
## Templates
|
||||
|
||||
@@ -180,16 +182,20 @@ Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiN
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Daffine)
|
||||
|
||||
## Hiring
|
||||
|
||||
Some amazing companies, including AFFiNE, are looking for developers! Are you interested in joining AFFiNE or its partners? Check out our [Discord channel](https://affine.pro/redirect/discord) for some of the latest jobs available.
|
||||
|
||||
## Feature Request
|
||||
|
||||
For feature requests, please see [discussions](https://github.com/toeverything/AFFiNE/discussions/categories/ideas).
|
||||
For feature requests, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
|
||||
|
||||
## Building
|
||||
|
||||
### Codespaces
|
||||
|
||||
From the GitHub repo main page, click the green "Code" button and select "Create codespace on master". This will open a new Codespace with the (supposedly auto-forked
|
||||
AFFiNE repo cloned, built, and ready to go).
|
||||
AFFiNE repo cloned, built, and ready to go.
|
||||
|
||||
### Local
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
name: 'chromium',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: 'playwright',
|
||||
isolate: false,
|
||||
providerOptions: {},
|
||||
},
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
|
||||
@@ -216,13 +216,9 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
override renderBlock() {
|
||||
const icon = this.model.props.icon$.value;
|
||||
const backgroundColorName = this.model.props.backgroundColorName$.value;
|
||||
const normalizedBackgroundName =
|
||||
backgroundColorName === 'default' || backgroundColorName === ''
|
||||
? 'grey'
|
||||
: backgroundColorName;
|
||||
const backgroundColor = (
|
||||
cssVarV2.block.callout.background as Record<string, string>
|
||||
)[normalizedBackgroundName ?? 'grey'];
|
||||
)[backgroundColorName ?? ''];
|
||||
|
||||
const iconContent = getIcon(icon);
|
||||
|
||||
|
||||
@@ -68,14 +68,14 @@ const backgroundColorAction = {
|
||||
${repeat(colors, color => {
|
||||
const isDefault = color === 'default';
|
||||
const value = isDefault
|
||||
? cssVarV2.block.callout.background.grey
|
||||
? null
|
||||
: `var(--affine-text-highlight-${color})`;
|
||||
const displayName = `${color} Background`;
|
||||
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid="background-${color}"
|
||||
@click=${() => updateBackground(isDefault ? 'grey' : color)}
|
||||
@click=${() => updateBackground(color)}
|
||||
>
|
||||
<affine-text-duotone-icon
|
||||
style=${styleMap({
|
||||
|
||||
@@ -27,16 +27,6 @@ export const codeBlockStyles = css`
|
||||
|
||||
${scrollbarStyle('.affine-code-block-container rich-text')}
|
||||
|
||||
/* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
.affine-code-block-container rich-text {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')}
|
||||
transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-code-block-container .inline-editor {
|
||||
font-family: var(--affine-font-code-family);
|
||||
font-variant-ligatures: none;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GroupBy } from '../core/common/types.js';
|
||||
import type { DataSource } from '../core/data-source/base.js';
|
||||
import { DetailSelection } from '../core/detail/selection.js';
|
||||
import type { FilterGroup } from '../core/filter/types.js';
|
||||
import { groupByMatchers } from '../core/group-by/define.js';
|
||||
import { t } from '../core/logical/type-presets.js';
|
||||
import type { DataViewCellLifeCycle } from '../core/property/index.js';
|
||||
@@ -18,10 +17,7 @@ import {
|
||||
pickKanbanGroupColumn,
|
||||
resolveKanbanGroupBy,
|
||||
} from '../view-presets/kanban/group-by-utils.js';
|
||||
import {
|
||||
KanbanSingleView,
|
||||
materializeKanbanColumns,
|
||||
} from '../view-presets/kanban/kanban-view-manager.js';
|
||||
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
|
||||
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
|
||||
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
|
||||
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
|
||||
@@ -274,73 +270,6 @@ describe('kanban', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
const sharedFilter: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: 'status',
|
||||
},
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: 'Done' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sharedTitleProperty = {
|
||||
id: 'title',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Task 1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
it('evaluates filters with hidden columns', () => {
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Done',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: sharedFilter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [sharedTitleProperty] },
|
||||
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
|
||||
} as unknown as KanbanSingleView;
|
||||
|
||||
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when hidden filtered column does not match', () => {
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'In Progress',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: sharedFilter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [sharedTitleProperty] },
|
||||
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
|
||||
} as unknown as KanbanSingleView;
|
||||
|
||||
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag indicator', () => {
|
||||
it('shows drop preview when insert position exists', () => {
|
||||
const controller = createDragController();
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { FilterGroup } from '../core/filter/types.js';
|
||||
import { numberFormats } from '../property-presets/number/utils/formats.js';
|
||||
import {
|
||||
formatNumber,
|
||||
NumberFormatSchema,
|
||||
parseNumber,
|
||||
} from '../property-presets/number/utils/formatter.js';
|
||||
import { DEFAULT_COLUMN_WIDTH } from '../view-presets/table/consts.js';
|
||||
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
|
||||
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
|
||||
import { pcEffects } from '../view-presets/table/pc/effect.js';
|
||||
import type { TableGroup } from '../view-presets/table/pc/group.js';
|
||||
import {
|
||||
materializeTableColumns,
|
||||
TableSingleView,
|
||||
} from '../view-presets/table/table-view-manager.js';
|
||||
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
@@ -47,146 +41,6 @@ describe('TableGroup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('table column materialization', () => {
|
||||
test('appends missing properties while preserving existing order and state', () => {
|
||||
const columns = [
|
||||
{ id: 'status', width: 240, hide: true },
|
||||
{ id: 'title', width: 320 },
|
||||
];
|
||||
|
||||
const next = materializeTableColumns(columns, ['title', 'status', 'date']);
|
||||
|
||||
expect(next).toEqual([
|
||||
{ id: 'status', width: 240, hide: true },
|
||||
{ id: 'title', width: 320 },
|
||||
{ id: 'date', width: DEFAULT_COLUMN_WIDTH },
|
||||
]);
|
||||
});
|
||||
|
||||
test('drops stale columns that no longer exist in data source', () => {
|
||||
const columns = [
|
||||
{ id: 'title', width: 320 },
|
||||
{ id: 'removed', width: 200, hide: true },
|
||||
];
|
||||
|
||||
const next = materializeTableColumns(columns, ['title']);
|
||||
|
||||
expect(next).toEqual([{ id: 'title', width: 320 }]);
|
||||
});
|
||||
|
||||
test('returns original reference when columns are already materialized', () => {
|
||||
const columns = [
|
||||
{ id: 'title', width: 320 },
|
||||
{ id: 'status', width: 240, hide: true },
|
||||
];
|
||||
|
||||
const next = materializeTableColumns(columns, ['title', 'status']);
|
||||
|
||||
expect(next).toBe(columns);
|
||||
});
|
||||
|
||||
test('supports type-aware default width when materializing missing columns', () => {
|
||||
const next = materializeTableColumns([], ['title', 'status'], id =>
|
||||
id === 'title' ? 260 : DEFAULT_COLUMN_WIDTH
|
||||
);
|
||||
|
||||
expect(next).toEqual([
|
||||
{ id: 'title', width: 260 },
|
||||
{ id: 'status', width: DEFAULT_COLUMN_WIDTH },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table filtering', () => {
|
||||
test('evaluates filters with hidden columns', () => {
|
||||
const filter: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: 'status',
|
||||
},
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: 'Done' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const titleProperty = {
|
||||
id: 'title',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Task 1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Done',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: filter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [titleProperty] },
|
||||
propertiesRaw$: { value: [titleProperty, statusProperty] },
|
||||
} as unknown as TableSingleView;
|
||||
|
||||
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when hidden filtered column does not match', () => {
|
||||
const filter: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: 'status',
|
||||
},
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: 'Done' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const titleProperty = {
|
||||
id: 'title',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Task 1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'In Progress',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: filter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [titleProperty] },
|
||||
propertiesRaw$: { value: [titleProperty, statusProperty] },
|
||||
} as unknown as TableSingleView;
|
||||
|
||||
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number formatter', () => {
|
||||
test('number format menu should expose all schema formats', () => {
|
||||
const menuFormats = numberFormats.map(format => format.type);
|
||||
|
||||
@@ -349,7 +349,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value?.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
this.properties$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
|
||||
@@ -54,9 +54,7 @@ export class DatabaseCellContainer extends SignalWatcher(
|
||||
const selectionView = this.selectionView;
|
||||
if (selectionView) {
|
||||
const selection = selectionView.selection;
|
||||
const shouldEnterEditMode =
|
||||
editing && this.cell?.beforeEnterEditMode() !== false;
|
||||
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
|
||||
if (selection && this.isSelected(selection) && editing) {
|
||||
selectionView.selection = TableViewAreaSelection.create({
|
||||
groupKey: this.groupKey,
|
||||
focus: {
|
||||
|
||||
@@ -57,9 +57,7 @@ export class TableViewCellContainer extends SignalWatcher(
|
||||
const selectionView = this.selectionController;
|
||||
if (selectionView) {
|
||||
const selection = selectionView.selection;
|
||||
const shouldEnterEditMode =
|
||||
editing && this.cell?.beforeEnterEditMode() !== false;
|
||||
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
|
||||
if (selection && this.isSelected(selection) && editing) {
|
||||
selectionView.selection = TableViewAreaSelection.create({
|
||||
groupKey: this.groupKey,
|
||||
focus: {
|
||||
|
||||
@@ -26,52 +26,6 @@ import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
|
||||
import type { TableViewData } from './define.js';
|
||||
|
||||
export const materializeColumnsByPropertyIds = (
|
||||
columns: TableColumnData[],
|
||||
propertyIds: string[],
|
||||
getDefaultWidth: (id: string) => number = () => DEFAULT_COLUMN_WIDTH
|
||||
) => {
|
||||
const needShow = new Set(propertyIds);
|
||||
const orderedColumns: TableColumnData[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
if (needShow.has(column.id)) {
|
||||
orderedColumns.push(column);
|
||||
needShow.delete(column.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of needShow) {
|
||||
orderedColumns.push({ id, width: getDefaultWidth(id), hide: undefined });
|
||||
}
|
||||
|
||||
return orderedColumns;
|
||||
};
|
||||
|
||||
export const materializeTableColumns = (
|
||||
columns: TableColumnData[],
|
||||
propertyIds: string[],
|
||||
getDefaultWidth?: (id: string) => number
|
||||
) => {
|
||||
const nextColumns = materializeColumnsByPropertyIds(
|
||||
columns,
|
||||
propertyIds,
|
||||
getDefaultWidth
|
||||
);
|
||||
const unchanged =
|
||||
columns.length === nextColumns.length &&
|
||||
columns.every((column, index) => {
|
||||
const nextColumn = nextColumns[index];
|
||||
return (
|
||||
nextColumn != null &&
|
||||
column.id === nextColumn.id &&
|
||||
column.hide === nextColumn.hide
|
||||
);
|
||||
});
|
||||
|
||||
return unchanged ? columns : nextColumns;
|
||||
};
|
||||
|
||||
export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
propertiesRaw$ = computed(() => {
|
||||
const needShow = new Set(this.dataSource.properties$.value);
|
||||
@@ -266,10 +220,14 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
return this.data$.value?.mode ?? 'table';
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
}
|
||||
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value?.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
this.properties$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
@@ -332,33 +290,6 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private materializeColumns() {
|
||||
const data = this.data$.value;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextColumns = materializeTableColumns(
|
||||
data.columns,
|
||||
this.dataSource.properties$.value,
|
||||
id => this.propertyGetOrCreate(id).width$.value
|
||||
);
|
||||
if (nextColumns === data.columns) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataUpdate(() => ({ columns: nextColumns }));
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
// Materialize view columns on view activation so newly added properties
|
||||
// can participate in hide/order operations in table.
|
||||
queueMicrotask(() => {
|
||||
this.materializeColumns();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type TableColumnData = TableViewData['columns'][number];
|
||||
|
||||
@@ -8,10 +8,9 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
name: 'chromium',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: 'playwright',
|
||||
isolate: false,
|
||||
providerOptions: {},
|
||||
},
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
import { unsafeCSSVarV2 } from '../theme/css-variables';
|
||||
|
||||
/**
|
||||
* You should add a container before the scrollbar style to prevent the style pollution of the whole doc.
|
||||
*/
|
||||
@@ -30,7 +28,7 @@ export const scrollbarStyle = (container: string) => {
|
||||
}
|
||||
${unsafeCSS(container)}::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
background-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')};
|
||||
background-color: #b1b1b1;
|
||||
}
|
||||
${unsafeCSS(container)}::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
|
||||
@@ -220,7 +220,9 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
||||
</header>
|
||||
<div>
|
||||
AFFiNE will gradually support more file formats for import.
|
||||
<a href="https://affine.pro/redirect/discord" target="_blank"
|
||||
<a
|
||||
href="https://community.affine.pro/c/feature-requests/import-export"
|
||||
target="_blank"
|
||||
>Provide feedback.</a
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { bindKeymap } from '../event/keymap.js';
|
||||
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
keyCode: number;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: options.key,
|
||||
altKey: options.altKey ?? false,
|
||||
ctrlKey: options.ctrlKey ?? false,
|
||||
metaKey: options.metaKey ?? false,
|
||||
shiftKey: options.shiftKey ?? false,
|
||||
});
|
||||
|
||||
Object.defineProperty(event, 'keyCode', {
|
||||
configurable: true,
|
||||
get: () => options.keyCode,
|
||||
});
|
||||
Object.defineProperty(event, 'which', {
|
||||
configurable: true,
|
||||
get: () => options.keyCode,
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
const createCtx = (event: KeyboardEvent) => {
|
||||
return {
|
||||
get(name: string) {
|
||||
if (name === 'keyboardState') {
|
||||
return { raw: event };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
describe('bindKeymap', () => {
|
||||
test('falls back to physical key for ctrl shortcuts on non-US layouts', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Ctrl-f': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'а',
|
||||
keyCode: 70,
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(true);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
test('does not fallback for Alt+locale-character letter input', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Alt-s': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'ś',
|
||||
keyCode: 83,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps Alt+digit fallback for non-ASCII key outputs', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Alt-0': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'º',
|
||||
keyCode: 48,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(true);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
test('does not fallback on non-ASCII input without modifiers', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'[': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'х',
|
||||
keyCode: 219,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -90,21 +90,9 @@ export function bindKeymap(
|
||||
// Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard),
|
||||
// because the user intends to type that character, not trigger a shortcut bound to the physical key.
|
||||
// See: https://github.com/toeverything/AFFiNE/issues/14059
|
||||
const hasModifier =
|
||||
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
|
||||
const hasModifier = event.shiftKey || event.altKey || event.metaKey;
|
||||
const baseName = base[event.keyCode];
|
||||
const isSingleAscii = name.length === 1 && name.charCodeAt(0) <= 0x7e;
|
||||
const isAltInputChar = event.altKey && !event.ctrlKey && !isSingleAscii;
|
||||
// Keep supporting existing Alt+digit shortcuts (e.g. Alt-0/1/2 in edgeless)
|
||||
// while preventing Alt-based locale input characters from triggering letter shortcuts.
|
||||
const isDigitBaseKey =
|
||||
baseName != null && baseName.length === 1 && /[0-9]/.test(baseName);
|
||||
if (
|
||||
hasModifier &&
|
||||
baseName &&
|
||||
baseName !== name &&
|
||||
!(isAltInputChar && !isDigitBaseKey)
|
||||
) {
|
||||
if (hasModifier && baseName && baseName !== name) {
|
||||
const fromCode = map[modifiers(baseName, event)];
|
||||
if (fromCode && fromCode(ctx)) {
|
||||
return true;
|
||||
|
||||
@@ -8,10 +8,9 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
name: 'chromium',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: 'playwright',
|
||||
isolate: false,
|
||||
providerOptions: {},
|
||||
},
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
|
||||
@@ -19,11 +19,7 @@ export default defineConfig(_configEnv =>
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: process.env.CI === 'true',
|
||||
instances: [
|
||||
{ browser: 'chromium' },
|
||||
{ browser: 'firefox' },
|
||||
{ browser: 'webkit' },
|
||||
],
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: 'playwright',
|
||||
isolate: false,
|
||||
viewport: {
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Please visit https://docs.affine.pro/contributing
|
||||
# Please visit https://docs.affine.pro/docs/contributing
|
||||
|
||||
10
docs/contributor-add.md
Normal file
10
docs/contributor-add.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Contributor Add
|
||||
|
||||
- https://allcontributors.org/docs/en/cli/usage
|
||||
- https://allcontributors.org/docs/en/emoji-key
|
||||
|
||||
```shell
|
||||
all-contributors check
|
||||
all-contributors add tzhangchi code,doc
|
||||
all-contributors generate
|
||||
```
|
||||
@@ -26,4 +26,4 @@ You may be able to find additional help and information on our social media plat
|
||||
|
||||
### :earth_asia: Translations
|
||||
|
||||
AFFiNE is internationalized and available in multiple languages. The source content in this repository is written in English. We integrate with an external localization platform to work with the community in localizing the English content. You can find more info on our community page, in our [Discord](https://affine.pro/redirect/discord).
|
||||
AFFiNE is internationalized and available in multiple languages. The source content in this repository is written in English. We integrate with an external localization platform to work with the community in localizing the English content. You can find more info on our community page, in our [i18n General Space ](https://community.affine.pro/c/i18n-general).
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { URLHelper } from './helpers';
|
||||
|
||||
const DEV_LOOPBACK_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
const DEV_LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
const MOBILE_CLIENT_ORIGINS = new Set([
|
||||
'https://localhost',
|
||||
'capacitor://localhost',
|
||||
'ionic://localhost',
|
||||
]);
|
||||
const DESKTOP_CLIENT_ORIGINS = new Set(['assets://.', 'assets://another-host']);
|
||||
|
||||
export const CORS_ALLOWED_METHODS = [
|
||||
'GET',
|
||||
'HEAD',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'DELETE',
|
||||
'OPTIONS',
|
||||
];
|
||||
|
||||
export const CORS_ALLOWED_HEADERS = [
|
||||
'accept',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'x-affine-version',
|
||||
'x-operation-name',
|
||||
'x-request-id',
|
||||
'x-captcha-token',
|
||||
'x-captcha-challenge',
|
||||
'x-affine-csrf-token',
|
||||
'x-requested-with',
|
||||
'range',
|
||||
];
|
||||
|
||||
export const CORS_EXPOSED_HEADERS = [
|
||||
'content-length',
|
||||
'content-range',
|
||||
'x-request-id',
|
||||
];
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
||||
}
|
||||
|
||||
function isDevLoopbackOrigin(origin: string) {
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
return (
|
||||
DEV_LOOPBACK_PROTOCOLS.has(parsed.protocol) &&
|
||||
DEV_LOOPBACK_HOSTS.has(normalizeHostname(parsed.hostname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCorsAllowedOrigins(url: URLHelper) {
|
||||
return new Set<string>([
|
||||
...url.allowedOrigins,
|
||||
...MOBILE_CLIENT_ORIGINS,
|
||||
...DESKTOP_CLIENT_ORIGINS,
|
||||
]);
|
||||
}
|
||||
|
||||
export function isCorsOriginAllowed(
|
||||
origin: string | undefined | null,
|
||||
allowedOrigins: Set<string>
|
||||
) {
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowedOrigins.has(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((env.dev || env.testing) && isDevLoopbackOrigin(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function corsOriginCallback(
|
||||
origin: string | undefined,
|
||||
allowedOrigins: Set<string>,
|
||||
onBlocked: (origin: string) => void,
|
||||
callback: (error: Error | null, allow?: boolean) => void
|
||||
) {
|
||||
if (isCorsOriginAllowed(origin, allowedOrigins)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockedOrigin = origin ?? '<empty>';
|
||||
onBlocked(blockedOrigin);
|
||||
callback(null, false);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export {
|
||||
defineModuleConfig,
|
||||
type JSONSchema,
|
||||
} from './config';
|
||||
export * from './cors';
|
||||
export * from './error';
|
||||
export { EventBus, OnEvent } from './event';
|
||||
export {
|
||||
|
||||
@@ -4,15 +4,7 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { Config } from '../config';
|
||||
import {
|
||||
buildCorsAllowedOrigins,
|
||||
CORS_ALLOWED_HEADERS,
|
||||
CORS_ALLOWED_METHODS,
|
||||
corsOriginCallback,
|
||||
} from '../cors';
|
||||
import { AuthenticationRequired } from '../error';
|
||||
import { URLHelper } from '../helpers';
|
||||
import { AFFiNELogger } from '../logger';
|
||||
import { SocketIoRedis } from '../redis';
|
||||
import { WEBSOCKET_OPTIONS } from './options';
|
||||
|
||||
@@ -22,34 +14,17 @@ export class SocketIoAdapter extends IoAdapter {
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: any): Server {
|
||||
const logger = this.app.get(AFFiNELogger);
|
||||
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'] & {
|
||||
canActivate: (socket: Socket) => Promise<boolean>;
|
||||
};
|
||||
const url = this.app.get(URLHelper);
|
||||
const allowedOrigins = buildCorsAllowedOrigins(url);
|
||||
|
||||
const server: Server = super.createIOServer(port, {
|
||||
...config,
|
||||
...options,
|
||||
// Enable CORS for Socket.IO
|
||||
cors: {
|
||||
origin: (
|
||||
origin: string | undefined,
|
||||
callback: (error: Error | null, allow?: boolean) => void
|
||||
) => {
|
||||
corsOriginCallback(
|
||||
origin,
|
||||
allowedOrigins,
|
||||
blockedOrigin =>
|
||||
logger.warn(
|
||||
`Blocked WebSocket CORS request from origin: ${blockedOrigin}`
|
||||
),
|
||||
callback
|
||||
);
|
||||
},
|
||||
credentials: true,
|
||||
methods: CORS_ALLOWED_METHODS,
|
||||
allowedHeaders: CORS_ALLOWED_HEADERS,
|
||||
origin: true, // Allow all origins
|
||||
credentials: true, // Allow credentials (cookies, auth headers)
|
||||
methods: ['GET', 'POST'],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,14 +5,9 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
|
||||
import {
|
||||
AFFiNELogger,
|
||||
buildCorsAllowedOrigins,
|
||||
CacheInterceptor,
|
||||
CloudThrottlerGuard,
|
||||
Config,
|
||||
CORS_ALLOWED_HEADERS,
|
||||
CORS_ALLOWED_METHODS,
|
||||
CORS_EXPOSED_HEADERS,
|
||||
corsOriginCallback,
|
||||
GlobalExceptionFilter,
|
||||
URLHelper,
|
||||
} from './base';
|
||||
@@ -21,11 +16,12 @@ import { AuthGuard } from './core/auth';
|
||||
import { serverTimingAndCache } from './middleware/timing';
|
||||
|
||||
const OneMB = 1024 * 1024;
|
||||
|
||||
export async function run() {
|
||||
const { AppModule } = await import('./app.module');
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: false,
|
||||
cors: true,
|
||||
rawBody: true,
|
||||
bodyParser: true,
|
||||
bufferLogs: true,
|
||||
@@ -36,27 +32,6 @@ export async function run() {
|
||||
const logger = app.get(AFFiNELogger);
|
||||
app.useLogger(logger);
|
||||
const config = app.get(Config);
|
||||
const url = app.get(URLHelper);
|
||||
|
||||
const allowedOrigins = buildCorsAllowedOrigins(url);
|
||||
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
corsOriginCallback(
|
||||
origin,
|
||||
allowedOrigins,
|
||||
blockedOrigin =>
|
||||
logger.warn(`Blocked CORS request from origin: ${blockedOrigin}`),
|
||||
callback
|
||||
);
|
||||
},
|
||||
credentials: true,
|
||||
methods: CORS_ALLOWED_METHODS,
|
||||
allowedHeaders: CORS_ALLOWED_HEADERS,
|
||||
exposedHeaders: CORS_EXPOSED_HEADERS,
|
||||
maxAge: 86400,
|
||||
optionsSuccessStatus: 204,
|
||||
});
|
||||
|
||||
if (config.server.path) {
|
||||
app.setGlobalPrefix(config.server.path);
|
||||
@@ -99,6 +74,8 @@ export async function run() {
|
||||
});
|
||||
}
|
||||
|
||||
const url = app.get(URLHelper);
|
||||
|
||||
await app.listen(config.server.port, config.server.listenAddr);
|
||||
|
||||
const formattedAddr = config.server.listenAddr.includes(':')
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TestOps extends OpSchema {
|
||||
add: [{ a: number; b: number }, number];
|
||||
bin: [Uint8Array, Uint8Array];
|
||||
sub: [Uint8Array, number];
|
||||
init: [{ fastText?: boolean } | undefined, { ok: true }];
|
||||
}
|
||||
|
||||
declare module 'vitest' {
|
||||
@@ -84,6 +85,55 @@ describe('op client', () => {
|
||||
expect(data.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it('should send optional payload call with abort signal', async ctx => {
|
||||
const abortController = new AbortController();
|
||||
const result = ctx.producer.call(
|
||||
'init',
|
||||
{ fastText: true },
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"id": "init:1",
|
||||
"name": "init",
|
||||
"payload": {
|
||||
"fastText": true,
|
||||
},
|
||||
"type": "call",
|
||||
}
|
||||
`);
|
||||
|
||||
ctx.handlers.return({
|
||||
type: 'return',
|
||||
id: 'init:1',
|
||||
data: { ok: true },
|
||||
});
|
||||
|
||||
await expect(result).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should send undefined payload for optional input call', async ctx => {
|
||||
const result = ctx.producer.call('init', undefined);
|
||||
|
||||
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"id": "init:1",
|
||||
"name": "init",
|
||||
"payload": undefined,
|
||||
"type": "call",
|
||||
}
|
||||
`);
|
||||
|
||||
ctx.handlers.return({
|
||||
type: 'return',
|
||||
id: 'init:1',
|
||||
data: { ok: true },
|
||||
});
|
||||
|
||||
await expect(result).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should cancel call', async ctx => {
|
||||
const promise = ctx.producer.call('add', { a: 1, b: 2 });
|
||||
|
||||
|
||||
@@ -40,18 +40,14 @@ describe('op consumer', () => {
|
||||
it('should throw if no handler registered', async ctx => {
|
||||
ctx.handlers.call({ type: 'call', id: 'add:1', name: 'add', payload: {} });
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"error": {
|
||||
"message": "Handler for operation [add] is not registered.",
|
||||
"name": "Error",
|
||||
},
|
||||
"id": "add:1",
|
||||
"type": "return",
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(ctx.postMessage.mock.lastCall?.[0]).toMatchObject({
|
||||
type: 'return',
|
||||
id: 'add:1',
|
||||
error: {
|
||||
message: 'Handler for operation [add] is not registered.',
|
||||
name: 'Error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle call message', async ctx => {
|
||||
@@ -73,6 +69,38 @@ describe('op consumer', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('should serialize string errors with message', async ctx => {
|
||||
ctx.consumer.register('any', () => {
|
||||
throw 'worker panic';
|
||||
});
|
||||
|
||||
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(ctx.postMessage.mock.calls[0][0]).toMatchObject({
|
||||
type: 'return',
|
||||
id: 'any:1',
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: 'worker panic',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize plain object errors with fallback message', async ctx => {
|
||||
ctx.consumer.register('any', () => {
|
||||
throw { reason: 'panic', code: 'E_PANIC' };
|
||||
});
|
||||
|
||||
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
|
||||
const message = ctx.postMessage.mock.calls[0][0]?.error?.message;
|
||||
expect(typeof message).toBe('string');
|
||||
expect(message).toContain('"reason":"panic"');
|
||||
expect(message).toContain('"code":"E_PANIC"');
|
||||
});
|
||||
|
||||
it('should handle cancel message', async ctx => {
|
||||
ctx.consumer.register('add', ({ a, b }, { signal }) => {
|
||||
const { reject, resolve, promise } = Promise.withResolvers<number>();
|
||||
|
||||
@@ -16,6 +16,96 @@ import {
|
||||
} from './message';
|
||||
import type { OpInput, OpNames, OpOutput, OpSchema } from './types';
|
||||
|
||||
const SERIALIZABLE_ERROR_FIELDS = [
|
||||
'name',
|
||||
'message',
|
||||
'code',
|
||||
'type',
|
||||
'status',
|
||||
'data',
|
||||
'stacktrace',
|
||||
] as const;
|
||||
|
||||
type SerializableErrorShape = Partial<
|
||||
Record<(typeof SERIALIZABLE_ERROR_FIELDS)[number], unknown>
|
||||
> & {
|
||||
name?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function getFallbackErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === 'number' ||
|
||||
typeof error === 'boolean' ||
|
||||
typeof error === 'bigint' ||
|
||||
typeof error === 'symbol'
|
||||
) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
if (error === null || error === undefined) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonMessage = JSON.stringify(error);
|
||||
if (jsonMessage && jsonMessage !== '{}') {
|
||||
return jsonMessage;
|
||||
}
|
||||
} catch {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
function serializeError(error: unknown): Error {
|
||||
const valueToPick =
|
||||
error && typeof error === 'object'
|
||||
? error
|
||||
: ({} as Record<string, unknown>);
|
||||
const serialized = pick(
|
||||
valueToPick,
|
||||
SERIALIZABLE_ERROR_FIELDS
|
||||
) as SerializableErrorShape;
|
||||
|
||||
if (!serialized.message || typeof serialized.message !== 'string') {
|
||||
serialized.message = getFallbackErrorMessage(error);
|
||||
}
|
||||
|
||||
if (!serialized.name || typeof serialized.name !== 'string') {
|
||||
if (error instanceof Error && error.name) {
|
||||
serialized.name = error.name;
|
||||
} else if (error && typeof error === 'object') {
|
||||
const constructorName = error.constructor?.name;
|
||||
serialized.name =
|
||||
typeof constructorName === 'string' && constructorName.length > 0
|
||||
? constructorName
|
||||
: 'Error';
|
||||
} else {
|
||||
serialized.name = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!serialized.stacktrace &&
|
||||
error instanceof Error &&
|
||||
typeof error.stack === 'string'
|
||||
) {
|
||||
serialized.stacktrace = error.stack;
|
||||
}
|
||||
|
||||
return serialized as Error;
|
||||
}
|
||||
|
||||
interface OpCallContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -71,15 +161,7 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
this.port.postMessage({
|
||||
type: 'return',
|
||||
id: msg.id,
|
||||
error: pick(error, [
|
||||
'name',
|
||||
'message',
|
||||
'code',
|
||||
'type',
|
||||
'status',
|
||||
'data',
|
||||
'stacktrace',
|
||||
]),
|
||||
error: serializeError(error),
|
||||
} satisfies ReturnMessage);
|
||||
},
|
||||
complete: () => {
|
||||
@@ -109,15 +191,7 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
|
||||
this.port.postMessage({
|
||||
type: 'error',
|
||||
id: msg.id,
|
||||
error: pick(error, [
|
||||
'name',
|
||||
'message',
|
||||
'code',
|
||||
'type',
|
||||
'status',
|
||||
'data',
|
||||
'stacktrace',
|
||||
]),
|
||||
error: serializeError(error),
|
||||
} satisfies SubscriptionErrorMessage);
|
||||
},
|
||||
complete: () => {
|
||||
|
||||
@@ -12,7 +12,16 @@ export interface OpSchema {
|
||||
[key: string]: [any, any?];
|
||||
}
|
||||
|
||||
type RequiredInput<In> = In extends void ? [] : In extends never ? [] : [In];
|
||||
type IsAny<T> = 0 extends 1 & T ? true : false;
|
||||
|
||||
type RequiredInput<In> =
|
||||
IsAny<In> extends true
|
||||
? [In]
|
||||
: [In] extends [never]
|
||||
? []
|
||||
: [In] extends [void]
|
||||
? []
|
||||
: [In];
|
||||
|
||||
export type OpNames<T extends OpSchema> = ValuesOf<KeyToKey<T>>;
|
||||
export type OpInput<
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cp from 'node:child_process';
|
||||
import { readdir, rm, symlink } from 'node:fs/promises';
|
||||
import { rm, symlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -25,144 +25,6 @@ const fromBuildIdentifier = utils.fromBuildIdentifier;
|
||||
const linuxMimeTypes = [`x-scheme-handler/${productName.toLowerCase()}`];
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
const DEFAULT_ELECTRON_LOCALES_KEEP = new Set([
|
||||
'en',
|
||||
'en_US',
|
||||
'en_GB',
|
||||
'zh_CN',
|
||||
'zh_TW',
|
||||
'fr',
|
||||
'es',
|
||||
'es_419',
|
||||
'pl',
|
||||
'de',
|
||||
'ru',
|
||||
'ja',
|
||||
'it',
|
||||
'ca',
|
||||
'da',
|
||||
'hi',
|
||||
'sv',
|
||||
'ur',
|
||||
'ar',
|
||||
'uk',
|
||||
'ko',
|
||||
'pt_BR',
|
||||
'fa',
|
||||
'nb',
|
||||
]);
|
||||
|
||||
const getElectronLocalesKeep = () => {
|
||||
const raw = process.env.ELECTRON_LOCALES_KEEP?.trim();
|
||||
if (!raw) return DEFAULT_ELECTRON_LOCALES_KEEP;
|
||||
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized === 'all' || normalized === '*') return null;
|
||||
|
||||
const keep = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
// Always keep English as a safe fallback.
|
||||
keep.add('en');
|
||||
keep.add('en_US');
|
||||
keep.add('en_GB');
|
||||
return keep;
|
||||
};
|
||||
|
||||
const getElectronPakLocalesKeep = keep => {
|
||||
const pakKeep = new Set();
|
||||
for (const locale of keep) {
|
||||
if (locale === 'en') {
|
||||
pakKeep.add('en-US');
|
||||
continue;
|
||||
}
|
||||
pakKeep.add(locale.replaceAll('_', '-'));
|
||||
}
|
||||
|
||||
// Always keep English (US) as a safe fallback for Chromium/Electron locales.
|
||||
pakKeep.add('en');
|
||||
pakKeep.add('en-US');
|
||||
pakKeep.add('en-GB');
|
||||
return pakKeep;
|
||||
};
|
||||
|
||||
const trimElectronFrameworkLocales = async (
|
||||
resourcesAppDir,
|
||||
targetPlatform
|
||||
) => {
|
||||
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
|
||||
if (targetPlatform !== 'darwin' && targetPlatform !== 'mas') return;
|
||||
|
||||
const keep = getElectronLocalesKeep();
|
||||
if (!keep) return;
|
||||
|
||||
const contentsDir = path.resolve(resourcesAppDir, '..', '..');
|
||||
const frameworkResourcesDir = path.join(
|
||||
contentsDir,
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Versions',
|
||||
'A',
|
||||
'Resources'
|
||||
);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(frameworkResourcesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const localeDirs = entries
|
||||
.filter(entry => entry.isDirectory() && entry.name.endsWith('.lproj'))
|
||||
.map(entry => entry.name);
|
||||
|
||||
await Promise.all(
|
||||
localeDirs.map(async dirName => {
|
||||
const locale = dirName.slice(0, -'.lproj'.length);
|
||||
if (keep.has(locale)) return;
|
||||
await rm(path.join(frameworkResourcesDir, dirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const trimElectronPakLocales = async (resourcesAppDir, targetPlatform) => {
|
||||
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
|
||||
if (targetPlatform !== 'win32' && targetPlatform !== 'linux') return;
|
||||
|
||||
const keep = getElectronLocalesKeep();
|
||||
if (!keep) return;
|
||||
|
||||
const rootDir = path.resolve(resourcesAppDir, '..', '..');
|
||||
const localesDir = path.join(rootDir, 'locales');
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(localesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const pakKeep = getElectronPakLocalesKeep(keep);
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith('.pak'))
|
||||
.map(async entry => {
|
||||
const locale = entry.name.slice(0, -'.pak'.length);
|
||||
if (pakKeep.has(locale)) return;
|
||||
await rm(path.join(localesDir, entry.name), { force: true });
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const makers = [
|
||||
!process.env.SKIP_BUNDLE &&
|
||||
platform === 'darwin' && {
|
||||
@@ -342,27 +204,7 @@ export default {
|
||||
},
|
||||
],
|
||||
executableName: productName,
|
||||
ignore: [
|
||||
/\.map$/,
|
||||
/\/test($|\/)/,
|
||||
/\/scripts($|\/)/,
|
||||
/\/examples($|\/)/,
|
||||
/\/docs($|\/)/,
|
||||
/\/README\.md$/,
|
||||
/\/forge\.config\.mjs$/,
|
||||
/\/dev-app-update\.yml$/,
|
||||
/\/resources\/app-update\.yml$/,
|
||||
],
|
||||
afterCopy: [
|
||||
(buildPath, _electronVersion, targetPlatform, _arch, done) => {
|
||||
Promise.all([
|
||||
trimElectronFrameworkLocales(buildPath, targetPlatform),
|
||||
trimElectronPakLocales(buildPath, targetPlatform),
|
||||
])
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
],
|
||||
ignore: [/\.map$/],
|
||||
asar: true,
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription:
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<project_license>MIT AND LicenseRef-proprietary=https://github.com/toeverything/AFFiNE/blob/canary/LICENSE</project_license>
|
||||
<url type="homepage">https://affine.pro</url>
|
||||
<url type="bugtracker">https://github.com/toeverything/AFFiNE/issues</url>
|
||||
<url type="help">https://docs.affine.pro/</url>
|
||||
<url type="help">https://docs.affine.pro/docs</url>
|
||||
<url type="vcs-browser">https://github.com/toeverything/AFFiNE</url>
|
||||
<url type="contribute">https://docs.affine.pro/contributing</url>
|
||||
<url type="contribute">https://docs.affine.pro/docs/contributing</url>
|
||||
<developer id="pro.affine.app">
|
||||
<name>affine</name>
|
||||
</developer>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { dialogHandlers } from './dialog';
|
||||
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
|
||||
import { previewHandlers } from './preview';
|
||||
import { provideExposed } from './provide';
|
||||
import { workspaceEvents, workspaceHandlers } from './workspace';
|
||||
|
||||
@@ -8,6 +9,7 @@ export const handlers = {
|
||||
nbstore: nbstoreHandlers,
|
||||
workspace: workspaceHandlers,
|
||||
dialog: dialogHandlers,
|
||||
preview: previewHandlers,
|
||||
};
|
||||
|
||||
export const events = {
|
||||
|
||||
69
packages/frontend/apps/electron/src/helper/preview/index.ts
Normal file
69
packages/frontend/apps/electron/src/helper/preview/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type MermaidRenderRequest,
|
||||
type MermaidRenderResult,
|
||||
renderMermaidSvg,
|
||||
renderTypstSvg,
|
||||
type TypstRenderRequest,
|
||||
type TypstRenderResult,
|
||||
} from '@affine/native';
|
||||
|
||||
const TYPST_FONT_DIRS_ENV = 'AFFINE_TYPST_FONT_DIRS';
|
||||
|
||||
function parseTypstFontDirsFromEnv() {
|
||||
const value = process.env[TYPST_FONT_DIRS_ENV];
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(path.delimiter)
|
||||
.map(dir => dir.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getTypstFontDirCandidates() {
|
||||
const resourcesPath = process.resourcesPath ?? '';
|
||||
|
||||
return [
|
||||
...parseTypstFontDirsFromEnv(),
|
||||
path.join(resourcesPath, 'fonts'),
|
||||
path.join(resourcesPath, 'js', 'fonts'),
|
||||
path.join(resourcesPath, 'app.asar.unpacked', 'fonts'),
|
||||
path.join(resourcesPath, 'app.asar.unpacked', 'js', 'fonts'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveTypstFontDirs() {
|
||||
return Array.from(
|
||||
new Set(getTypstFontDirCandidates().map(dir => path.resolve(dir)))
|
||||
).filter(dir => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
|
||||
}
|
||||
|
||||
function withTypstFontDirs(
|
||||
request: TypstRenderRequest,
|
||||
fontDirs: string[]
|
||||
): TypstRenderRequest {
|
||||
const nextOptions = request.options ? { ...request.options } : {};
|
||||
if (!nextOptions.fontDirs?.length) {
|
||||
nextOptions.fontDirs = fontDirs;
|
||||
}
|
||||
return { ...request, options: nextOptions };
|
||||
}
|
||||
|
||||
const typstFontDirs = resolveTypstFontDirs();
|
||||
|
||||
export const previewHandlers = {
|
||||
renderMermaidSvg: async (
|
||||
request: MermaidRenderRequest
|
||||
): Promise<MermaidRenderResult> => {
|
||||
return renderMermaidSvg(request);
|
||||
},
|
||||
renderTypstSvg: async (
|
||||
request: TypstRenderRequest
|
||||
): Promise<TypstRenderResult> => {
|
||||
return renderTypstSvg(withTypstFontDirs(request, typstFontDirs));
|
||||
},
|
||||
};
|
||||
@@ -106,17 +106,9 @@ export async function listLocalWorkspaceIds(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deletedWorkspaceBasePath = await getDeletedWorkspacesBasePath();
|
||||
const deletedWorkspaceIds = new Set<string>(
|
||||
(await fs.readdir(deletedWorkspaceBasePath).catch(() => [])).filter(Boolean)
|
||||
);
|
||||
|
||||
const entries = await fs.readdir(localWorkspaceBasePath);
|
||||
const ids = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
if (deletedWorkspaceIds.has(entry)) {
|
||||
return null;
|
||||
}
|
||||
const workspacePath = path.join(localWorkspaceBasePath, entry);
|
||||
const stat = await fs.stat(workspacePath).catch(() => null);
|
||||
if (!stat?.isDirectory()) {
|
||||
|
||||
85
packages/frontend/apps/electron/test/helper/preview.spec.ts
Normal file
85
packages/frontend/apps/electron/test/helper/preview.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const { native } = vi.hoisted(() => ({
|
||||
native: {
|
||||
renderMermaidSvg: vi.fn(),
|
||||
renderTypstSvg: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/native', () => native);
|
||||
|
||||
const tmpDir = path.join(__dirname, 'tmp');
|
||||
const typstFontDirA = path.join(tmpDir, 'fonts-a');
|
||||
const typstFontDirB = path.join(tmpDir, 'fonts-b');
|
||||
|
||||
async function loadPreviewHandlers() {
|
||||
vi.resetModules();
|
||||
const module = await import('../../src/helper/preview');
|
||||
return module.previewHandlers;
|
||||
}
|
||||
|
||||
describe('helper preview handlers', () => {
|
||||
beforeEach(async () => {
|
||||
await fs.ensureDir(typstFontDirA);
|
||||
await fs.ensureDir(typstFontDirB);
|
||||
process.env.AFFINE_TYPST_FONT_DIRS = [
|
||||
typstFontDirA,
|
||||
typstFontDirB,
|
||||
path.join(tmpDir, 'missing'),
|
||||
].join(path.delimiter);
|
||||
native.renderMermaidSvg.mockReset();
|
||||
native.renderTypstSvg.mockReset();
|
||||
native.renderMermaidSvg.mockReturnValue({
|
||||
svg: '<svg><text>mermaid</text></svg>',
|
||||
});
|
||||
native.renderTypstSvg.mockReturnValue({
|
||||
svg: '<svg><text>typst</text></svg>',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.AFFINE_TYPST_FONT_DIRS;
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
test('passes mermaid request to native renderer', async () => {
|
||||
const previewHandlers = await loadPreviewHandlers();
|
||||
const request = { code: 'flowchart TD; A-->B' };
|
||||
|
||||
await previewHandlers.renderMermaidSvg(request);
|
||||
|
||||
expect(native.renderMermaidSvg).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
test('injects resolved fontDirs into typst requests', async () => {
|
||||
const previewHandlers = await loadPreviewHandlers();
|
||||
|
||||
await previewHandlers.renderTypstSvg({ code: '= hello' });
|
||||
|
||||
const [request] = native.renderTypstSvg.mock.calls[0];
|
||||
expect(request.options?.fontDirs).toEqual(
|
||||
expect.arrayContaining([
|
||||
path.resolve(typstFontDirA),
|
||||
path.resolve(typstFontDirB),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps explicit typst fontDirs', async () => {
|
||||
const previewHandlers = await loadPreviewHandlers();
|
||||
const request = {
|
||||
code: '= hello',
|
||||
options: {
|
||||
fontDirs: ['/tmp/custom-fonts'],
|
||||
},
|
||||
};
|
||||
|
||||
await previewHandlers.renderTypstSvg(request);
|
||||
|
||||
expect(native.renderTypstSvg).toHaveBeenCalledWith(request);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,6 @@ describe('workspace db management', () => {
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const validWorkspaceId = v4();
|
||||
const noDbWorkspaceId = v4();
|
||||
const deletedWorkspaceId = v4();
|
||||
const fileEntry = 'README.txt';
|
||||
|
||||
const validWorkspacePath = path.join(
|
||||
@@ -53,17 +52,6 @@ describe('workspace db management', () => {
|
||||
'local',
|
||||
noDbWorkspaceId
|
||||
);
|
||||
const deletedWorkspacePath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
'local',
|
||||
deletedWorkspaceId
|
||||
);
|
||||
const deletedWorkspaceTrashPath = path.join(
|
||||
appDataPath,
|
||||
'deleted-workspaces',
|
||||
deletedWorkspaceId
|
||||
);
|
||||
const nonDirectoryPath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
@@ -74,15 +62,11 @@ describe('workspace db management', () => {
|
||||
await fs.ensureDir(validWorkspacePath);
|
||||
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
|
||||
await fs.ensureDir(noDbWorkspacePath);
|
||||
await fs.ensureDir(deletedWorkspacePath);
|
||||
await fs.ensureFile(path.join(deletedWorkspacePath, 'storage.db'));
|
||||
await fs.ensureDir(deletedWorkspaceTrashPath);
|
||||
await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
|
||||
|
||||
const ids = await listLocalWorkspaceIds();
|
||||
expect(ids).toContain(validWorkspaceId);
|
||||
expect(ids).not.toContain(noDbWorkspaceId);
|
||||
expect(ids).not.toContain(deletedWorkspaceId);
|
||||
expect(ids).not.toContain(fileEntry);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ImportPage = ({
|
||||
<span>
|
||||
AFFiNE will gradually support more and more file types for import.
|
||||
<a
|
||||
href="https://affine.pro/redirect/discord"
|
||||
href="https://community.affine.pro/c/feature-requests/import-export"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const EditorLoading = ({
|
||||
1: (
|
||||
<a
|
||||
style={{ color: 'var(--affine-primary-color)' }}
|
||||
href="https://affine.pro/redirect/discord"
|
||||
href="https://community.affine.pro"
|
||||
target="__blank"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@radix-ui/react-toolbar": "^1.1.1",
|
||||
"@sentry/react": "^9.47.1",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@toeverything/mermaid-wasm": "^0.1.0",
|
||||
"@toeverything/pdf-viewer": "^0.1.1",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@vanilla-extract/dynamic": "^2.1.2",
|
||||
@@ -57,6 +58,7 @@
|
||||
"cmdk": "^1.0.4",
|
||||
"core-js": "^3.39.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.3.0",
|
||||
"eventemitter2": "^6.4.9",
|
||||
"file-type": "^21.0.0",
|
||||
"filesize": "^10.1.6",
|
||||
@@ -76,7 +78,6 @@
|
||||
"lit": "^3.2.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lottie-react": "^2.4.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mp4-muxer": "^5.2.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
@@ -40,7 +40,14 @@ interface CodeArtifactToolResult {
|
||||
toolCallId: string;
|
||||
toolName: string; // 'code_artifact'
|
||||
args: { title: string };
|
||||
result: { title: string; html: string; size: number } | ToolError | null;
|
||||
result:
|
||||
| {
|
||||
title: string;
|
||||
html: string;
|
||||
size: number;
|
||||
}
|
||||
| ToolError
|
||||
| null;
|
||||
}
|
||||
|
||||
export class CodeHighlighter extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderMermaidSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
|
||||
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { CodeBlockModel } from '@blocksuite/affine/model';
|
||||
@@ -7,7 +8,6 @@ import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import type { Mermaid } from 'mermaid';
|
||||
|
||||
export const CodeBlockMermaidPreview = CodeBlockPreviewExtension(
|
||||
'mermaid',
|
||||
@@ -154,7 +154,6 @@ export class MermaidPreview extends SignalWatcher(
|
||||
@query('.mermaid-preview-container')
|
||||
accessor container!: HTMLDivElement;
|
||||
|
||||
private mermaid: Mermaid | null = null;
|
||||
private retryCount = 0;
|
||||
private readonly maxRetries = 3;
|
||||
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -169,9 +168,6 @@ export class MermaidPreview extends SignalWatcher(
|
||||
private lastMouseY = 0;
|
||||
|
||||
override firstUpdated(_changedProperties: PropertyValues): void {
|
||||
this._loadMermaid().catch(error => {
|
||||
console.error('Failed to load mermaid in firstUpdated:', error);
|
||||
});
|
||||
this._scheduleRender();
|
||||
this._setupEventListeners();
|
||||
|
||||
@@ -271,7 +267,8 @@ export class MermaidPreview extends SignalWatcher(
|
||||
event.preventDefault();
|
||||
|
||||
const delta = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newScale = Math.max(0.1, Math.min(5, this.scale * delta));
|
||||
const previousScale = this.scale;
|
||||
const newScale = Math.max(0.1, Math.min(5, previousScale * delta));
|
||||
|
||||
// calculate mouse position relative to container
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
@@ -284,8 +281,8 @@ export class MermaidPreview extends SignalWatcher(
|
||||
|
||||
// update transform
|
||||
this.scale = newScale;
|
||||
this.translateX = mouseX - scaleCenterX * (newScale / this.scale);
|
||||
this.translateY = mouseY - scaleCenterY * (newScale / this.scale);
|
||||
this.translateX = mouseX - scaleCenterX * (newScale / previousScale);
|
||||
this.translateY = mouseY - scaleCenterY * (newScale / previousScale);
|
||||
|
||||
this._updateTransform();
|
||||
};
|
||||
@@ -309,44 +306,6 @@ export class MermaidPreview extends SignalWatcher(
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadMermaid() {
|
||||
try {
|
||||
// dynamic load mermaid
|
||||
const mermaidModule = await import('mermaid');
|
||||
this.mermaid = mermaidModule.default;
|
||||
|
||||
// initialize mermaid
|
||||
this.mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
sequence: {
|
||||
useMaxWidth: true,
|
||||
},
|
||||
gantt: {
|
||||
useMaxWidth: true,
|
||||
},
|
||||
pie: {
|
||||
useMaxWidth: true,
|
||||
},
|
||||
journey: {
|
||||
useMaxWidth: true,
|
||||
},
|
||||
gitGraph: {
|
||||
useMaxWidth: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load mermaid:', error);
|
||||
this.state = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
private async _render() {
|
||||
// prevent duplicate rendering
|
||||
if (this.isRendering) {
|
||||
@@ -356,28 +315,25 @@ export class MermaidPreview extends SignalWatcher(
|
||||
this.isRendering = true;
|
||||
this.state = 'loading';
|
||||
|
||||
if (!this.normalizedMermaidCode) {
|
||||
const code = this.normalizedMermaidCode?.trim();
|
||||
|
||||
if (!code) {
|
||||
this.svgContent = '';
|
||||
this.state = 'fallback';
|
||||
this.isRendering = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.mermaid) {
|
||||
await this._loadMermaid();
|
||||
}
|
||||
if (!this.mermaid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// generate unique ID
|
||||
const diagramId = `mermaid-diagram-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// generate SVG
|
||||
const { svg } = await this.mermaid.render(
|
||||
diagramId,
|
||||
this.normalizedMermaidCode
|
||||
);
|
||||
const { svg } = await renderMermaidSvg({
|
||||
code,
|
||||
options: {
|
||||
fastText: true,
|
||||
svgOnly: true,
|
||||
theme: 'default',
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
},
|
||||
});
|
||||
|
||||
// update SVG content
|
||||
this.svgContent = svg;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderTypstSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
|
||||
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { CodeBlockModel } from '@blocksuite/affine/model';
|
||||
@@ -8,8 +9,6 @@ import { property, query, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ensureTypstReady, getTypst } from './typst';
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 200;
|
||||
|
||||
export const CodeBlockTypstPreview = CodeBlockPreviewExtension(
|
||||
@@ -378,9 +377,7 @@ ${this.errorMessage}</pre
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureTypstReady();
|
||||
const typst = await getTypst();
|
||||
const svg = await typst.svg({ mainContent: code });
|
||||
const { svg } = await renderTypstSvg({ code });
|
||||
this.svgContent = svg;
|
||||
this.state = 'finish';
|
||||
this._resetView();
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { $typst, type BeforeBuildFn, loadFonts } from '@myriaddreamin/typst.ts';
|
||||
|
||||
const FONT_CDN_URLS = [
|
||||
'https://cdn.affine.pro/fonts/Inter-Regular.woff',
|
||||
'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
|
||||
'https://cdn.affine.pro/fonts/Inter-Italic.woff',
|
||||
'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
|
||||
'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf',
|
||||
] as const;
|
||||
|
||||
const getBeforeBuildHooks = (): BeforeBuildFn[] => [
|
||||
loadFonts([...FONT_CDN_URLS]),
|
||||
];
|
||||
|
||||
const compilerWasmUrl = new URL(
|
||||
'@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const rendererWasmUrl = new URL(
|
||||
'@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
let typstInitPromise: Promise<void> | null = null;
|
||||
|
||||
export async function ensureTypstReady() {
|
||||
if (typstInitPromise) {
|
||||
return typstInitPromise;
|
||||
}
|
||||
|
||||
typstInitPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
$typst.setCompilerInitOptions({
|
||||
beforeBuild: getBeforeBuildHooks(),
|
||||
getModule: () => compilerWasmUrl,
|
||||
});
|
||||
|
||||
$typst.setRendererInitOptions({
|
||||
beforeBuild: getBeforeBuildHooks(),
|
||||
getModule: () => rendererWasmUrl,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
typstInitPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return typstInitPromise;
|
||||
}
|
||||
|
||||
export async function getTypst() {
|
||||
await ensureTypstReady();
|
||||
return $typst;
|
||||
}
|
||||
|
||||
export const TYPST_FONT_URLS = FONT_CDN_URLS;
|
||||
@@ -142,7 +142,7 @@ export function ContactUS() {
|
||||
1: (
|
||||
<a
|
||||
style={{ color: 'var(--affine-primary-color)' }}
|
||||
href="https://affine.pro/redirect/discord"
|
||||
href="https://community.affine.pro"
|
||||
target="__blank"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -25,7 +25,7 @@ export const property = style({
|
||||
selectors: {
|
||||
'&[data-show="false"]': {
|
||||
backgroundColor: cssVarV2.button.emptyIconBackground,
|
||||
color: cssVarV2.text.secondary,
|
||||
color: cssVarV2.icon.disable,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, notify } from '@affine/component';
|
||||
import { Button } from '@affine/component';
|
||||
import {
|
||||
AuthContainer,
|
||||
AuthContent,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { ServersService } from '@affine/core/modules/cloud';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import {
|
||||
@@ -36,14 +35,12 @@ export const AddSelfhostedStep = ({
|
||||
state: SignInState;
|
||||
changeState: Dispatch<SetStateAction<SignInState>>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const serversService = useService(ServersService);
|
||||
const [baseURL, setBaseURL] = useState(state.initialServerBaseUrl ?? '');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [errorHint, setErrorHint] = useState(
|
||||
t['com.affine.auth.sign.add-selfhosted.error']()
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const urlValid = useMemo(() => {
|
||||
try {
|
||||
@@ -54,14 +51,10 @@ export const AddSelfhostedStep = ({
|
||||
}
|
||||
}, [baseURL]);
|
||||
|
||||
const onBaseURLChange = useCallback(
|
||||
(value: string) => {
|
||||
setBaseURL(value);
|
||||
setError(false);
|
||||
setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
|
||||
},
|
||||
[t]
|
||||
);
|
||||
const onBaseURLChange = useCallback((value: string) => {
|
||||
setBaseURL(value);
|
||||
setError(false);
|
||||
}, []);
|
||||
|
||||
const onConnect = useAsyncCallback(async () => {
|
||||
setIsConnecting(true);
|
||||
@@ -76,33 +69,11 @@ export const AddSelfhostedStep = ({
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const userFriendlyError = UserFriendlyError.fromAny(err);
|
||||
setError(true);
|
||||
if (userFriendlyError.is('TOO_MANY_REQUEST')) {
|
||||
setErrorHint(t['error.TOO_MANY_REQUEST']());
|
||||
} else if (
|
||||
userFriendlyError.is('NETWORK_ERROR') ||
|
||||
userFriendlyError.is('REQUEST_ABORTED')
|
||||
) {
|
||||
setErrorHint(t['error.NETWORK_ERROR']());
|
||||
} else {
|
||||
setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
|
||||
}
|
||||
|
||||
notify.error({
|
||||
title: t['com.affine.auth.toast.title.failed'](),
|
||||
message:
|
||||
userFriendlyError.is('REQUEST_ABORTED') ||
|
||||
userFriendlyError.is('NETWORK_ERROR')
|
||||
? t['error.NETWORK_ERROR']()
|
||||
: userFriendlyError.is('TOO_MANY_REQUEST')
|
||||
? t['error.TOO_MANY_REQUEST']()
|
||||
: t[`error.${userFriendlyError.name}`](userFriendlyError.data),
|
||||
});
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [baseURL, changeState, serversService, t]);
|
||||
|
||||
setIsConnecting(false);
|
||||
}, [baseURL, changeState, serversService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.initialServerBaseUrl) {
|
||||
@@ -130,7 +101,7 @@ export const AddSelfhostedStep = ({
|
||||
placeholder="https://your-server.com"
|
||||
error={!!error}
|
||||
disabled={isConnecting}
|
||||
errorHint={errorHint}
|
||||
errorHint={t['com.affine.auth.sign.add-selfhosted.error']()}
|
||||
onEnter={onConnect}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '@affine/core/modules/cloud';
|
||||
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { ServerDeploymentType } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
@@ -47,7 +46,6 @@ export const SignInWithPasswordStep = ({
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const [passwordErrorHint, setPasswordErrorHint] = useState('');
|
||||
const captchaService = useService(CaptchaService);
|
||||
const serverService = useService(ServerService);
|
||||
const isSelfhosted = useLiveData(
|
||||
@@ -76,10 +74,6 @@ export const SignInWithPasswordStep = ({
|
||||
onAuthenticated?.(loginStatus);
|
||||
}, [loginStatus, onAuthenticated, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
}, [t]);
|
||||
|
||||
const onSignIn = useAsyncCallback(async () => {
|
||||
if (isLoading || (!verifyToken && needCaptcha)) return;
|
||||
setIsLoading(true);
|
||||
@@ -94,23 +88,7 @@ export const SignInWithPasswordStep = ({
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = UserFriendlyError.fromAny(err);
|
||||
|
||||
if (
|
||||
error.is('WRONG_SIGN_IN_CREDENTIALS') ||
|
||||
error.is('PASSWORD_REQUIRED')
|
||||
) {
|
||||
setPasswordError(true);
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
} else {
|
||||
setPasswordError(false);
|
||||
notify.error({
|
||||
title: t['com.affine.auth.toast.title.failed'](),
|
||||
message: error.is('REQUEST_ABORTED')
|
||||
? t['error.NETWORK_ERROR']()
|
||||
: t[`error.${error.name}`](error.data),
|
||||
});
|
||||
}
|
||||
setPasswordError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -123,7 +101,6 @@ export const SignInWithPasswordStep = ({
|
||||
email,
|
||||
password,
|
||||
challenge,
|
||||
t,
|
||||
]);
|
||||
|
||||
const sendMagicLink = useCallback(() => {
|
||||
@@ -149,15 +126,11 @@ export const SignInWithPasswordStep = ({
|
||||
label={t['com.affine.auth.password']()}
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={(value: string) => {
|
||||
onChange={useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
if (passwordError) {
|
||||
setPasswordError(false);
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
}
|
||||
}}
|
||||
}, [])}
|
||||
error={passwordError}
|
||||
errorHint={passwordErrorHint}
|
||||
errorHint={t['com.affine.auth.password.error']()}
|
||||
onEnter={onSignIn}
|
||||
/>
|
||||
{!isSelfhosted && (
|
||||
|
||||
@@ -128,7 +128,7 @@ export const AboutAffine = () => {
|
||||
<a
|
||||
className={styles.link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro/redirect/discord"
|
||||
href="https://community.affine.pro"
|
||||
target="_blank"
|
||||
>
|
||||
{t['com.affine.aboutAFFiNE.contact.community']()}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
gqlFetcherFactory,
|
||||
type OauthProvidersQuery,
|
||||
@@ -12,45 +11,6 @@ import { Store } from '@toeverything/infra';
|
||||
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
|
||||
OauthProvidersQuery['serverConfig'];
|
||||
|
||||
const NETWORK_ERROR_PATTERNS = [
|
||||
/failed to fetch/i,
|
||||
/network request failed/i,
|
||||
/fetch failed/i,
|
||||
/load failed/i,
|
||||
/networkerror/i,
|
||||
/cors/i,
|
||||
/certificate/i,
|
||||
/ssl/i,
|
||||
/err_[a-z_]+/i,
|
||||
];
|
||||
|
||||
function mapServerConfigError(error: unknown) {
|
||||
const userFriendlyError = UserFriendlyError.fromAny(error);
|
||||
if (
|
||||
userFriendlyError.is('NETWORK_ERROR') ||
|
||||
userFriendlyError.is('REQUEST_ABORTED') ||
|
||||
userFriendlyError.is('TOO_MANY_REQUEST')
|
||||
) {
|
||||
return userFriendlyError;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const detail = `${error.name}: ${error.message}`;
|
||||
if (NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(detail))) {
|
||||
return new UserFriendlyError({
|
||||
status: 504,
|
||||
code: 'NETWORK_ERROR',
|
||||
type: 'NETWORK_ERROR',
|
||||
name: 'NETWORK_ERROR',
|
||||
message: detail,
|
||||
stacktrace: error.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return userFriendlyError;
|
||||
}
|
||||
|
||||
export class ServerConfigStore extends Store {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -60,13 +20,19 @@ export class ServerConfigStore extends Store {
|
||||
serverBaseUrl: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<ServerConfigType> {
|
||||
try {
|
||||
const gql = gqlFetcherFactory(
|
||||
`${serverBaseUrl}/graphql`,
|
||||
globalThis.fetch
|
||||
);
|
||||
const serverConfigData = await gql({
|
||||
query: serverConfigQuery,
|
||||
const gql = gqlFetcherFactory(`${serverBaseUrl}/graphql`, globalThis.fetch);
|
||||
const serverConfigData = await gql({
|
||||
query: serverConfigQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)) {
|
||||
const oauthProvidersData = await gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
@@ -74,26 +40,11 @@ export class ServerConfigStore extends Store {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (
|
||||
serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)
|
||||
) {
|
||||
const oauthProvidersData = await gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...serverConfigData.serverConfig,
|
||||
...oauthProvidersData.serverConfig,
|
||||
};
|
||||
}
|
||||
return { ...serverConfigData.serverConfig, oauthProviders: [] };
|
||||
} catch (error) {
|
||||
throw mapServerConfigError(error);
|
||||
return {
|
||||
...serverConfigData.serverConfig,
|
||||
...oauthProvidersData.serverConfig,
|
||||
};
|
||||
}
|
||||
return { ...serverConfigData.serverConfig, oauthProviders: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const { desktopPreviewApis, mermaidRender, typstRender } = vi.hoisted(() => {
|
||||
return {
|
||||
mermaidRender: vi.fn(),
|
||||
typstRender: vi.fn(),
|
||||
desktopPreviewApis: {} as {
|
||||
preview?: {
|
||||
renderMermaidSvg?: (request: {
|
||||
code: string;
|
||||
}) => Promise<{ svg: string }>;
|
||||
renderTypstSvg?: (request: {
|
||||
code: string;
|
||||
}) => Promise<{ svg: string }>;
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { domPurifySanitize } = vi.hoisted(() => ({
|
||||
domPurifySanitize: vi.fn((value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/mermaid/renderer', () => ({
|
||||
getMermaidRenderer: () => ({
|
||||
render: mermaidRender,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/typst/renderer', () => ({
|
||||
getTypstRenderer: () => ({
|
||||
render: typstRender,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/electron-api', () => ({
|
||||
apis: desktopPreviewApis,
|
||||
}));
|
||||
|
||||
vi.mock('dompurify', () => ({
|
||||
default: {
|
||||
sanitize: domPurifySanitize,
|
||||
},
|
||||
}));
|
||||
|
||||
import { renderMermaidSvg, renderTypstSvg } from './bridge';
|
||||
|
||||
const initialBuildConfig = globalThis.BUILD_CONFIG;
|
||||
|
||||
describe('preview render bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
domPurifySanitize.mockImplementation((value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
});
|
||||
globalThis.BUILD_CONFIG = {
|
||||
...initialBuildConfig,
|
||||
isElectron: false,
|
||||
};
|
||||
desktopPreviewApis.preview = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.BUILD_CONFIG = initialBuildConfig;
|
||||
});
|
||||
|
||||
test('uses worker renderers and only sanitizes mermaid output', async () => {
|
||||
mermaidRender.mockResolvedValue({
|
||||
svg: '<svg><script>alert(1)</script><text>mermaid</text></svg>',
|
||||
});
|
||||
typstRender.mockResolvedValue({
|
||||
svg: '<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>',
|
||||
});
|
||||
|
||||
const mermaid = await renderMermaidSvg({ code: 'flowchart TD;A-->B' });
|
||||
const typst = await renderTypstSvg({ code: '= Title' });
|
||||
|
||||
expect(mermaidRender).toHaveBeenCalledTimes(1);
|
||||
expect(typstRender).toHaveBeenCalledTimes(1);
|
||||
expect(mermaid.svg).toContain('<svg');
|
||||
expect(mermaid.svg).toContain('mermaid');
|
||||
expect(mermaid.svg).not.toContain('<script');
|
||||
expect(typst.svg).toBe(
|
||||
'<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>'
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers desktop preview handlers on electron', async () => {
|
||||
const renderMermaidFromDesktop = vi.fn().mockResolvedValue({
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg"><text>desktop</text></svg>`,
|
||||
});
|
||||
const renderTypstFromDesktop = vi.fn().mockResolvedValue({
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg"><text>desktop</text></svg>`,
|
||||
});
|
||||
desktopPreviewApis.preview = {
|
||||
renderMermaidSvg: renderMermaidFromDesktop,
|
||||
renderTypstSvg: renderTypstFromDesktop,
|
||||
};
|
||||
globalThis.BUILD_CONFIG = {
|
||||
...initialBuildConfig,
|
||||
isElectron: true,
|
||||
};
|
||||
|
||||
const mermaid = await renderMermaidSvg({ code: 'flowchart TD;A-->B' });
|
||||
const typst = await renderTypstSvg({ code: '= Title' });
|
||||
|
||||
expect(renderMermaidFromDesktop).toHaveBeenCalledTimes(1);
|
||||
expect(renderTypstFromDesktop).toHaveBeenCalledTimes(1);
|
||||
expect(mermaidRender).not.toHaveBeenCalled();
|
||||
expect(typstRender).not.toHaveBeenCalled();
|
||||
expect(mermaid.svg).toContain('<svg');
|
||||
expect(typst.svg).toBe(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg"><text>desktop</text></svg>`
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on electron when native handlers are missing', async () => {
|
||||
globalThis.BUILD_CONFIG = {
|
||||
...initialBuildConfig,
|
||||
isElectron: true,
|
||||
};
|
||||
desktopPreviewApis.preview = {};
|
||||
|
||||
await expect(
|
||||
renderMermaidSvg({ code: 'flowchart TD;A-->B' })
|
||||
).rejects.toThrow(
|
||||
'Electron preview handler "renderMermaidSvg" is unavailable.'
|
||||
);
|
||||
await expect(renderTypstSvg({ code: '= Title' })).rejects.toThrow(
|
||||
'Electron preview handler "renderTypstSvg" is unavailable.'
|
||||
);
|
||||
|
||||
expect(mermaidRender).not.toHaveBeenCalled();
|
||||
expect(typstRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws when sanitized svg is empty', async () => {
|
||||
mermaidRender.mockResolvedValue({
|
||||
svg: '<div><text>invalid</text></div>',
|
||||
});
|
||||
|
||||
await expect(
|
||||
renderMermaidSvg({ code: 'flowchart TD;A-->B' })
|
||||
).rejects.toThrow('Preview renderer returned invalid SVG.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
getMermaidRenderer,
|
||||
type MermaidRenderRequest,
|
||||
type MermaidRenderResult,
|
||||
} from '@affine/core/modules/mermaid/renderer';
|
||||
import {
|
||||
getTypstRenderer,
|
||||
type TypstRenderRequest,
|
||||
type TypstRenderResult,
|
||||
} from '@affine/core/modules/typst/renderer';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
function removeForeignObject(root: ParentNode) {
|
||||
root
|
||||
.querySelectorAll('foreignObject, foreignobject')
|
||||
.forEach(element => element.remove());
|
||||
}
|
||||
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
if (
|
||||
typeof DOMParser === 'undefined' ||
|
||||
typeof XMLSerializer === 'undefined'
|
||||
) {
|
||||
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
|
||||
if (typeof sanitized !== 'string' || !/^\s*<svg[\s>]/i.test(sanitized)) {
|
||||
return '';
|
||||
}
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const parsed = parser.parseFromString(svg, 'image/svg+xml');
|
||||
const root = parsed.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
|
||||
|
||||
const sanitized = DOMPurify.sanitize(root, { USE_PROFILES: { svg: true } });
|
||||
if (typeof sanitized !== 'string') return '';
|
||||
|
||||
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
|
||||
const sanitizedRoot = sanitizedDoc.documentElement;
|
||||
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg')
|
||||
return '';
|
||||
|
||||
removeForeignObject(sanitizedRoot);
|
||||
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
|
||||
}
|
||||
|
||||
type DesktopPreviewHandlers = {
|
||||
renderMermaidSvg?: (
|
||||
request: MermaidRenderRequest
|
||||
) => Promise<MermaidRenderResult>;
|
||||
renderTypstSvg?: (request: TypstRenderRequest) => Promise<TypstRenderResult>;
|
||||
};
|
||||
|
||||
type DesktopPreviewApis = {
|
||||
preview?: DesktopPreviewHandlers;
|
||||
};
|
||||
|
||||
function getDesktopPreviewHandlers() {
|
||||
if (!BUILD_CONFIG.isElectron || !apis) return null;
|
||||
|
||||
const previewApis = apis as unknown as DesktopPreviewApis;
|
||||
return previewApis.preview ?? null;
|
||||
}
|
||||
|
||||
function getRequiredDesktopHandler<Name extends keyof DesktopPreviewHandlers>(
|
||||
name: Name
|
||||
): NonNullable<DesktopPreviewHandlers[Name]> {
|
||||
const handlers = getDesktopPreviewHandlers();
|
||||
const handler = handlers?.[name];
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Electron preview handler "${String(name)}" is unavailable.`
|
||||
);
|
||||
}
|
||||
return handler as NonNullable<DesktopPreviewHandlers[Name]>;
|
||||
}
|
||||
|
||||
export async function renderMermaidSvg(
|
||||
request: MermaidRenderRequest
|
||||
): Promise<MermaidRenderResult> {
|
||||
const rendered = BUILD_CONFIG.isElectron
|
||||
? await getRequiredDesktopHandler('renderMermaidSvg')(request)
|
||||
: await getMermaidRenderer().render(request);
|
||||
|
||||
const sanitizedSvg = sanitizeSvg(rendered.svg);
|
||||
if (!sanitizedSvg) {
|
||||
throw new Error('Preview renderer returned invalid SVG.');
|
||||
}
|
||||
return { svg: sanitizedSvg };
|
||||
}
|
||||
|
||||
export async function renderTypstSvg(
|
||||
request: TypstRenderRequest
|
||||
): Promise<TypstRenderResult> {
|
||||
const rendered = BUILD_CONFIG.isElectron
|
||||
? await getRequiredDesktopHandler('renderTypstSvg')(request)
|
||||
: await getTypstRenderer().render(request);
|
||||
|
||||
return { svg: rendered.svg };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
MermaidRenderRequest,
|
||||
MermaidRenderResult,
|
||||
} from '@affine/core/modules/mermaid/renderer';
|
||||
import type {
|
||||
TypstRenderRequest,
|
||||
TypstRenderResult,
|
||||
} from '@affine/core/modules/typst/renderer';
|
||||
|
||||
export type PreviewRenderRequestMap = {
|
||||
mermaid: MermaidRenderRequest;
|
||||
typst: TypstRenderRequest;
|
||||
};
|
||||
|
||||
export type PreviewRenderResultMap = {
|
||||
mermaid: MermaidRenderResult;
|
||||
typst: TypstRenderResult;
|
||||
};
|
||||
39
packages/frontend/core/src/modules/mermaid/renderer/index.ts
Normal file
39
packages/frontend/core/src/modules/mermaid/renderer/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
|
||||
import type {
|
||||
MermaidOps,
|
||||
MermaidRenderOptions,
|
||||
MermaidRenderRequest,
|
||||
} from './types';
|
||||
|
||||
class MermaidRenderer extends WorkerOpRenderer<MermaidOps> {
|
||||
constructor() {
|
||||
super('mermaid');
|
||||
}
|
||||
|
||||
init(options?: MermaidRenderOptions) {
|
||||
return this.ensureInitialized(() => this.call('init', options));
|
||||
}
|
||||
|
||||
async render(request: MermaidRenderRequest) {
|
||||
await this.init();
|
||||
return this.call('render', request);
|
||||
}
|
||||
}
|
||||
|
||||
let sharedMermaidRenderer: MermaidRenderer | null = null;
|
||||
|
||||
export function getMermaidRenderer() {
|
||||
if (!sharedMermaidRenderer) {
|
||||
sharedMermaidRenderer = new MermaidRenderer();
|
||||
}
|
||||
return sharedMermaidRenderer;
|
||||
}
|
||||
|
||||
export type {
|
||||
MermaidOps,
|
||||
MermaidRenderOptions,
|
||||
MermaidRenderRequest,
|
||||
MermaidRenderResult,
|
||||
MermaidRenderTheme,
|
||||
MermaidTextMetrics,
|
||||
} from './types';
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { MessageCommunicapable } from '@toeverything/infra/op';
|
||||
import { OpConsumer } from '@toeverything/infra/op';
|
||||
import initMmdr, { render_mermaid_svg } from '@toeverything/mermaid-wasm';
|
||||
|
||||
import type {
|
||||
MermaidOps,
|
||||
MermaidRenderOptions,
|
||||
MermaidRenderRequest,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_RENDER_OPTIONS: MermaidRenderOptions = {
|
||||
fastText: true,
|
||||
svgOnly: true,
|
||||
theme: 'modern',
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
};
|
||||
|
||||
function mergeOptions(
|
||||
base: MermaidRenderOptions,
|
||||
override: MermaidRenderOptions | undefined
|
||||
): MermaidRenderOptions {
|
||||
if (!override) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
textMetrics: override.textMetrics ?? base.textMetrics,
|
||||
};
|
||||
}
|
||||
|
||||
class MermaidRendererBackend extends OpConsumer<MermaidOps> {
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private options: MermaidRenderOptions = DEFAULT_RENDER_OPTIONS;
|
||||
|
||||
constructor(port: MessageCommunicapable) {
|
||||
super(port);
|
||||
this.register('init', this.init.bind(this));
|
||||
this.register('render', this.render.bind(this));
|
||||
}
|
||||
|
||||
private ensureReady() {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = initMmdr().then(() => undefined);
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async init(options?: MermaidRenderOptions) {
|
||||
this.options = mergeOptions(DEFAULT_RENDER_OPTIONS, options);
|
||||
await this.ensureReady();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
|
||||
async render({ code, options }: MermaidRenderRequest) {
|
||||
await this.ensureReady();
|
||||
const mergedOptions = mergeOptions(this.options, options);
|
||||
const svg = render_mermaid_svg(code, JSON.stringify(mergedOptions));
|
||||
return { svg };
|
||||
}
|
||||
}
|
||||
|
||||
new MermaidRendererBackend(self as MessageCommunicapable);
|
||||
32
packages/frontend/core/src/modules/mermaid/renderer/types.ts
Normal file
32
packages/frontend/core/src/modules/mermaid/renderer/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { OpSchema } from '@toeverything/infra/op';
|
||||
|
||||
export type MermaidTextMetrics = {
|
||||
ascii: number;
|
||||
cjk: number;
|
||||
space: number;
|
||||
};
|
||||
|
||||
export type MermaidRenderTheme = 'modern' | 'default';
|
||||
|
||||
export type MermaidRenderOptions = {
|
||||
fastText?: boolean;
|
||||
svgOnly?: boolean;
|
||||
textMetrics?: MermaidTextMetrics;
|
||||
theme?: MermaidRenderTheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
export type MermaidRenderRequest = {
|
||||
code: string;
|
||||
options?: MermaidRenderOptions;
|
||||
};
|
||||
|
||||
export type MermaidRenderResult = {
|
||||
svg: string;
|
||||
};
|
||||
|
||||
export interface MermaidOps extends OpSchema {
|
||||
init: [MermaidRenderOptions | undefined, { ok: true }];
|
||||
render: [MermaidRenderRequest, MermaidRenderResult];
|
||||
}
|
||||
@@ -1,2 +1,10 @@
|
||||
export { PDFRenderer } from './renderer';
|
||||
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
|
||||
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
|
||||
import type { PDFOps } from './types';
|
||||
|
||||
export class PDFRenderer extends WorkerOpRenderer<PDFOps> {
|
||||
constructor() {
|
||||
super('pdf');
|
||||
}
|
||||
}
|
||||
|
||||
export type { PDFMeta, PDFOps, RenderedPage, RenderPageOpts } from './types';
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { OpSchema } from '@toeverything/infra/op';
|
||||
|
||||
import type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
|
||||
|
||||
export interface ClientOps extends OpSchema {
|
||||
open: [{ data: ArrayBuffer }, PDFMeta];
|
||||
render: [RenderPageOpts, RenderedPage];
|
||||
}
|
||||
@@ -23,10 +23,9 @@ import {
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
|
||||
import type { ClientOps } from './ops';
|
||||
import type { PDFMeta, RenderPageOpts } from './types';
|
||||
import type { PDFMeta, PDFOps, RenderPageOpts } from './types';
|
||||
|
||||
class PDFRendererBackend extends OpConsumer<ClientOps> {
|
||||
class PDFRendererBackend extends OpConsumer<PDFOps> {
|
||||
constructor(port: MessageCommunicapable) {
|
||||
super(port);
|
||||
this.register('open', this.open.bind(this));
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { OpClient } from '@toeverything/infra/op';
|
||||
|
||||
import type { ClientOps } from './ops';
|
||||
|
||||
export class PDFRenderer extends OpClient<ClientOps> {
|
||||
private readonly worker: Worker;
|
||||
|
||||
constructor() {
|
||||
const worker = new Worker(getWorkerUrl('pdf'));
|
||||
super(worker);
|
||||
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
super.destroy();
|
||||
this.worker.terminate();
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { OpSchema } from '@toeverything/infra/op';
|
||||
|
||||
export type PageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -21,3 +23,8 @@ export type RenderPageOpts = {
|
||||
export type RenderedPage = {
|
||||
bitmap: ImageBitmap;
|
||||
};
|
||||
|
||||
export interface PDFOps extends OpSchema {
|
||||
open: [{ data: ArrayBuffer }, PDFMeta];
|
||||
render: [RenderPageOpts, RenderedPage];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { OpClient, type OpSchema } from '@toeverything/infra/op';
|
||||
|
||||
type InitTask = () => Promise<unknown>;
|
||||
|
||||
export abstract class WorkerOpRenderer<
|
||||
Ops extends OpSchema,
|
||||
> extends OpClient<Ops> {
|
||||
private readonly worker: Worker;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
protected constructor(workerName: string) {
|
||||
const worker = new Worker(getWorkerUrl(workerName));
|
||||
super(worker);
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
protected ensureInitialized(task: InitTask) {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = task()
|
||||
.then(() => undefined)
|
||||
.catch(error => {
|
||||
this.initPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
protected resetInitialization() {
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
super.destroy();
|
||||
this.worker.terminate();
|
||||
this.resetInitialization();
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
33
packages/frontend/core/src/modules/typst/renderer/index.ts
Normal file
33
packages/frontend/core/src/modules/typst/renderer/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
|
||||
import type { TypstOps, TypstRenderOptions, TypstRenderRequest } from './types';
|
||||
|
||||
class TypstRenderer extends WorkerOpRenderer<TypstOps> {
|
||||
constructor() {
|
||||
super('typst');
|
||||
}
|
||||
|
||||
init(options?: TypstRenderOptions) {
|
||||
return this.ensureInitialized(() => this.call('init', options));
|
||||
}
|
||||
|
||||
async render(request: TypstRenderRequest) {
|
||||
await this.init();
|
||||
return this.call('render', request);
|
||||
}
|
||||
}
|
||||
|
||||
let sharedTypstRenderer: TypstRenderer | null = null;
|
||||
|
||||
export function getTypstRenderer() {
|
||||
if (!sharedTypstRenderer) {
|
||||
sharedTypstRenderer = new TypstRenderer();
|
||||
}
|
||||
return sharedTypstRenderer;
|
||||
}
|
||||
|
||||
export type {
|
||||
TypstOps,
|
||||
TypstRenderOptions,
|
||||
TypstRenderRequest,
|
||||
TypstRenderResult,
|
||||
} from './types';
|
||||
177
packages/frontend/core/src/modules/typst/renderer/runtime.ts
Normal file
177
packages/frontend/core/src/modules/typst/renderer/runtime.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { $typst, type BeforeBuildFn, loadFonts } from '@myriaddreamin/typst.ts';
|
||||
|
||||
import type { TypstRenderOptions } from './types';
|
||||
|
||||
export const DEFAULT_TYPST_FONT_URLS = [
|
||||
'https://cdn.affine.pro/fonts/Inter-Regular.woff',
|
||||
'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
|
||||
'https://cdn.affine.pro/fonts/Inter-Italic.woff',
|
||||
'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
|
||||
'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TYPST_RENDER_OPTIONS: TypstRenderOptions = {
|
||||
fontUrls: [...DEFAULT_TYPST_FONT_URLS],
|
||||
};
|
||||
|
||||
const DEFAULT_FONT_FALLBACKS: Record<string, string> = {
|
||||
'Inter-Regular.woff': 'Inter-Regular.woff2',
|
||||
'Inter-SemiBold.woff': 'Inter-SemiBold.woff2',
|
||||
'Inter-Italic.woff': 'Inter-Italic.woff2',
|
||||
'Inter-SemiBoldItalic.woff': 'Inter-SemiBoldItalic.woff2',
|
||||
'SarasaGothicCL-Regular.ttf': 'Inter-Regular.woff2',
|
||||
'Inter-Regular.woff2': 'Inter-Regular.woff2',
|
||||
'Inter-SemiBold.woff2': 'Inter-SemiBold.woff2',
|
||||
'Inter-Italic.woff2': 'Inter-Italic.woff2',
|
||||
'Inter-SemiBoldItalic.woff2': 'Inter-SemiBoldItalic.woff2',
|
||||
};
|
||||
|
||||
const compilerWasmUrl = new URL(
|
||||
'@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const rendererWasmUrl = new URL(
|
||||
'@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
type TypstWasmModuleUrls = {
|
||||
compilerWasmUrl?: string;
|
||||
rendererWasmUrl?: string;
|
||||
};
|
||||
|
||||
let typstInitPromise: Promise<void> | null = null;
|
||||
|
||||
function extractInputUrl(input: RequestInfo | URL): string | null {
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
return input.url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalFallbackFontUrl(sourceUrl: string): string | null {
|
||||
if (typeof location === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = new URL(sourceUrl, location.href);
|
||||
const fileName = source.pathname.split('/').at(-1);
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackFileName = DEFAULT_FONT_FALLBACKS[fileName];
|
||||
if (!fallbackFileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workerUrl = new URL(location.href);
|
||||
const jsPathMarker = '/js/';
|
||||
const markerIndex = workerUrl.pathname.lastIndexOf(jsPathMarker);
|
||||
const basePath =
|
||||
markerIndex >= 0 ? workerUrl.pathname.slice(0, markerIndex + 1) : '/';
|
||||
|
||||
return new URL(
|
||||
`${basePath}fonts/${fallbackFileName}`,
|
||||
workerUrl.origin
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function createTypstFontFetcher(baseFetcher: typeof fetch = fetch) {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const sourceUrl = extractInputUrl(input);
|
||||
const fallbackUrl = sourceUrl
|
||||
? resolveLocalFallbackFontUrl(sourceUrl)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const response = await baseFetcher(input, init);
|
||||
if (!fallbackUrl || response.ok || fallbackUrl === sourceUrl) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const fallbackResponse = await baseFetcher(fallbackUrl, init);
|
||||
return fallbackResponse.ok ? fallbackResponse : response;
|
||||
} catch (error) {
|
||||
if (!fallbackUrl || fallbackUrl === sourceUrl) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return baseFetcher(fallbackUrl, init);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeTypstRenderOptions(
|
||||
base: TypstRenderOptions,
|
||||
override: TypstRenderOptions | undefined
|
||||
): TypstRenderOptions {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
fontUrls: override?.fontUrls ?? base.fontUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function getBeforeBuildHooks(fontUrls: string[]): BeforeBuildFn[] {
|
||||
return [
|
||||
loadFonts([...fontUrls], {
|
||||
assets: ['text'],
|
||||
fetcher: createTypstFontFetcher(),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export async function ensureTypstReady(
|
||||
fontUrls: string[],
|
||||
wasmModuleUrls: TypstWasmModuleUrls = {}
|
||||
) {
|
||||
if (typstInitPromise) {
|
||||
return typstInitPromise;
|
||||
}
|
||||
|
||||
typstInitPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const compilerBeforeBuild = getBeforeBuildHooks(fontUrls);
|
||||
|
||||
$typst.setCompilerInitOptions({
|
||||
beforeBuild: compilerBeforeBuild,
|
||||
getModule: () => wasmModuleUrls.compilerWasmUrl ?? compilerWasmUrl,
|
||||
});
|
||||
$typst.setRendererInitOptions({
|
||||
getModule: () => wasmModuleUrls.rendererWasmUrl ?? rendererWasmUrl,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
typstInitPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return typstInitPromise;
|
||||
}
|
||||
|
||||
export async function renderTypstSvgWithOptions(
|
||||
code: string,
|
||||
options: TypstRenderOptions | undefined,
|
||||
wasmModuleUrls?: TypstWasmModuleUrls
|
||||
) {
|
||||
const resolvedOptions = mergeTypstRenderOptions(
|
||||
DEFAULT_TYPST_RENDER_OPTIONS,
|
||||
options
|
||||
);
|
||||
await ensureTypstReady(
|
||||
resolvedOptions.fontUrls ?? [...DEFAULT_TYPST_FONT_URLS],
|
||||
wasmModuleUrls
|
||||
);
|
||||
const svg = await $typst.svg({
|
||||
mainContent: code,
|
||||
});
|
||||
return { svg };
|
||||
}
|
||||
20
packages/frontend/core/src/modules/typst/renderer/types.ts
Normal file
20
packages/frontend/core/src/modules/typst/renderer/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { OpSchema } from '@toeverything/infra/op';
|
||||
|
||||
export type TypstRenderOptions = {
|
||||
fontUrls?: string[];
|
||||
theme?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
export type TypstRenderRequest = {
|
||||
code: string;
|
||||
options?: TypstRenderOptions;
|
||||
};
|
||||
|
||||
export type TypstRenderResult = {
|
||||
svg: string;
|
||||
};
|
||||
|
||||
export interface TypstOps extends OpSchema {
|
||||
init: [TypstRenderOptions | undefined, { ok: true }];
|
||||
render: [TypstRenderRequest, TypstRenderResult];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MessageCommunicapable } from '@toeverything/infra/op';
|
||||
import { OpConsumer } from '@toeverything/infra/op';
|
||||
|
||||
import {
|
||||
DEFAULT_TYPST_RENDER_OPTIONS,
|
||||
ensureTypstReady,
|
||||
mergeTypstRenderOptions,
|
||||
renderTypstSvgWithOptions,
|
||||
} from './runtime';
|
||||
import type { TypstOps, TypstRenderOptions, TypstRenderRequest } from './types';
|
||||
|
||||
class TypstRendererBackend extends OpConsumer<TypstOps> {
|
||||
private options: TypstRenderOptions = DEFAULT_TYPST_RENDER_OPTIONS;
|
||||
|
||||
constructor(port: MessageCommunicapable) {
|
||||
super(port);
|
||||
this.register('init', this.init.bind(this));
|
||||
this.register('render', this.render.bind(this));
|
||||
}
|
||||
|
||||
async init(options?: TypstRenderOptions) {
|
||||
this.options = mergeTypstRenderOptions(
|
||||
DEFAULT_TYPST_RENDER_OPTIONS,
|
||||
options
|
||||
);
|
||||
await ensureTypstReady(this.options.fontUrls ?? []);
|
||||
return { ok: true } as const;
|
||||
}
|
||||
|
||||
async render({ code, options }: TypstRenderRequest) {
|
||||
const mergedOptions = mergeTypstRenderOptions(this.options, options);
|
||||
return renderTypstSvgWithOptions(code, mergedOptions);
|
||||
}
|
||||
}
|
||||
|
||||
new TypstRendererBackend(self as MessageCommunicapable);
|
||||
@@ -25,6 +25,17 @@ sqlx = { workspace = true, default-features = false, features = [
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
mermaid-rs-renderer = { git = "https://github.com/toeverything/mermaid-rs-renderer", rev = "887bd78", default-features = false }
|
||||
typst = "0.14.2"
|
||||
typst-as-lib = { version = "0.15.4", default-features = false, features = [
|
||||
"packages",
|
||||
"typst-kit-embed-fonts",
|
||||
"typst-kit-fonts",
|
||||
"ureq",
|
||||
] }
|
||||
typst-svg = "0.14.2"
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
mimalloc = { workspace = true }
|
||||
|
||||
|
||||
43
packages/frontend/native/index.d.ts
vendored
43
packages/frontend/native/index.d.ts
vendored
@@ -40,8 +40,51 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u
|
||||
|
||||
/** Decode audio file into a Float32Array */
|
||||
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
|
||||
export interface MermaidRenderOptions {
|
||||
fastText?: boolean
|
||||
svgOnly?: boolean
|
||||
textMetrics?: MermaidTextMetrics
|
||||
theme?: string
|
||||
fontFamily?: string
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
export interface MermaidRenderRequest {
|
||||
code: string
|
||||
options?: MermaidRenderOptions
|
||||
}
|
||||
|
||||
export interface MermaidRenderResult {
|
||||
svg: string
|
||||
}
|
||||
|
||||
export interface MermaidTextMetrics {
|
||||
ascii: number
|
||||
cjk: number
|
||||
space: number
|
||||
}
|
||||
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export declare function renderMermaidSvg(request: MermaidRenderRequest): MermaidRenderResult
|
||||
|
||||
export declare function renderTypstSvg(request: TypstRenderRequest): TypstRenderResult
|
||||
|
||||
export interface TypstRenderOptions {
|
||||
fontUrls?: Array<string>
|
||||
theme?: string
|
||||
fontDirs?: Array<string>
|
||||
}
|
||||
|
||||
export interface TypstRenderRequest {
|
||||
code: string
|
||||
options?: TypstRenderOptions
|
||||
}
|
||||
|
||||
export interface TypstRenderResult {
|
||||
svg: string
|
||||
}
|
||||
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
export declare class DocStorage {
|
||||
constructor(path: string)
|
||||
|
||||
@@ -77,8 +77,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-android-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-android-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -93,8 +93,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-android-arm-eabi')
|
||||
const bindingPackageVersion = require('@affine/native-android-arm-eabi/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -114,8 +114,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-x64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-win32-x64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -130,8 +130,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-x64-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-x64-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -147,8 +147,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-ia32-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-ia32-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -163,8 +163,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-arm64-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-arm64-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -182,8 +182,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-universal')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-universal/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -198,8 +198,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-x64')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -214,8 +214,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -234,8 +234,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-freebsd-x64')
|
||||
const bindingPackageVersion = require('@affine/native-freebsd-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -250,8 +250,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-freebsd-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-freebsd-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -271,8 +271,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-x64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-x64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -287,8 +287,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-x64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-x64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -305,8 +305,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -321,8 +321,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -339,8 +339,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm-musleabihf')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm-musleabihf/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -355,8 +355,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm-gnueabihf')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm-gnueabihf/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -373,8 +373,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-loong64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-loong64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -389,8 +389,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-loong64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-loong64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -407,8 +407,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-riscv64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-riscv64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -423,8 +423,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-riscv64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-riscv64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -440,8 +440,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-ppc64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-ppc64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -456,8 +456,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-s390x-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-s390x-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -476,8 +476,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -492,8 +492,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-x64')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -508,8 +508,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-arm')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-arm/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -580,6 +580,8 @@ module.exports.ShareableContent = nativeBinding.ShareableContent
|
||||
module.exports.decodeAudio = nativeBinding.decodeAudio
|
||||
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
|
||||
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
|
||||
module.exports.renderMermaidSvg = nativeBinding.renderMermaidSvg
|
||||
module.exports.renderTypstSvg = nativeBinding.renderTypstSvg
|
||||
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
|
||||
module.exports.DocStorage = nativeBinding.DocStorage
|
||||
module.exports.DocStoragePool = nativeBinding.DocStoragePool
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod hashcash;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub mod preview;
|
||||
|
||||
#[cfg(not(target_arch = "arm"))]
|
||||
#[global_allocator]
|
||||
|
||||
174
packages/frontend/native/src/preview.rs
Normal file
174
packages/frontend/native/src/preview.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mermaid_rs_renderer::RenderOptions;
|
||||
use napi::{Error, Result};
|
||||
use napi_derive::napi;
|
||||
use typst::layout::{Abs, PagedDocument};
|
||||
use typst_as_lib::{TypstEngine, typst_kit_options::TypstKitFontOptions};
|
||||
|
||||
#[napi(object)]
|
||||
pub struct MermaidTextMetrics {
|
||||
pub ascii: f64,
|
||||
pub cjk: f64,
|
||||
pub space: f64,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct MermaidRenderOptions {
|
||||
pub fast_text: Option<bool>,
|
||||
pub svg_only: Option<bool>,
|
||||
pub text_metrics: Option<MermaidTextMetrics>,
|
||||
pub theme: Option<String>,
|
||||
pub font_family: Option<String>,
|
||||
pub font_size: Option<f64>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct MermaidRenderRequest {
|
||||
pub code: String,
|
||||
pub options: Option<MermaidRenderOptions>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct MermaidRenderResult {
|
||||
pub svg: String,
|
||||
}
|
||||
|
||||
fn resolve_mermaid_render_options(options: Option<MermaidRenderOptions>) -> RenderOptions {
|
||||
let mut render_options = match options.as_ref().and_then(|options| options.theme.as_deref()) {
|
||||
Some("default") => RenderOptions::mermaid_default(),
|
||||
_ => RenderOptions::modern(),
|
||||
};
|
||||
|
||||
if let Some(options) = options {
|
||||
if let Some(font_family) = options.font_family {
|
||||
render_options.theme.font_family = font_family;
|
||||
}
|
||||
|
||||
if let Some(font_size) = options.font_size {
|
||||
render_options.theme.font_size = font_size as f32;
|
||||
}
|
||||
}
|
||||
|
||||
render_options
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result<MermaidRenderResult> {
|
||||
let render_options = resolve_mermaid_render_options(request.options);
|
||||
let svg = mermaid_rs_renderer::render_with_options(&request.code, render_options)
|
||||
.map_err(|error| Error::from_reason(error.to_string()))?;
|
||||
|
||||
Ok(MermaidRenderResult { svg })
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct TypstRenderOptions {
|
||||
pub font_urls: Option<Vec<String>>,
|
||||
pub theme: Option<String>,
|
||||
pub font_dirs: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct TypstRenderRequest {
|
||||
pub code: String,
|
||||
pub options: Option<TypstRenderOptions>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct TypstRenderResult {
|
||||
pub svg: String,
|
||||
}
|
||||
|
||||
fn resolve_typst_font_dirs(options: &Option<TypstRenderOptions>) -> Vec<PathBuf> {
|
||||
options
|
||||
.as_ref()
|
||||
.and_then(|options| options.font_dirs.as_ref())
|
||||
.map(|dirs| dirs.iter().map(PathBuf::from).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_typst_svg(svg: String) -> String {
|
||||
let mut svg = svg;
|
||||
let page_background_marker = r##"<path class="typst-shape""##;
|
||||
let mut cursor = 0;
|
||||
|
||||
while let Some(relative_idx) = svg[cursor..].find(page_background_marker) {
|
||||
let idx = cursor + relative_idx;
|
||||
let rest = &svg[idx..];
|
||||
let Some(relative_end) = rest.find("/>") else {
|
||||
break;
|
||||
};
|
||||
|
||||
let end = idx + relative_end + 2;
|
||||
let path_fragment = &svg[idx..end];
|
||||
let is_page_background_path =
|
||||
path_fragment.contains(r#"d="M 0 0v "#) && path_fragment.contains(r#" h "#) && path_fragment.contains(r#" v -"#);
|
||||
|
||||
if is_page_background_path {
|
||||
svg.replace_range(idx..end, "");
|
||||
cursor = idx;
|
||||
continue;
|
||||
}
|
||||
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
svg
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn render_typst_svg(request: TypstRenderRequest) -> Result<TypstRenderResult> {
|
||||
let font_dirs = resolve_typst_font_dirs(&request.options);
|
||||
let search_options = TypstKitFontOptions::new()
|
||||
.include_system_fonts(false)
|
||||
.include_embedded_fonts(true)
|
||||
.include_dirs(font_dirs);
|
||||
|
||||
let engine = TypstEngine::builder()
|
||||
.main_file(request.code)
|
||||
.search_fonts_with(search_options)
|
||||
.with_package_file_resolver()
|
||||
.build();
|
||||
|
||||
let document = engine
|
||||
.compile::<PagedDocument>()
|
||||
.output
|
||||
.map_err(|error| Error::from_reason(error.to_string()))?;
|
||||
|
||||
let svg = normalize_typst_svg(typst_svg::svg_merged(&document, Abs::pt(0.0)));
|
||||
Ok(TypstRenderResult { svg })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_typst_svg;
|
||||
|
||||
#[test]
|
||||
fn normalize_typst_svg_removes_all_backgrounds() {
|
||||
let input = r##"<svg>
|
||||
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
|
||||
<g></g>
|
||||
<path class="typst-shape" fill="#ffffff" fill-rule="nonzero" d="M 0 0v 10 h 10 v -10 Z "/>
|
||||
<g transform="matrix(1 0 0 1 0 10)"></g>
|
||||
</svg>"##
|
||||
.to_string();
|
||||
|
||||
let normalized = normalize_typst_svg(input);
|
||||
let retained = normalized
|
||||
.matches(r##"<path class="typst-shape" fill="#ffffff" fill-rule="nonzero""##)
|
||||
.count();
|
||||
assert_eq!(retained, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_typst_svg_keeps_non_background_paths() {
|
||||
let input = r##"<svg>
|
||||
<path class="typst-shape" fill="#000000" fill-rule="nonzero" d="M 1 2 L 3 4 Z "/>
|
||||
</svg>"##
|
||||
.to_string();
|
||||
|
||||
let normalized = normalize_typst_svg(input);
|
||||
assert!(normalized.contains(r##"d="M 1 2 L 3 4 Z ""##));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
"test": "yarn playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/integration-test": "workspace:*",
|
||||
"@playwright/test": "=1.52.0",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"include": ["e2e"],
|
||||
"references": [
|
||||
{ "path": "../kit" },
|
||||
{ "path": "../../blocksuite/affine/all" },
|
||||
{ "path": "../../blocksuite/integration-test" }
|
||||
]
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
|
||||
type WorkerConfig = { name: string };
|
||||
type CreateWorkerTargetConfig = (pkg: Package, entry: string) => WorkerConfig;
|
||||
type BaseWorkerOptions = {
|
||||
includeMermaidAndTypst?: boolean;
|
||||
};
|
||||
|
||||
function assertRspackSupportedPackage(pkg: Package) {
|
||||
assertRspackSupportedPackageName(pkg.name);
|
||||
@@ -60,11 +63,13 @@ async function uploadAssetsForPackage(pkg: Package, logger: Logger) {
|
||||
|
||||
function getBaseWorkerConfigs(
|
||||
pkg: Package,
|
||||
createWorkerTargetConfig: CreateWorkerTargetConfig
|
||||
createWorkerTargetConfig: CreateWorkerTargetConfig,
|
||||
options: BaseWorkerOptions = {}
|
||||
) {
|
||||
const core = new Package('@affine/core');
|
||||
const includeMermaidAndTypst = options.includeMermaidAndTypst ?? true;
|
||||
|
||||
return [
|
||||
const workerConfigs = [
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join(
|
||||
@@ -82,6 +87,21 @@ function getBaseWorkerConfigs(
|
||||
).value
|
||||
),
|
||||
];
|
||||
|
||||
if (includeMermaidAndTypst) {
|
||||
workerConfigs.push(
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join('modules/mermaid/renderer/mermaid.worker.ts').value
|
||||
),
|
||||
createWorkerTargetConfig(
|
||||
pkg,
|
||||
core.srcPath.join('modules/typst/renderer/typst.worker.ts').value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return workerConfigs;
|
||||
}
|
||||
|
||||
function getWebpackBundleConfigs(pkg: Package): webpack.MultiConfiguration {
|
||||
@@ -123,7 +143,8 @@ function getWebpackBundleConfigs(pkg: Package): webpack.MultiConfiguration {
|
||||
case '@affine/electron-renderer': {
|
||||
const workerConfigs = getBaseWorkerConfigs(
|
||||
pkg,
|
||||
createWebpackWorkerTargetConfig
|
||||
createWebpackWorkerTargetConfig,
|
||||
{ includeMermaidAndTypst: false }
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -195,7 +216,8 @@ function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
|
||||
case '@affine/electron-renderer': {
|
||||
const workerConfigs = getBaseWorkerConfigs(
|
||||
pkg,
|
||||
createRspackWorkerTargetConfig
|
||||
createRspackWorkerTargetConfig,
|
||||
{ includeMermaidAndTypst: false }
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
@@ -1444,7 +1444,6 @@ export const PackageList = [
|
||||
location: 'tests/blocksuite',
|
||||
name: '@affine-test/blocksuite',
|
||||
workspaceDependencies: [
|
||||
'tests/kit',
|
||||
'blocksuite/affine/all',
|
||||
'blocksuite/integration-test',
|
||||
],
|
||||
|
||||
@@ -46,6 +46,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
workspace: [
|
||||
'.',
|
||||
'./packages/frontend/apps/electron',
|
||||
'./blocksuite/**/*/vitest.config.ts',
|
||||
],
|
||||
setupFiles: [
|
||||
resolve(rootDir, './scripts/setup/polyfill.ts'),
|
||||
resolve(rootDir, './scripts/setup/lit.ts'),
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineWorkspace } from 'vitest/config';
|
||||
|
||||
export default defineWorkspace([
|
||||
'.',
|
||||
'./packages/frontend/apps/electron',
|
||||
'./blocksuite/**/*/vitest.config.ts',
|
||||
]);
|
||||
Reference in New Issue
Block a user