Compare commits

...

18 Commits

Author SHA1 Message Date
Yifeng Wang
f723d41bd8 chore: test result 2025-05-24 17:41:47 +08:00
Yifeng Wang
cd753dcd83 chore: test 2025-05-24 17:32:17 +08:00
Yifeng Wang
f1608d4298 fix: review 2025-05-24 13:54:00 +08:00
Yifeng Wang
a4dd931b71 fix: test 2025-05-24 13:46:00 +08:00
Yifeng Wang
ddc9cb7a3d feat(editor): support triangle and diamond shape in shape dom renderer 2025-05-24 13:46:00 +08:00
renovate
0f19a506ac chore: bump up file-type version to v21 (#12500)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [file-type](https://redirect.github.com/sindresorhus/file-type) | [`^20.0.0` -> `^21.0.0`](https://renovatebot.com/diffs/npm/file-type/20.5.0/21.0.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/file-type/21.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/file-type/21.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/file-type/20.5.0/21.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/file-type/20.5.0/21.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>sindresorhus/file-type (file-type)</summary>

### [`v21.0.0`](https://redirect.github.com/sindresorhus/file-type/releases/tag/v21.0.0)

[Compare Source](https://redirect.github.com/sindresorhus/file-type/compare/v20.5.0...v21.0.0)

##### Breaking

-   Require Node.js 20  [`24aec1f`](https://redirect.github.com/sindresorhus/file-type/commit/24aec1f)
-   Drop Adobe Illustrator (.ai) detection support ([#&#8203;743](https://redirect.github.com/sindresorhus/file-type/issues/743))  [`af169f3`](https://redirect.github.com/sindresorhus/file-type/commit/af169f3)
-   Correct Matroska (video) MIME-type to formal IANA registration ([#&#8203;753](https://redirect.github.com/sindresorhus/file-type/issues/753))  [`f53f5ff`](https://redirect.github.com/sindresorhus/file-type/commit/f53f5ff)
-   Correct FLAC MIME-type to formal IANA registration ([#&#8203;755](https://redirect.github.com/sindresorhus/file-type/issues/755))  [`b9fda36`](https://redirect.github.com/sindresorhus/file-type/commit/b9fda36)
-   Correct Apache Parquet MIME-type to formal IANA registration ([#&#8203;748](https://redirect.github.com/sindresorhus/file-type/issues/748))  [`98e3f8e`](https://redirect.github.com/sindresorhus/file-type/commit/98e3f8e)
-   Correct Apache Arrow MIME-type to formal IANA registration ([#&#8203;754](https://redirect.github.com/sindresorhus/file-type/issues/754))  [`7184775`](https://redirect.github.com/sindresorhus/file-type/commit/7184775)

##### Improvements

-   Allow options to be directly passed to exported functions ([#&#8203;752](https://redirect.github.com/sindresorhus/file-type/issues/752))  [`d264029`](https://redirect.github.com/sindresorhus/file-type/commit/d264029)
-   Add `mpegOffsetTolerance` option ([#&#8203;646](https://redirect.github.com/sindresorhus/file-type/issues/646))  [`c40840a`](https://redirect.github.com/sindresorhus/file-type/commit/c40840a)

##### Fixes

-   Fix detection of some PAX TAR formats ([#&#8203;762](https://redirect.github.com/sindresorhus/file-type/issues/762))  [`574d0d6`](https://redirect.github.com/sindresorhus/file-type/commit/574d0d6)

***

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC4xNi4wIiwidXBkYXRlZEluVmVyIjoiNDAuMTYuMCIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2025-05-24 02:55:26 +00:00
L-Sun
7223d35c89 fix(editor): shape tool should be the last used shape (#12425)
Close [BS-3305](https://linear.app/affine-design/issue/BS-3305/白板按s应该使用上次的shape形状)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added a method to cycle through shape types when using the shape tool.

- **Bug Fixes**
  - Improved shape tool behavior to ensure the selected shape type does not change unexpectedly after adding a new shape.

- **Tests**
  - Added an end-to-end test to verify that the shape tool retains the selected shape type after adding a new shape.
  - Enhanced shortcut tests to verify cycling shapes forward and backward using 's' and 'Shift+s' keys.

- **Refactor**
  - Streamlined the logic for cycling through shape types and connector modes for improved maintainability.
  - Removed external utility for cycling shapes, integrating the functionality directly into the shape tool.
  - Updated keyboard shortcut handling to use the new cycling method within the shape tool.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 16:31:02 +00:00
zzj3720
3f753eddf5 fix(editor): simple table will disappear when converted from a note to a linked doc (#12482)
fix: https://github.com/toeverything/AFFiNE/issues/12403

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Bug Fixes**
	- Improved handling of certain table cell content to prevent potential display or behavior issues when content is missing.
	- Updated language completeness data for Spanish (Argentina) locale.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 16:16:35 +00:00
donteatfriedrice
a828c74f87 feat(editor): add experimental feature adapter panel to AFFiNE canary (#12489)
Closes: [BS-2539](https://linear.app/affine-design/issue/BS-2539/为-affine-添加-ef,并且支持在-affine-预览对应的功能)

> [!warning]
> This feature is only available in the canary build and is intended for debugging purposes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced an "Adapter Panel" feature with a new sidebar tab for previewing document content in multiple formats (Markdown, PlainText, HTML, Snapshot), controllable via a feature flag.
  - Added a fully integrated adapter panel component with reactive UI elements for selecting adapters, toggling HTML preview modes, and updating content.
  - Provided a customizable adapter panel for both main app and playground environments, supporting content transformation pipelines and export previews.
  - Enabled seamless toggling and live updating of adapter panel content through intuitive menus and controls.

- **Localization**
  - Added English translations and descriptive settings for the Adapter Panel feature.

- **Chores**
  - Added new package and workspace dependencies along with TypeScript project references to support the Adapter Panel modules and components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 14:08:12 +00:00
darkskygit
2a80fbb993 feat(server): workspace embedding improve (#12022)
fix AI-10
fix AI-109
fix PD-2484

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added a method to check if a document requires embedding, improving embedding efficiency.
  - Enhanced document embeddings with enriched metadata, including title, summary, creation/update dates, and author information.
  - Introduced a new type for document fragments with extended metadata fields.

- **Improvements**
  - Embedding logic now conditionally processes only documents needing updates.
  - Embedding content now includes document metadata for more informative context.
  - Expanded and improved test coverage for embedding scenarios and workspace behaviors.
  - Event emission added for workspace embedding updates on client version mismatch.
  - Job queueing enhanced with prioritization and explicit job IDs for better management.
  - Job queue calls updated to include priority and context identifiers in a structured format.

- **Bug Fixes**
  - Improved handling of ignored documents in embedding matches.
  - Fixed incorrect document ID assignment in embedding job queueing.

- **Tests**
  - Added and updated snapshot and behavioral tests for embedding and workspace document handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 10:16:15 +00:00
CatsJuice
262f1a47a4 feat(core): track for calendar integration (#12378) 2025-05-23 10:01:14 +00:00
CatsJuice
fe99e51d5b chore(core): replace integration icon color (#12365)
chore(core): replace integration icon color

feat(core): track for calendar integration

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Style**
  - Updated icon color in workspace integration card for improved visual consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 10:01:14 +00:00
fengmk2
a7e6c511a9 chore(server): manticore v9.3.2 (#12475) 2025-05-23 09:45:31 +00:00
fengmk2
bd72c931c4 chore(server): disable indexer on self-host by default (#12452)
enable indexer using `compose.indexer.yml` on self-host:

```bash
docker compose -f compose.yml -f compose.indexer.yml up
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Chores**
  - Updated the build process in the development container to include building the reader package.
  - Added and configured a Manticore Search indexer service in the development container.
  - Removed the indexer service and related environment variables from the self-hosted Docker Compose setup and environment example file.

- **Documentation**
  - Improved documentation formatting for better readability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 09:45:31 +00:00
fundon
cb88156188 fix(editor): update shared deps (#12479)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Chores**
  - Updated dependency management to include "@types/bytes" in the main dependencies.
- **Localization**
  - Slight adjustment to Spanish (Argentina) translation completeness.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 09:30:32 +00:00
fundon
952650e1bc refactor(nbstore): improve HTTP connection timeout handling (#11985)
Closes: [BS-3344](https://linear.app/affine-design/issue/BS-3344/改进请求超时提示)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Refactor**
  - Improved internal request timeout handling for cloud operations, resulting in more robust and reliable network requests. No changes to user-facing features or functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 09:13:59 +00:00
EYHN
797646442d feat(core): add number property filter and group by (#12483)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Enhanced filtering options for number properties with multiple comparison operators and empty/not empty checks.
  - Added support for grouping and ordering by number properties.
  - Introduced new UI components for number property filtering and group headers.
  - Improved draft completion behavior for text and tags filters using empty/not empty methods.

- **Bug Fixes**
  - Improved consistency in draft completion behavior when filtering by text or tags using empty/not empty methods.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 08:54:22 +00:00
fengmk2
4b9313ce37 chore(i18n): ignore i18n-completenesses.json (#12484)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Chores**
  - Updated workflow to revert changes to a specific i18n file after running the i18n build step during automated checks. No impact on user-facing features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-23 08:38:43 +00:00
84 changed files with 2365 additions and 546 deletions

View File

@@ -6,6 +6,7 @@ yarn install
# Build Server Dependencies
yarn affine @affine/server-native build
yarn affine @affine/reader build
# Create database
yarn affine @affine/server prisma migrate reset -f

View File

@@ -10,6 +10,7 @@ services:
environment:
DATABASE_URL: postgresql://affine:affine@db:5432/affine
REDIS_SERVER_HOST: redis
AFFINE_INDEXER_SEARCH_ENDPOINT: http://indexer:9308
db:
image: pgvector/pgvector:pg16
@@ -23,5 +24,19 @@ services:
redis:
image: redis
indexer:
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.3.2}
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
memlock:
soft: -1
hard: -1
volumes:
- manticoresearch_data:/var/lib/manticore
volumes:
postgres-data:
manticoresearch_data:

View File

@@ -12,4 +12,4 @@ DB_DATABASE_NAME=affine
# ELASTIC_PLATFORM=linux/arm64
# manticoresearch
MANTICORE_VERSION=9.2.14
MANTICORE_VERSION=9.3.2

View File

@@ -26,7 +26,7 @@ services:
# https://manual.manticoresearch.com/Starting_the_server/Docker
manticoresearch:
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.2.14}
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.3.2}
ports:
- 9308:9308
ulimits:

View File

@@ -21,8 +21,3 @@ CONFIG_LOCATION=~/.affine/self-host/config
DB_USERNAME=affine
DB_PASSWORD=
DB_DATABASE=affine
# indexer search provider manticoresearch version
MANTICORE_VERSION=9.2.14
# position of the manticoresearch data to persist
MANTICORE_DATA_LOCATION=~/.affine/self-host/manticore

View File

@@ -10,8 +10,6 @@ services:
condition: service_healthy
postgres:
condition: service_healthy
indexer:
condition: service_healthy
affine_migration:
condition: service_completed_successfully
volumes:
@@ -23,7 +21,7 @@ services:
environment:
- REDIS_SERVER_HOST=redis
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@postgres:5432/${DB_DATABASE:-affine}
- AFFINE_INDEXER_SEARCH_ENDPOINT=http://indexer:9308
- AFFINE_INDEXER_ENABLED=false
restart: unless-stopped
affine_migration:
@@ -39,14 +37,12 @@ services:
environment:
- REDIS_SERVER_HOST=redis
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@postgres:5432/${DB_DATABASE:-affine}
- AFFINE_INDEXER_SEARCH_ENDPOINT=http://indexer:9308
- AFFINE_INDEXER_ENABLED=false
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
indexer:
condition: service_healthy
redis:
image: redis
@@ -78,24 +74,3 @@ services:
timeout: 5s
retries: 5
restart: unless-stopped
indexer:
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.2.14}
container_name: affine_indexer
volumes:
- ${MANTICORE_DATA_LOCATION}:/var/lib/manticore
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
memlock:
soft: -1
hard: -1
healthcheck:
test:
['CMD', 'wget', '-O-', 'http://127.0.0.1:9308']
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped

View File

@@ -125,6 +125,7 @@ jobs:
- name: Run BS Docs Build
run: |
yarn affine bs-docs build
git checkout packages/frontend/i18n/src/i18n-completenesses.json
git status --porcelain | grep . && {
echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted"
exit 1
@@ -183,6 +184,7 @@ jobs:
yarn affine gql build
yarn affine i18n build
yarn affine server genconfig
git checkout packages/frontend/i18n/src/i18n-completenesses.json
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
@@ -583,7 +585,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -733,7 +735,7 @@ jobs:
ports:
- 6379:6379
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -957,7 +959,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -1056,7 +1058,7 @@ jobs:
ports:
- 6379:6379
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -1183,7 +1185,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:

View File

@@ -60,7 +60,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -135,7 +135,7 @@ jobs:
ports:
- 6379:6379
indexer:
image: manticoresearch/manticore:9.2.14
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:

View File

@@ -33,6 +33,7 @@
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-foundation": "workspace:*",
"@blocksuite/affine-fragment-adapter-panel": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",
"@blocksuite/affine-fragment-frame-panel": "workspace:*",
"@blocksuite/affine-fragment-outline": "workspace:*",
@@ -209,6 +210,8 @@
"./fragments/frame-panel/view": "./src/fragments/frame-panel/view.ts",
"./fragments/outline": "./src/fragments/outline/index.ts",
"./fragments/outline/view": "./src/fragments/outline/view.ts",
"./fragments/adapter-panel": "./src/fragments/adapter-panel/index.ts",
"./fragments/adapter-panel/view": "./src/fragments/adapter-panel/view.ts",
"./gfx/text": "./src/gfx/text/index.ts",
"./gfx/text/store": "./src/gfx/text/store.ts",
"./gfx/text/view": "./src/gfx/text/view.ts",

View File

@@ -19,6 +19,7 @@ import { SurfaceViewExtension } from '@blocksuite/affine-block-surface/view';
import { SurfaceRefViewExtension } from '@blocksuite/affine-block-surface-ref/view';
import { TableViewExtension } from '@blocksuite/affine-block-table/view';
import { FoundationViewExtension } from '@blocksuite/affine-foundation/view';
import { AdapterPanelViewExtension } from '@blocksuite/affine-fragment-adapter-panel/view';
import { DocTitleViewExtension } from '@blocksuite/affine-fragment-doc-title/view';
import { FramePanelViewExtension } from '@blocksuite/affine-fragment-frame-panel/view';
import { OutlineViewExtension } from '@blocksuite/affine-fragment-outline/view';
@@ -124,5 +125,6 @@ export function getInternalViewExtensions() {
DocTitleViewExtension,
FramePanelViewExtension,
OutlineViewExtension,
AdapterPanelViewExtension,
];
}

View File

@@ -0,0 +1 @@
export * from '@blocksuite/affine-fragment-adapter-panel';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/affine-fragment-adapter-panel/view';

View File

@@ -30,6 +30,7 @@
{ "path": "../components" },
{ "path": "../ext-loader" },
{ "path": "../foundation" },
{ "path": "../fragments/adapter-panel" },
{ "path": "../fragments/doc-title" },
{ "path": "../fragments/frame-panel" },
{ "path": "../fragments/outline" },

View File

@@ -24,7 +24,7 @@
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.14",
"file-type": "^20.0.0",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",

View File

@@ -26,7 +26,7 @@
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.14",
"file-type": "^20.0.0",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",

View File

@@ -66,7 +66,6 @@ import {
DEFAULT_NOTE_TIP,
} from './utils/consts.js';
import { deleteElements } from './utils/crud.js';
import { getNextShapeType } from './utils/hotkey-utils.js';
import { isCanvasElement } from './utils/query.js';
export class EdgelessPageKeyboardManager extends PageKeyboardManager {
@@ -216,10 +215,8 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
) {
return;
}
const { shapeName } = controller.activatedOption;
const nextShapeName = getNextShapeType(shapeName);
this._setEdgelessTool(ShapeTool, {
shapeName: nextShapeName,
shapeName: controller.cycleShapeName('prev'),
});
controller.createOverlay();

View File

@@ -1,15 +0,0 @@
import type { ShapeToolOption } from '@blocksuite/affine-gfx-shape';
import { ShapeType } from '@blocksuite/affine-model';
const shapeMap: Record<ShapeToolOption['shapeName'], number> = {
[ShapeType.Rect]: 0,
[ShapeType.Ellipse]: 1,
[ShapeType.Diamond]: 2,
[ShapeType.Triangle]: 3,
roundedRect: 4,
};
const shapes = Object.keys(shapeMap) as ShapeToolOption['shapeName'][];
export function getNextShapeType(cur: ShapeToolOption['shapeName']) {
return shapes[(shapeMap[cur] + 1) % shapes.length];
}

View File

@@ -0,0 +1,3 @@
import { ConnectorDomRendererExtension } from '../renderer/dom-elements/index.js';
export { ConnectorDomRendererExtension };

View File

@@ -1,4 +1,5 @@
export * from './clipboard-config';
export * from './connector-dom-renderer';
export * from './crud-extension';
export * from './dom-element-renderer';
export * from './edit-props-middleware-builder';

View File

@@ -0,0 +1,398 @@
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { ConnectorMode, DefaultTheme } from '@blocksuite/affine-model';
import {
getBezierParameters,
type PointLocation,
} from '@blocksuite/global/gfx';
import { DomElementRendererExtension } from '../../extensions/dom-element-renderer.js';
import type { DomRenderer } from '../dom-renderer.js';
import type { DomElementRenderer } from './index.js';
/**
* DOM renderer for connector elements.
* Uses SVG to render connector paths, endpoints, and labels.
*/
export const connectorDomRenderer: DomElementRenderer<ConnectorElementModel> = (
elementModel,
domElement,
renderer
) => {
const {
mode,
path: points,
strokeStyle,
frontEndpointStyle,
rearEndpointStyle,
strokeWidth,
stroke,
w,
h,
} = elementModel;
// Clear previous content
domElement.innerHTML = '';
// Points might not be built yet in some scenarios (undo/redo, copy/paste)
if (!points.length || points.length < 2) {
return;
}
// Create SVG element
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.width = `${w * renderer.viewport.zoom}px`;
svg.style.height = `${h * renderer.viewport.zoom}px`;
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.pointerEvents = 'none';
svg.style.overflow = 'visible';
const strokeColor = renderer.getColorValue(
stroke,
DefaultTheme.connectorColor,
true
);
// Render connector path
renderConnectorPath(
svg,
points,
mode,
strokeStyle,
strokeWidth,
strokeColor,
renderer.viewport.zoom
);
// Render endpoints
if (frontEndpointStyle && frontEndpointStyle !== 'None') {
renderEndpoint(
svg,
points,
frontEndpointStyle,
'front',
strokeWidth,
strokeColor,
mode,
renderer.viewport.zoom
);
}
if (rearEndpointStyle && rearEndpointStyle !== 'None') {
renderEndpoint(
svg,
points,
rearEndpointStyle,
'rear',
strokeWidth,
strokeColor,
mode,
renderer.viewport.zoom
);
}
// Render label if exists
if (elementModel.hasLabel()) {
renderConnectorLabel(elementModel, domElement, renderer);
}
domElement.appendChild(svg);
};
function renderConnectorPath(
svg: SVGSVGElement,
points: PointLocation[],
mode: ConnectorMode,
strokeStyle: string,
strokeWidth: number,
strokeColor: string,
zoom: number
) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
let pathData = '';
if (mode === ConnectorMode.Curve) {
// Bezier curve
const bezierParams = getBezierParameters(points);
const [p0, p1, p2, p3] = bezierParams;
pathData = `M ${p0[0]} ${p0[1]} C ${p1[0]} ${p1[1]} ${p2[0]} ${p2[1]} ${p3[0]} ${p3[1]}`;
} else {
// Straight or orthogonal lines
pathData = `M ${points[0][0]} ${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
pathData += ` L ${points[i][0]} ${points[i][1]}`;
}
}
path.setAttribute('d', pathData);
path.setAttribute('stroke', strokeColor);
path.setAttribute('stroke-width', (strokeWidth * zoom).toString());
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
if (strokeStyle === 'dash') {
const dashArray = `${12 * zoom},${12 * zoom}`;
path.setAttribute('stroke-dasharray', dashArray);
}
svg.appendChild(path);
}
function renderEndpoint(
svg: SVGSVGElement,
points: PointLocation[],
endpointStyle: string,
position: 'front' | 'rear',
strokeWidth: number,
strokeColor: string,
mode: ConnectorMode,
zoom: number
) {
const pointIndex = position === 'rear' ? points.length - 1 : 0;
const point = points[pointIndex];
const size = 15 * (strokeWidth / 2) * zoom;
// Calculate tangent direction for endpoint orientation
let tangent: [number, number];
if (mode === ConnectorMode.Curve) {
const bezierParams = getBezierParameters(points);
// For curve mode, use bezier tangent
if (position === 'rear') {
const lastIdx = points.length - 1;
const prevPoint = points[lastIdx - 1];
tangent = [point[0] - prevPoint[0], point[1] - prevPoint[1]];
} else {
const nextPoint = points[1];
tangent = [nextPoint[0] - point[0], nextPoint[1] - point[1]];
}
} else {
// For straight/orthogonal mode
if (position === 'rear') {
const prevPoint = points[points.length - 2];
tangent = [point[0] - prevPoint[0], point[1] - prevPoint[1]];
} else {
const nextPoint = points[1];
tangent = [nextPoint[0] - point[0], nextPoint[1] - point[1]];
}
}
// Normalize tangent
const length = Math.sqrt(tangent[0] * tangent[0] + tangent[1] * tangent[1]);
if (length > 0) {
tangent[0] /= length;
tangent[1] /= length;
}
// Adjust tangent direction for front endpoint
if (position === 'front') {
tangent[0] = -tangent[0];
tangent[1] = -tangent[1];
}
switch (endpointStyle) {
case 'Arrow':
renderArrowEndpoint(svg, point, tangent, size, strokeColor, zoom);
break;
case 'Triangle':
renderTriangleEndpoint(svg, point, tangent, size, strokeColor, zoom);
break;
case 'Circle':
renderCircleEndpoint(svg, point, tangent, size, strokeColor, zoom);
break;
case 'Diamond':
renderDiamondEndpoint(svg, point, tangent, size, strokeColor, zoom);
break;
}
}
function renderArrowEndpoint(
svg: SVGSVGElement,
point: PointLocation,
tangent: [number, number],
size: number,
color: string,
zoom: number
) {
const angle = Math.PI / 4; // 45 degrees
const arrowPath = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
// Calculate arrow points
const cos1 = Math.cos(angle);
const sin1 = Math.sin(angle);
const cos2 = Math.cos(-angle);
const sin2 = Math.sin(-angle);
const x1 = point[0] + size * (tangent[0] * cos1 - tangent[1] * sin1);
const y1 = point[1] + size * (tangent[0] * sin1 + tangent[1] * cos1);
const x2 = point[0] + size * (tangent[0] * cos2 - tangent[1] * sin2);
const y2 = point[1] + size * (tangent[0] * sin2 + tangent[1] * cos2);
const pathData = `M ${x1} ${y1} L ${point[0]} ${point[1]} L ${x2} ${y2}`;
arrowPath.setAttribute('d', pathData);
arrowPath.setAttribute('stroke', color);
arrowPath.setAttribute('stroke-width', (2 * zoom).toString());
arrowPath.setAttribute('fill', 'none');
arrowPath.setAttribute('stroke-linecap', 'round');
arrowPath.setAttribute('stroke-linejoin', 'round');
svg.appendChild(arrowPath);
}
function renderTriangleEndpoint(
svg: SVGSVGElement,
point: PointLocation,
tangent: [number, number],
size: number,
color: string,
zoom: number
) {
const triangle = document.createElementNS(
'http://www.w3.org/2000/svg',
'polygon'
);
const angle = Math.PI / 3; // 60 degrees
const cos1 = Math.cos(angle);
const sin1 = Math.sin(angle);
const cos2 = Math.cos(-angle);
const sin2 = Math.sin(-angle);
const x1 = point[0] + size * (tangent[0] * cos1 - tangent[1] * sin1);
const y1 = point[1] + size * (tangent[0] * sin1 + tangent[1] * cos1);
const x2 = point[0] + size * (tangent[0] * cos2 - tangent[1] * sin2);
const y2 = point[1] + size * (tangent[0] * sin2 + tangent[1] * cos2);
const points = `${point[0]},${point[1]} ${x1},${y1} ${x2},${y2}`;
triangle.setAttribute('points', points);
triangle.setAttribute('fill', color);
triangle.setAttribute('stroke', color);
triangle.setAttribute('stroke-width', (1 * zoom).toString());
svg.appendChild(triangle);
}
function renderCircleEndpoint(
svg: SVGSVGElement,
point: PointLocation,
tangent: [number, number],
size: number,
color: string,
zoom: number
) {
const circle = document.createElementNS(
'http://www.w3.org/2000/svg',
'circle'
);
const radius = size * 0.5;
const centerX = point[0] + radius * tangent[0];
const centerY = point[1] + radius * tangent[1];
circle.setAttribute('cx', centerX.toString());
circle.setAttribute('cy', centerY.toString());
circle.setAttribute('r', radius.toString());
circle.setAttribute('fill', color);
circle.setAttribute('stroke', color);
circle.setAttribute('stroke-width', (1 * zoom).toString());
svg.appendChild(circle);
}
function renderDiamondEndpoint(
svg: SVGSVGElement,
point: PointLocation,
tangent: [number, number],
size: number,
color: string,
zoom: number
) {
const diamond = document.createElementNS(
'http://www.w3.org/2000/svg',
'polygon'
);
// Calculate diamond points
const perpX = -tangent[1]; // Perpendicular to tangent
const perpY = tangent[0];
const halfSize = size * 0.5;
const x1 = point[0] + halfSize * tangent[0]; // Front point
const y1 = point[1] + halfSize * tangent[1];
const x2 = point[0] + halfSize * perpX; // Right point
const y2 = point[1] + halfSize * perpY;
const x3 = point[0] - halfSize * tangent[0]; // Back point
const y3 = point[1] - halfSize * tangent[1];
const x4 = point[0] - halfSize * perpX; // Left point
const y4 = point[1] - halfSize * perpY;
const points = `${x1},${y1} ${x2},${y2} ${x3},${y3} ${x4},${y4}`;
diamond.setAttribute('points', points);
diamond.setAttribute('fill', color);
diamond.setAttribute('stroke', color);
diamond.setAttribute('stroke-width', (1 * zoom).toString());
svg.appendChild(diamond);
}
function renderConnectorLabel(
elementModel: ConnectorElementModel,
domElement: HTMLElement,
renderer: DomRenderer
) {
if (!elementModel.text || !elementModel.labelXYWH) {
return;
}
const labelElement = document.createElement('div');
const [lx, ly, lw, lh] = elementModel.labelXYWH;
const { x, y } = elementModel;
// Position label relative to the connector
const relativeX = (lx - x) * renderer.viewport.zoom;
const relativeY = (ly - y) * renderer.viewport.zoom;
labelElement.style.position = 'absolute';
labelElement.style.left = `${relativeX}px`;
labelElement.style.top = `${relativeY}px`;
labelElement.style.width = `${lw * renderer.viewport.zoom}px`;
labelElement.style.height = `${lh * renderer.viewport.zoom}px`;
labelElement.style.pointerEvents = 'auto';
labelElement.style.display = 'flex';
labelElement.style.alignItems = 'center';
labelElement.style.justifyContent = 'center';
labelElement.style.backgroundColor = 'white';
labelElement.style.border = '1px solid #e0e0e0';
labelElement.style.borderRadius = '4px';
labelElement.style.padding = '2px 4px';
labelElement.style.fontSize = `${(elementModel.labelStyle?.fontSize || 16) * renderer.viewport.zoom}px`;
labelElement.style.fontFamily =
elementModel.labelStyle?.fontFamily || 'Inter';
labelElement.style.color = renderer.getColorValue(
elementModel.labelStyle?.color || DefaultTheme.black,
DefaultTheme.black,
true
);
labelElement.style.textAlign = elementModel.labelStyle?.textAlign || 'center';
labelElement.style.overflow = 'hidden';
labelElement.style.whiteSpace = 'nowrap';
labelElement.style.textOverflow = 'ellipsis';
// Set label text content
labelElement.textContent = elementModel.text.toString();
domElement.appendChild(labelElement);
}
// Export the extension
import { DomElementRendererExtension } from '../../extensions/dom-element-renderer.js';
export const ConnectorDomRendererExtension = DomElementRendererExtension(
'connector',
connectorDomRenderer
);

View File

@@ -29,3 +29,9 @@ export const DomElementRendererIdentifier = (type: string) =>
export type DomElementRenderer<
T extends SurfaceElementModel = SurfaceElementModel,
> = (elementModel: T, domElement: HTMLElement, renderer: DomRenderer) => void;
// Export the connector DOM renderer
export {
connectorDomRenderer,
ConnectorDomRendererExtension,
} from './connector.js';

View File

@@ -81,53 +81,6 @@ function getOpacity(elementModel: SurfaceElementModel) {
return { opacity: `${elementModel.opacity ?? 1}` };
}
/**
* @class DomRenderer
* Renders surface elements directly to the DOM using HTML elements and CSS.
*
* This renderer supports an extension mechanism to handle different types of surface elements.
* To add rendering support for a new element type (e.g., 'my-custom-element'), follow these steps:
*
* 1. **Define the Renderer Function**:
* Create a function that implements the rendering logic for your element.
* This function will receive the element's model, the target HTMLElement, and the DomRenderer instance.
* Signature: `(model: MyCustomElementModel, domElement: HTMLElement, renderer: DomRenderer) => void;`
* Example: `shapeDomRenderer` in `blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts`.
* In this function, you'll apply styles and attributes to the `domElement` based on the `model`.
*
* 2. **Create the Renderer Extension**:
* Create a new file (e.g., `my-custom-element-dom-renderer.extension.ts`).
* Import `DomElementRendererExtension` (e.g., from `@blocksuite/affine-block-surface` or its source location
* `blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts`).
* Import your renderer function (from step 1).
* Use the factory to create your extension:
* `export const MyCustomElementDomRendererExtension = DomElementRendererExtension('my-custom-element', myCustomElementRendererFn);`
* Example: `ShapeDomRendererExtension` in `blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts`.
*
* 3. **Register the Extension**:
* In your application setup where BlockSuite services and view extensions are registered (e.g., a `ViewExtensionProvider`
* or a central DI configuration place), import your new extension (from step 2) and register it with the
* dependency injection container.
* Example: `context.register(MyCustomElementDomRendererExtension);`
* As seen with `ShapeDomRendererExtension` being registered in `blocksuite/affine/gfx/shape/src/view.ts`.
*
* 4. **Core Infrastructure (Provided by DomRenderer System)**:
* - `DomElementRenderer` (type): The function signature for renderers, defined in
* `blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts`.
* - `DomElementRendererIdentifier` (function): Creates unique service identifiers for DI,
* used by `DomRenderer` to look up specific renderers. Defined in the same file.
* - `DomElementRendererExtension` (factory): A helper to create extension objects for easy registration.
* (e.g., from `@blocksuite/affine-block-surface` or its source).
* - `DomRenderer._renderElement()`: This method automatically looks up the registered renderer using
* `DomElementRendererIdentifier(elementType)` and calls it if found.
*
* 5. **Ensure Exports**:
* - The `DomRenderer` class itself should be accessible (e.g., exported from `@blocksuite/affine/blocks/surface`).
* - The `DomElementRendererExtension` factory should be accessible.
*
* By following these steps, `DomRenderer` will automatically pick up and use your custom rendering logic
* when it encounters elements of 'my-custom-element' type.
*/
export class DomRenderer {
private _container!: HTMLElement;

View File

@@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js';
import { effects } from './effects';
import {
ConnectorDomRendererExtension,
EdgelessCRUDExtension,
EdgelessLegacySlotExtension,
EditPropsMiddlewareBuilder,
@@ -26,6 +27,7 @@ export class SurfaceViewExtension extends ViewExtensionProvider {
super.setup(context);
context.register([
FlavourExtension('affine:surface'),
ConnectorDomRendererExtension,
EdgelessCRUDExtension,
EdgelessLegacySlotExtension,
ExportManagerExtension,

View File

@@ -745,7 +745,7 @@ export class TableCell extends SignalWatcher(
padding: '8px 12px',
})}
.yText="${this.text}"
.inlineEventSource="${this.topContenteditableElement}"
.inlineEventSource="${this.topContenteditableElement ?? nothing}"
.attributesSchema="${this.inlineManager?.getSchema()}"
.attributeRenderer="${this.inlineManager?.getRenderer()}"
.embedChecker="${this.inlineManager?.embedChecker}"

View File

@@ -0,0 +1,39 @@
{
"name": "@blocksuite/affine-fragment-adapter-panel",
"description": "Adapter panel fragment for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.12",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.14",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.21.0"
}

View File

@@ -0,0 +1,177 @@
import type { Store, TransformerMiddleware } from '@blocksuite/affine/store';
import {
type HtmlAdapter,
HtmlAdapterFactoryIdentifier,
type MarkdownAdapter,
MarkdownAdapterFactoryIdentifier,
type PlainTextAdapter,
PlainTextAdapterFactoryIdentifier,
} from '@blocksuite/affine-shared/adapters';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { provide } from '@lit/context';
import { effect, signal } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import {
type AdapterPanelContext,
adapterPanelContext,
ADAPTERS,
} from './config';
export const AFFINE_ADAPTER_PANEL = 'affine-adapter-panel';
export class AdapterPanel extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
:host {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.adapters-container {
width: 100%;
height: 100%;
background-color: var(--affine-background-primary-color);
box-sizing: border-box;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
private _createJob() {
return this.store.getTransformer(this.transformerMiddlewares);
}
private _getDocSnapshot() {
const job = this._createJob();
const result = job.docToSnapshot(this.store);
return result;
}
private async _getHtmlContent() {
try {
const job = this._createJob();
const htmlAdapterFactory = this.store.get(HtmlAdapterFactoryIdentifier);
const htmlAdapter = htmlAdapterFactory.get(job) as HtmlAdapter;
const result = await htmlAdapter.fromDoc(this.store);
return result?.file;
} catch (error) {
console.error('Failed to get html content', error);
return '';
}
}
private async _getMarkdownContent() {
try {
const job = this._createJob();
const markdownAdapterFactory = this.store.get(
MarkdownAdapterFactoryIdentifier
);
const markdownAdapter = markdownAdapterFactory.get(
job
) as MarkdownAdapter;
const result = await markdownAdapter.fromDoc(this.store);
return result?.file;
} catch (error) {
console.error('Failed to get markdown content', error);
return '';
}
}
private async _getPlainTextContent() {
try {
const job = this._createJob();
const plainTextAdapterFactory = this.store.get(
PlainTextAdapterFactoryIdentifier
);
const plainTextAdapter = plainTextAdapterFactory.get(
job
) as PlainTextAdapter;
const result = await plainTextAdapter.fromDoc(this.store);
return result?.file;
} catch (error) {
console.error('Failed to get plain text content', error);
return '';
}
}
private readonly _updateActiveContent = async () => {
const activeId = this.activeAdapter.id;
switch (activeId) {
case 'markdown':
this._context.markdownContent$.value =
(await this._getMarkdownContent()) || '';
break;
case 'html':
this._context.htmlContent$.value = (await this._getHtmlContent()) || '';
break;
case 'plaintext':
this._context.plainTextContent$.value =
(await this._getPlainTextContent()) || '';
break;
case 'snapshot':
this._context.docSnapshot$.value = this._getDocSnapshot() || null;
break;
}
};
override connectedCallback() {
super.connectedCallback();
this._context = {
activeAdapter$: signal(ADAPTERS[0]),
isHtmlPreview$: signal(false),
docSnapshot$: signal(null),
htmlContent$: signal(''),
markdownContent$: signal(''),
plainTextContent$: signal(''),
};
}
override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('store')) {
this._updateActiveContent().catch(console.error);
}
}
override firstUpdated() {
this.disposables.add(
effect(() => {
if (this.activeAdapter) {
this._updateActiveContent().catch(console.error);
}
})
);
}
override render() {
return html`
<div class="adapters-container">
<affine-adapter-panel-header
.updateActiveContent=${this._updateActiveContent}
></affine-adapter-panel-header>
<affine-adapter-panel-body></affine-adapter-panel-body>
</div>
`;
}
@property({ attribute: false })
accessor store!: Store;
@property({ attribute: false })
accessor transformerMiddlewares: TransformerMiddleware[] = [];
@provide({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_PANEL]: AdapterPanel;
}
}

View File

@@ -0,0 +1,216 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher } from '@blocksuite/global/lit';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import {
type AdapterItem,
type AdapterPanelContext,
adapterPanelContext,
ADAPTERS,
} from '../config';
export const AFFINE_ADAPTER_PANEL_BODY = 'affine-adapter-panel-body';
export class AdapterPanelBody extends SignalWatcher(LitElement) {
static override styles = css`
.adapter-panel-body {
width: 100%;
height: calc(100% - 50px);
box-sizing: border-box;
overflow: auto;
padding: 8px 16px;
}
${scrollbarStyle('.adapter-panel-body')}
.adapter-content {
width: 100%;
height: 100%;
white-space: pre-wrap;
color: var(--affine-text-primary-color);
font-size: var(--affine-font-sm);
box-sizing: border-box;
}
.html-content {
display: flex;
gap: 8px;
flex-direction: column;
justify-content: space-between;
}
.html-preview-container,
.html-panel-content {
width: 100%;
flex: 1 0 0;
border: none;
box-sizing: border-box;
color: var(--affine-text-primary-color);
overflow: auto;
}
${scrollbarStyle('.html-panel-content')}
.html-panel-footer {
width: 100%;
height: 24px;
display: flex;
}
.html-toggle-container {
display: flex;
background: ${unsafeCSSVarV2('segment/background')};
justify-content: flex-start;
padding: 2px;
border-radius: 4px;
}
.html-toggle-item {
cursor: pointer;
display: flex;
padding: 0px 4px;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 500;
line-height: 20px;
border-radius: 4px;
color: ${unsafeCSSVarV2('text/primary')};
}
.html-toggle-item:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.html-toggle-item[active] {
background: ${unsafeCSSVarV2('segment/button')};
box-shadow:
var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px)
var(--Shadow-buttonShadow-1-blur, 1px) 0px
var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)),
var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px)
var(--Shadow-buttonShadow-2-blur, 5px) 0px
var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12));
}
.adapter-container {
display: none;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.adapter-container.active {
display: block;
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
get isHtmlPreview() {
return this._context.isHtmlPreview$.value;
}
get htmlContent() {
return this._context.htmlContent$.value;
}
get markdownContent() {
return this._context.markdownContent$.value;
}
get plainTextContent() {
return this._context.plainTextContent$.value;
}
get docSnapshot() {
return this._context.docSnapshot$.value;
}
private _renderHtmlPanel() {
return html`
${this.isHtmlPreview
? html`<iframe
class="html-preview-container"
.srcdoc=${this.htmlContent}
sandbox="allow-same-origin"
></iframe>`
: html`<div class="html-panel-content">${this.htmlContent}</div>`}
<div class="html-panel-footer">
<div class="html-toggle-container">
<span
class="html-toggle-item"
?active=${!this.isHtmlPreview}
@click=${() => (this._context.isHtmlPreview$.value = false)}
>Source</span
>
<span
class="html-toggle-item"
?active=${this.isHtmlPreview}
@click=${() => (this._context.isHtmlPreview$.value = true)}
>Preview</span
>
</div>
</div>
`;
}
private readonly _renderAdapterContent = (adapter: AdapterItem) => {
switch (adapter.id) {
case 'html':
return this._renderHtmlPanel();
case 'markdown':
return this.markdownContent;
case 'plaintext':
return this.plainTextContent;
case 'snapshot':
return this.docSnapshot
? JSON.stringify(this.docSnapshot, null, 4)
: '';
default:
return '';
}
};
private readonly _renderAdapterContainer = (adapter: AdapterItem) => {
const containerClasses = classMap({
'adapter-container': true,
active: this.activeAdapter.id === adapter.id,
});
const contentClasses = classMap({
'adapter-content': true,
[`${adapter.id}-content`]: true,
});
const content = this._renderAdapterContent(adapter);
return html`
<div class=${containerClasses}>
<div class=${contentClasses}>${content}</div>
</div>
`;
};
override render() {
return html`
<div class="adapter-panel-body">
${ADAPTERS.map(adapter => this._renderAdapterContainer(adapter))}
</div>
`;
}
@consume({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_PANEL_BODY]: AdapterPanelBody;
}
}

View File

@@ -0,0 +1,28 @@
import type { DocSnapshot } from '@blocksuite/store';
import { createContext } from '@lit/context';
import type { Signal } from '@preact/signals-core';
export type AdapterItem = {
id: string;
label: string;
};
export const ADAPTERS: AdapterItem[] = [
{ id: 'markdown', label: 'Markdown' },
{ id: 'plaintext', label: 'PlainText' },
{ id: 'html', label: 'HTML' },
{ id: 'snapshot', label: 'Snapshot' },
];
export type AdapterPanelContext = {
activeAdapter$: Signal<AdapterItem>;
isHtmlPreview$: Signal<boolean>;
docSnapshot$: Signal<DocSnapshot | null>;
htmlContent$: Signal<string>;
markdownContent$: Signal<string>;
plainTextContent$: Signal<string>;
};
export const adapterPanelContext = createContext<AdapterPanelContext>(
'adapterPanelContext'
);

View File

@@ -0,0 +1,17 @@
import { AdapterPanel, AFFINE_ADAPTER_PANEL } from './adapter-panel';
import {
AdapterPanelBody,
AFFINE_ADAPTER_PANEL_BODY,
} from './body/adapter-panel-body';
import { AdapterMenu, AFFINE_ADAPTER_MENU } from './header/adapter-menu';
import {
AdapterPanelHeader,
AFFINE_ADAPTER_PANEL_HEADER,
} from './header/adapter-panel-header';
export function effects() {
customElements.define(AFFINE_ADAPTER_PANEL, AdapterPanel);
customElements.define(AFFINE_ADAPTER_MENU, AdapterMenu);
customElements.define(AFFINE_ADAPTER_PANEL_HEADER, AdapterPanelHeader);
customElements.define(AFFINE_ADAPTER_PANEL_BODY, AdapterPanelBody);
}

View File

@@ -0,0 +1,86 @@
import { SignalWatcher } from '@blocksuite/global/lit';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import {
type AdapterItem,
type AdapterPanelContext,
adapterPanelContext,
ADAPTERS,
} from '../config';
export const AFFINE_ADAPTER_MENU = 'affine-adapter-menu';
export class AdapterMenu extends SignalWatcher(LitElement) {
static override styles = css`
.adapter-menu {
min-width: 120px;
padding: 4px;
background: var(--affine-background-primary-color);
border: 1px solid var(--affine-border-color);
border-radius: 4px;
box-shadow: var(--affine-shadow-1);
}
.adapter-menu-item {
display: block;
width: 100%;
padding: 6px 8px;
border: none;
background: none;
text-align: left;
cursor: pointer;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
border-radius: 4px;
}
.adapter-menu-item:hover {
background: var(--affine-hover-color);
}
.adapter-menu-item.active {
color: var(--affine-primary-color);
background: var(--affine-hover-color);
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
private readonly _handleAdapterChange = async (adapter: AdapterItem) => {
this._context.activeAdapter$.value = adapter;
this.abortController?.abort();
};
override render() {
return html`<div class="adapter-menu">
${ADAPTERS.map(adapter => {
const classes = classMap({
'adapter-menu-item': true,
active: this.activeAdapter.id === adapter.id,
});
return html`
<button
class=${classes}
@click=${() => this._handleAdapterChange(adapter)}
>
${adapter.label}
</button>
`;
})}
</div>`;
}
@property({ attribute: false })
accessor abortController: AbortController | null = null;
@consume({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_MENU]: AdapterMenu;
}
}

View File

@@ -0,0 +1,119 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { SignalWatcher } from '@blocksuite/global/lit';
import { ArrowDownSmallIcon, FlipDirectionIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { type AdapterPanelContext, adapterPanelContext } from '../config';
export const AFFINE_ADAPTER_PANEL_HEADER = 'affine-adapter-panel-header';
export class AdapterPanelHeader extends SignalWatcher(LitElement) {
static override styles = css`
.adapter-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--affine-background-primary-color);
}
.adapter-selector {
display: flex;
align-items: center;
justify-content: space-between;
width: 100px;
cursor: pointer;
border-radius: 4px;
border: 1px solid var(--affine-border-color);
padding: 4px 8px;
}
.adapter-selector:hover {
background: var(--affine-hover-color);
}
.adapter-selector-label {
display: flex;
align-items: center;
color: var(--affine-text-primary-color);
font-size: var(--affine-font-xs);
}
.update-button {
height: 20px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: var(--affine-icon-color);
}
.update-button:hover {
background-color: var(--affine-hover-color);
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
private _adapterMenuAbortController: AbortController | null = null;
private readonly _toggleAdapterMenu = () => {
if (this._adapterMenuAbortController) {
this._adapterMenuAbortController.abort();
}
this._adapterMenuAbortController = new AbortController();
createLitPortal({
template: html`<affine-adapter-menu
.abortController=${this._adapterMenuAbortController}
></affine-adapter-menu>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this._adapterPanelHeader,
computePosition: {
referenceElement: this._adapterSelector,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
},
abortController: this._adapterMenuAbortController,
closeOnClickAway: true,
});
};
override render() {
return html`
<div class="adapter-panel-header">
<div class="adapter-selector" @click="${this._toggleAdapterMenu}">
<span class="adapter-selector-label">
${this.activeAdapter.label}
</span>
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</div>
<div class="update-button" @click="${this.updateActiveContent}">
${FlipDirectionIcon({ width: '16px', height: '16px' })}
</div>
</div>
`;
}
@query('.adapter-panel-header')
private accessor _adapterPanelHeader!: HTMLDivElement;
@query('.adapter-selector')
private accessor _adapterSelector!: HTMLDivElement;
@property({ attribute: false })
accessor updateActiveContent: () => void = () => {};
@consume({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_PANEL_HEADER]: AdapterPanelHeader;
}
}

View File

@@ -0,0 +1,4 @@
export * from './adapter-panel.js';
export * from './body/adapter-panel-body.js';
export * from './header/adapter-menu.js';
export * from './header/adapter-panel-header.js';

View File

@@ -0,0 +1,12 @@
import { ViewExtensionProvider } from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
export class AdapterPanelViewExtension extends ViewExtensionProvider {
override name = 'affine-adapter-panel-fragment';
override effect() {
super.effect();
effects();
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -223,13 +223,15 @@ export class ConnectorTool extends BaseTool<ConnectorToolOptions> {
}
getNextMode() {
switch (this.activatedOption.mode) {
case ConnectorMode.Curve:
return ConnectorMode.Orthogonal;
case ConnectorMode.Orthogonal:
return ConnectorMode.Straight;
case ConnectorMode.Straight:
return ConnectorMode.Curve;
}
// reorder the enum values
const modes = [
ConnectorMode.Curve,
ConnectorMode.Orthogonal,
ConnectorMode.Straight,
];
const currentIndex = modes.indexOf(this.activatedOption.mode);
const nextIndex = (currentIndex + 1) % modes.length;
return modes[nextIndex];
}
}

View File

@@ -235,21 +235,18 @@ export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin(
const locked = this.gfx.viewport.locked;
const selection = this.gfx.selection;
if (locked || selection.editing) return;
if (
this.gfx.tool.dragging$.peek() &&
this.gfx.tool.currentToolName$.peek() === 'shape'
) {
return;
}
const activeIndex = shapes.findIndex(
s => s.name === this.draggingShape
);
const nextIndex = (activeIndex + 1) % shapes.length;
const next = shapes[nextIndex];
this.draggingShape = next.name;
const currentTool = this.gfx.tool.currentToolName$.peek();
if (this.readyToDrop) {
if (currentTool === ShapeTool.toolName) {
const activeIndex = shapes.findIndex(
s => s.name === this.draggingShape
);
const nextIndex = (activeIndex + 1) % shapes.length;
const next = shapes[nextIndex];
this.draggingShape = next.name;
}
this.draggableController.cancelWithoutAnimation();
const el = this.shapeContainer.querySelector(
`.shape.${this.draggingShape}`
@@ -264,8 +261,14 @@ export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin(
const clientPos = { x: x + left, y: y + top };
this.draggableController.dragAndMoveTo(el, clientPos);
} else {
if (this.gfx.tool.dragging$.peek()) return;
let shapeName =
this.gfx.tool.get(ShapeTool).activatedOption.shapeName;
if (currentTool === ShapeTool.toolName) {
shapeName = this.gfx.tool.get(ShapeTool).cycleShapeName('next');
}
this.setEdgelessTool(ShapeTool, {
shapeName: this.draggingShape,
shapeName,
});
}
},

View File

@@ -9,18 +9,35 @@ function applyShapeSpecificStyles(
element: HTMLElement,
zoom: number
) {
if (model.shapeType === 'rect') {
const w = model.w * zoom;
const h = model.h * zoom;
const r = model.radius ?? 0;
const borderRadius =
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
element.style.borderRadius = borderRadius;
} else if (model.shapeType === 'ellipse') {
element.style.borderRadius = '50%';
} else {
element.style.borderRadius = '';
// Reset properties that might be set by different shape types
element.style.removeProperty('clip-path');
element.style.removeProperty('border-radius');
// Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based
if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') {
while (element.firstChild) element.firstChild.remove();
}
switch (model.shapeType) {
case 'rect': {
const w = model.w * zoom;
const h = model.h * zoom;
const r = model.radius ?? 0;
const borderRadius =
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
element.style.borderRadius = borderRadius;
break;
}
case 'ellipse':
element.style.borderRadius = '50%';
break;
case 'diamond':
element.style.clipPath = 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)';
break;
case 'triangle':
element.style.clipPath = 'polygon(50% 0%, 100% 100%, 0% 100%)';
break;
}
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
}
function applyBorderStyles(
@@ -78,6 +95,9 @@ export const shapeDomRenderer = (
renderer: DomRenderer
): void => {
const { zoom } = renderer.viewport;
const unscaledWidth = model.w;
const unscaledHeight = model.h;
const fillColor = renderer.getColorValue(
model.fillColor,
DefaultTheme.shapeFillColor,
@@ -89,17 +109,80 @@ export const shapeDomRenderer = (
true
);
element.style.width = `${model.w * zoom}px`;
element.style.height = `${model.h * zoom}px`;
element.style.width = `${unscaledWidth * zoom}px`;
element.style.height = `${unscaledHeight * zoom}px`;
element.style.boxSizing = 'border-box';
// Apply shape-specific clipping, border-radius, and potentially clear innerHTML
applyShapeSpecificStyles(model, element, zoom);
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
if (model.shapeType === 'diamond' || model.shapeType === 'triangle') {
// For diamond and triangle, fill and border are handled by inline SVG
element.style.border = 'none'; // Ensure no standard CSS border interferes
element.style.backgroundColor = 'transparent'; // Host element is transparent
const strokeW = model.strokeWidth;
const halfStroke = strokeW / 2; // Calculate half stroke width for point adjustment
let svgPoints = '';
if (model.shapeType === 'diamond') {
// Adjusted points for diamond
svgPoints = [
`${unscaledWidth / 2},${halfStroke}`,
`${unscaledWidth - halfStroke},${unscaledHeight / 2}`,
`${unscaledWidth / 2},${unscaledHeight - halfStroke}`,
`${halfStroke},${unscaledHeight / 2}`,
].join(' ');
} else {
// triangle
// Adjusted points for triangle
svgPoints = [
`${unscaledWidth / 2},${halfStroke}`,
`${unscaledWidth - halfStroke},${unscaledHeight - halfStroke}`,
`${halfStroke},${unscaledHeight - halfStroke}`,
].join(' ');
}
// Determine if stroke should be visible and its color
const finalStrokeColor =
model.strokeStyle !== 'none' && strokeW > 0 ? strokeColor : 'transparent';
// Determine dash array, only if stroke is visible and style is 'dash'
const finalStrokeDasharray =
model.strokeStyle === 'dash' && finalStrokeColor !== 'transparent'
? '12, 12'
: 'none';
// Determine fill color
const finalFillColor = model.filled ? fillColor : 'transparent';
// Build SVG safely with DOM-API
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
svg.setAttribute('preserveAspectRatio', 'none');
const polygon = document.createElementNS(SVG_NS, 'polygon');
polygon.setAttribute('points', svgPoints);
polygon.setAttribute('fill', finalFillColor);
polygon.setAttribute('stroke', finalStrokeColor);
polygon.setAttribute('stroke-width', String(strokeW));
if (finalStrokeDasharray !== 'none') {
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
}
svg.append(polygon);
// Replace existing children to avoid memory leaks
element.replaceChildren(svg);
} else {
// Standard rendering for other shapes (e.g., rect, ellipse)
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
}
applyBorderStyles(model, element, strokeColor, zoom);
applyTransformStyles(model, element);
element.style.boxSizing = 'border-box';
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
manageClassNames(model, element);

View File

@@ -5,7 +5,11 @@ import {
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import type { ShapeElementModel, ShapeName } from '@blocksuite/affine-model';
import { DefaultTheme, getShapeType } from '@blocksuite/affine-model';
import {
DefaultTheme,
getShapeType,
ShapeType,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
TelemetryProvider,
@@ -15,7 +19,7 @@ import { hasClassNameInList } from '@blocksuite/affine-shared/utils';
import type { IBound } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import { BaseTool } from '@blocksuite/std/gfx';
import { BaseTool, type GfxController } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import {
@@ -159,6 +163,13 @@ export class ShapeTool extends BaseTool<ShapeToolOption> {
this._surfaceComponent?.refresh();
}
constructor(gfx: GfxController) {
super(gfx);
this.activatedOption = {
shapeName: ShapeType.Rect,
};
}
override activate() {
this.createOverlay();
}
@@ -337,4 +348,20 @@ export class ShapeTool extends BaseTool<ShapeToolOption> {
setDisableOverlay(disable: boolean) {
this._disableOverlay = disable;
}
cycleShapeName(dir: 'prev' | 'next' = 'next'): ShapeName {
const shapeNames: ShapeName[] = [
ShapeType.Rect,
ShapeType.Ellipse,
ShapeType.Diamond,
ShapeType.Triangle,
'roundedRect',
];
const currentIndex = shapeNames.indexOf(this.activatedOption.shapeName);
const nextIndex =
(currentIndex + (dir === 'prev' ? -1 : 1) + shapeNames.length) %
shapeNames.length;
return shapeNames[nextIndex];
}
}

View File

@@ -11,9 +11,9 @@ export enum ShapeTextFontSize {
}
export enum ShapeType {
Diamond = 'diamond',
Ellipse = 'ellipse',
Rect = 'rect',
Ellipse = 'ellipse',
Diamond = 'diamond',
Triangle = 'triangle',
}

View File

@@ -19,6 +19,7 @@
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.14",
"@types/bytes": "^3.1.5",
"@types/hast": "^3.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
@@ -71,7 +72,6 @@
"!dist/__tests__"
],
"devDependencies": {
"@types/bytes": "^3.1.5",
"vitest": "3.1.3"
},
"version": "0.21.0"

View File

@@ -14,7 +14,7 @@
"@blocksuite/sync": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@types/lodash.ismatch": "^4.4.9",
"file-type": "^20.0.0",
"file-type": "^21.0.0",
"lib0": "^0.2.97",
"lodash.clonedeep": "^4.5.0",
"lodash.ismatch": "^4.4.0",

View File

@@ -30,7 +30,7 @@ describe('Shape rendering with DOM renderer', () => {
fill: '#ff0000',
stroke: '#000000',
};
const shapeId = surfaceModel.addElement(shapeProps as any);
const shapeId = surfaceModel.addElement(shapeProps);
await new Promise(resolve => setTimeout(resolve, 100));
const shapeElement = surfaceView?.renderRoot.querySelector(
@@ -73,7 +73,7 @@ describe('Shape rendering with DOM renderer', () => {
subType: 'ellipse',
xywh: '[200, 200, 50, 50]',
};
const shapeId = surfaceModel.addElement(shapeProps as any);
const shapeId = surfaceModel.addElement(shapeProps);
await new Promise(resolve => setTimeout(resolve, 100));
@@ -91,4 +91,48 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).toBeNull();
});
test('should correctly render diamond shape', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'diamond',
xywh: '[150, 150, 80, 60]',
fillColor: '#ff0000',
strokeColor: '#000000',
filled: true,
};
const shapeId = surfaceModel.addElement(shapeProps);
await wait(100);
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${shapeId}"]`
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
});
test('should correctly render triangle shape', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'triangle',
xywh: '[150, 150, 80, 60]',
fillColor: '#ff0000',
strokeColor: '#000000',
filled: true,
};
const shapeId = surfaceModel.addElement(shapeProps);
await wait(100);
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${shapeId}"]`
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
});
});

View File

@@ -1,285 +0,0 @@
/* eslint-disable @typescript-eslint/no-restricted-imports */
import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddlewareBuilder,
embedSyncedDocMiddleware,
type HtmlAdapter,
HtmlAdapterFactoryIdentifier,
type MarkdownAdapter,
MarkdownAdapterFactoryIdentifier,
type PlainTextAdapter,
PlainTextAdapterFactoryIdentifier,
titleMiddleware,
} from '@blocksuite/affine/shared/adapters';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { DocSnapshot } from '@blocksuite/affine/store';
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
import { effect } from '@preact/signals-core';
import type SlTabPanel from '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js';
import { css, html, type PropertyValues } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
@customElement('adapters-panel')
export class AdaptersPanel extends WithDisposable(ShadowlessElement) {
static override styles = css`
adapters-panel {
width: 36vw;
}
.adapters-container {
border: 1px solid var(--affine-border-color, #e3e2e4);
background-color: var(--affine-background-primary-color);
box-sizing: border-box;
position: relative;
}
.adapter-container {
padding: 0px 16px;
width: 100%;
height: calc(100vh - 80px);
white-space: pre-wrap;
color: var(--affine-text-primary-color);
overflow: auto;
}
.update-button {
position: absolute;
top: 8px;
right: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--affine-border-color);
font-family: var(--affine-font-family);
color: var(--affine-text-primary-color);
background-color: var(--affine-background-primary-color);
}
.update-button:hover {
background-color: var(--affine-hover-color);
}
.html-panel {
display: flex;
gap: 8px;
flex-direction: column;
}
.html-preview-container,
.html-panel-content {
width: 100%;
flex: 1;
border: none;
box-sizing: border-box;
color: var(--affine-text-primary-color);
overflow: auto;
}
.html-panel-footer {
width: 100%;
height: 32px;
display: flex;
justify-content: flex-end;
span {
cursor: pointer;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--affine-border-color);
font-family: var(--affine-font-family);
color: var(--affine-text-primary-color);
background-color: var(--affine-background-primary-color);
line-height: 20px;
}
span[active] {
background-color: var(--affine-hover-color);
}
}
`;
get doc() {
return this.editor.doc;
}
private _createJob() {
return this.doc.getTransformer([
docLinkBaseURLMiddlewareBuilder(
'https://example.com',
this.doc.workspace.id
).get(),
titleMiddleware(this.doc.workspace.meta.docMetas),
embedSyncedDocMiddleware('content'),
defaultImageProxyMiddleware,
]);
}
private _getDocSnapshot() {
const job = this._createJob();
const result = job.docToSnapshot(this.doc);
return result;
}
private async _getHtmlContent() {
const job = this._createJob();
const htmlAdapterFactory = this.editor.std.provider.get(
HtmlAdapterFactoryIdentifier
);
const htmlAdapter = htmlAdapterFactory.get(job) as HtmlAdapter;
const result = await htmlAdapter.fromDoc(this.doc);
return result?.file;
}
private async _getMarkdownContent() {
const job = this._createJob();
const markdownAdapterFactory = this.editor.std.provider.get(
MarkdownAdapterFactoryIdentifier
);
const markdownAdapter = markdownAdapterFactory.get(job) as MarkdownAdapter;
const result = await markdownAdapter.fromDoc(this.doc);
return result?.file;
}
private async _getPlainTextContent() {
const job = this._createJob();
const plainTextAdapterFactory = this.editor.std.provider.get(
PlainTextAdapterFactoryIdentifier
);
const plainTextAdapter = plainTextAdapterFactory.get(
job
) as PlainTextAdapter;
const result = await plainTextAdapter.fromDoc(this.doc);
return result?.file;
}
private async _handleTabShow(name: string) {
switch (name) {
case 'markdown':
this._markdownContent = (await this._getMarkdownContent()) || '';
break;
case 'html':
this._htmlContent = (await this._getHtmlContent()) || '';
break;
case 'plaintext':
this._plainTextContent = (await this._getPlainTextContent()) || '';
break;
case 'snapshot':
this._docSnapshot = this._getDocSnapshot() || null;
break;
}
}
private _renderHtmlPanel() {
return html`
${this._isHtmlPreview
? html`<iframe
class="html-preview-container"
.srcdoc=${this._htmlContent}
></iframe>`
: html`<div class="html-panel-content">${this._htmlContent}</div>`}
<div class="html-panel-footer">
<span
class="html-panel-footer-item"
?active=${!this._isHtmlPreview}
@click=${() => (this._isHtmlPreview = false)}
>Source</span
>
<span
class="html-panel-footer-item"
?active=${this._isHtmlPreview}
@click=${() => (this._isHtmlPreview = true)}
>Preview</span
>
</div>
`;
}
private async _updateActiveTabContent() {
if (!this._activeTab) return;
const activeTabName = this._activeTab.name;
await this._handleTabShow(activeTabName);
}
override firstUpdated() {
this.disposables.add(
effect(() => {
const doc = this.doc;
if (doc) {
this._updateActiveTabContent().catch(console.error);
}
})
);
}
override render() {
const snapshotString = this._docSnapshot
? JSON.stringify(this._docSnapshot, null, 4)
: '';
return html`
<div class="adapters-container">
<sl-tab-group
activation="auto"
@sl-tab-show=${(e: CustomEvent) => this._handleTabShow(e.detail.name)}
>
<sl-tab slot="nav" panel="markdown">Markdown</sl-tab>
<sl-tab slot="nav" panel="plaintext">PlainText</sl-tab>
<sl-tab slot="nav" panel="html">HTML</sl-tab>
<sl-tab slot="nav" panel="snapshot">Snapshot</sl-tab>
<sl-tab-panel name="markdown">
<div class="adapter-container">${this._markdownContent}</div>
</sl-tab-panel>
<sl-tab-panel name="html">
<div class="adapter-container html-panel">
${this._renderHtmlPanel()}
</div>
</sl-tab-panel>
<sl-tab-panel name="plaintext">
<div class="adapter-container">${this._plainTextContent}</div>
</sl-tab-panel>
<sl-tab-panel name="snapshot">
<div class="adapter-container">${snapshotString}</div>
</sl-tab-panel>
</sl-tab-group>
<sl-tooltip content="Update Adapter Content" placement="left" hoist>
<div class="update-button" @click="${this._updateActiveTabContent}">
Update
</div>
</sl-tooltip>
</div>
`;
}
override willUpdate(_changedProperties: PropertyValues) {
if (_changedProperties.has('editor')) {
requestIdleCallback(() => {
this._updateActiveTabContent().catch(console.error);
});
}
}
@query('sl-tab-panel[active]')
private accessor _activeTab!: SlTabPanel;
@state()
private accessor _docSnapshot: DocSnapshot | null = null;
@state()
private accessor _htmlContent = '';
@state()
private accessor _isHtmlPreview = false;
@state()
private accessor _markdownContent = '';
@state()
private accessor _plainTextContent = '';
@property({ attribute: false })
accessor editor!: TestAffineEditorContainer;
}
declare global {
interface HTMLElementTagNameMap {
'adapters-panel': AdaptersPanel;
}
}

View File

@@ -0,0 +1,61 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { TransformerMiddleware } from '@blocksuite/affine/store';
import type { TestAffineEditorContainer } from '@blocksuite/integration-test';
import { css, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('custom-adapter-panel')
export class CustomAdapterPanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.custom-adapter-container {
position: absolute;
top: 0;
right: 16px;
border: 1px solid var(--affine-border-color, #e3e2e4);
background: var(--affine-background-overlay-panel-color);
height: 100vh;
width: 30vw;
box-sizing: border-box;
z-index: 1;
}
`;
private _renderPanel() {
return html`<affine-adapter-panel
.store=${this.editor.doc}
.transformerMiddlewares=${this.transformerMiddlewares}
></affine-adapter-panel>`;
}
override render() {
return html`
${this._show
? html`
<div class="custom-adapter-container">${this._renderPanel()}</div>
`
: nothing}
`;
}
toggleDisplay() {
this._show = !this._show;
}
@state()
private accessor _show = false;
@property({ attribute: false })
accessor editor!: TestAffineEditorContainer;
@property({ attribute: false })
accessor transformerMiddlewares: TransformerMiddleware[] = [];
}
declare global {
interface HTMLElementTagNameMap {
'custom-adapter-panel': CustomAdapterPanel;
}
}

View File

@@ -70,7 +70,7 @@ import type { Pane } from 'tweakpane';
import type { CommentPanel } from '../../comment/index.js';
import { createTestEditor } from '../../starter/utils/extensions.js';
import { mockEdgelessTheme } from '../mock-services.js';
import { AdaptersPanel } from './adapters-panel.js';
import type { CustomAdapterPanel } from './custom-adapter-panel.js';
import type { CustomFramePanel } from './custom-frame-panel.js';
import type { CustomOutlinePanel } from './custom-outline-panel.js';
import type { CustomOutlineViewer } from './custom-outline-viewer.js';
@@ -612,26 +612,6 @@ export class StarterDebugMenu extends ShadowlessElement {
this._hasOffset = !this._hasOffset;
}
private _toggleAdaptersPanel() {
const app = document.querySelector('#app');
if (!app) return;
const currentAdaptersPanel = app.querySelector('adapters-panel');
if (currentAdaptersPanel) {
currentAdaptersPanel.remove();
(app as HTMLElement).style.display = 'block';
this.editor.style.width = '100%';
this.editor.style.flex = '';
return;
}
const adaptersPanel = new AdaptersPanel();
adaptersPanel.editor = this.editor;
app.append(adaptersPanel);
this.editor.style.flex = '1';
(app as HTMLElement).style.display = 'flex';
}
private _toggleCommentPanel() {
document.body.append(this.commentPanel);
}
@@ -649,6 +629,10 @@ export class StarterDebugMenu extends ShadowlessElement {
this.framePanel.toggleDisplay();
}
private _toggleAdapterPanel() {
this.adapterPanel.toggleDisplay();
}
private _toggleMultipleEditors() {
const app = document.querySelector('#app');
if (app) {
@@ -926,8 +910,8 @@ export class StarterDebugMenu extends ShadowlessElement {
<sl-menu-item @click="${this._toggleMultipleEditors}">
Toggle Multiple Editors
</sl-menu-item>
<sl-menu-item @click="${this._toggleAdaptersPanel}">
Toggle Adapters Panel
<sl-menu-item @click="${this._toggleAdapterPanel}">
Toggle Adapter Panel
</sl-menu-item>
</sl-menu>
</sl-dropdown>
@@ -1032,6 +1016,9 @@ export class StarterDebugMenu extends ShadowlessElement {
@property({ attribute: false })
accessor framePanel!: CustomFramePanel;
@property({ attribute: false })
accessor adapterPanel!: CustomAdapterPanel;
@property({ attribute: false })
accessor leftSidePanel!: LeftSidePanel;

View File

@@ -1,6 +1,13 @@
import type { Store, Workspace } from '@blocksuite/affine/store';
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddlewareBuilder,
embedSyncedDocMiddleware,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import { AttachmentViewerPanel } from '../../_common/components/attachment-viewer-panel';
import { CustomAdapterPanel } from '../../_common/components/custom-adapter-panel';
import { CustomFramePanel } from '../../_common/components/custom-frame-panel';
import { CustomOutlinePanel } from '../../_common/components/custom-outline-panel';
import { CustomOutlineViewer } from '../../_common/components/custom-outline-viewer';
@@ -24,6 +31,7 @@ export async function createTestApp(doc: Store, collection: Workspace) {
const docsPanel = new DocsPanel();
const framePanel = new CustomFramePanel();
const outlinePanel = new CustomOutlinePanel();
const adapterPanel = new CustomAdapterPanel();
const outlineViewer = new CustomOutlineViewer();
const leftSidePanel = new LeftSidePanel();
const commentPanel = new CommentPanel();
@@ -36,6 +44,16 @@ export async function createTestApp(doc: Store, collection: Workspace) {
outlineViewer.toggleOutlinePanel = () => {
outlinePanel.toggleDisplay();
};
adapterPanel.editor = editor;
adapterPanel.transformerMiddlewares = [
docLinkBaseURLMiddlewareBuilder(
'https://example.com',
editor.doc.workspace.id
).get(),
titleMiddleware(editor.doc.workspace.meta.docMetas),
embedSyncedDocMiddleware('content'),
defaultImageProxyMiddleware,
];
debugMenu.collection = collection;
debugMenu.editor = editor;
@@ -44,6 +62,7 @@ export async function createTestApp(doc: Store, collection: Workspace) {
debugMenu.framePanel = framePanel;
debugMenu.leftSidePanel = leftSidePanel;
debugMenu.docsPanel = docsPanel;
debugMenu.adapterPanel = adapterPanel;
debugMenu.commentPanel = commentPanel;
@@ -55,6 +74,7 @@ export async function createTestApp(doc: Store, collection: Workspace) {
document.body.append(framePanel);
document.body.append(leftSidePanel);
document.body.append(debugMenu);
document.body.append(adapterPanel);
window.editor = editor;
window.doc = doc;

View File

@@ -22,7 +22,7 @@ cp ./.docker/dev/.env.example ./.docker/dev/.env
docker compose -f ./.docker/dev/compose.yml up
```
#### Notify
### Notify
> Starting from AFFiNE 0.20, compose.yml includes a breaking change: the default database image has switched from `postgres:16` to `pgvector/pgvector:pg16`. If you were previously using another major version of Postgres, please change the number after `pgvector/pgvector:pg` to the major version you are using.
@@ -108,6 +108,6 @@ yarn affine server prisma studio
### Seed the db
```
```sh
yarn affine server seed -h
```

View File

@@ -4,19 +4,52 @@ The actual snapshot is saved in `copilot-context.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should get null for non-exist job
> should return null for non-exist job
null
## should insert embedding by doc id
> should match file embedding
[
{
chunk: 0,
content: 'content',
distance: 0,
fileId: 'file-id',
},
]
> should return empty array when embedding is deleted
[]
> should match workspace embedding
[
{
docId: 'doc1',
},
]
> should return empty array when doc is ignored
[]
> should return workspace embedding
[
{
docId: 'doc1',
},
]
> should return empty array when embedding deleted
[]
## should check embedding table
> should return true when embedding table is available
true

View File

@@ -6,9 +6,11 @@ import ava, { TestFn } from 'ava';
import { Config } from '../../base';
import { CopilotContextModel } from '../../models/copilot-context';
import { CopilotSessionModel } from '../../models/copilot-session';
import { CopilotWorkspaceConfigModel } from '../../models/copilot-workspace';
import { UserModel } from '../../models/user';
import { WorkspaceModel } from '../../models/workspace';
import { createTestingModule, type TestingModule } from '../utils';
import { cleanObject } from '../utils/copilot';
interface Context {
config: Config;
@@ -18,6 +20,7 @@ interface Context {
workspace: WorkspaceModel;
copilotSession: CopilotSessionModel;
copilotContext: CopilotContextModel;
copilotWorkspace: CopilotWorkspaceConfigModel;
}
const test = ava as TestFn<Context>;
@@ -28,6 +31,7 @@ test.before(async t => {
t.context.workspace = module.get(WorkspaceModel);
t.context.copilotSession = module.get(CopilotSessionModel);
t.context.copilotContext = module.get(CopilotContextModel);
t.context.copilotWorkspace = module.get(CopilotWorkspaceConfigModel);
t.context.db = module.get(PrismaClient);
t.context.config = module.get(Config);
t.context.module = module;
@@ -74,7 +78,7 @@ test('should create a copilot context', async t => {
test('should get null for non-exist job', async t => {
const job = await t.context.copilotContext.get('non-exist');
t.is(job, null);
t.snapshot(job, 'should return null for non-exist job');
});
test('should update context', async t => {
@@ -111,7 +115,10 @@ test('should insert embedding by doc id', async t => {
1,
1
);
t.snapshot(ret, 'should match file embedding');
t.snapshot(
cleanObject(ret, ['chunk', 'content', 'distance']),
'should match file embedding'
);
}
{
@@ -122,7 +129,7 @@ test('should insert embedding by doc id', async t => {
1,
1
);
t.is(ret.length, 0);
t.snapshot(ret, 'should return empty array when embedding is deleted');
}
}
@@ -155,7 +162,7 @@ test('should insert embedding by doc id', async t => {
workspace.id,
[docId]
);
t.true(ret.has(docId), 'should return true when embedding exists');
t.true(ret.has(docId), 'should return doc id when embedding is inserted');
}
{
@@ -165,8 +172,39 @@ test('should insert embedding by doc id', async t => {
1,
1
);
t.is(ret.length, 1);
t.is(ret[0].content, 'content');
t.snapshot(
cleanObject(ret, ['chunk', 'content', 'distance']),
'should match workspace embedding'
);
}
{
await t.context.copilotWorkspace.updateIgnoredDocs(workspace.id, [docId]);
const ret = await t.context.copilotContext.matchWorkspaceEmbedding(
Array.from({ length: 1024 }, () => 0.9),
workspace.id,
1,
1
);
t.snapshot(ret, 'should return empty array when doc is ignored');
}
{
await t.context.copilotWorkspace.updateIgnoredDocs(
workspace.id,
undefined,
[docId]
);
const ret = await t.context.copilotContext.matchWorkspaceEmbedding(
Array.from({ length: 1024 }, () => 0.9),
workspace.id,
1,
1
);
t.snapshot(
cleanObject(ret, ['chunk', 'content', 'distance']),
'should return workspace embedding'
);
}
{
@@ -188,7 +226,7 @@ test('should insert embedding by doc id', async t => {
test('should check embedding table', async t => {
{
const ret = await t.context.copilotContext.checkEmbeddingAvailable();
t.true(ret, 'should return true when embedding table is available');
t.snapshot(ret, 'should return true when embedding table is available');
}
// {

View File

@@ -201,6 +201,68 @@ test('should insert and search embedding', async t => {
}
});
test('should check need to be embedded', async t => {
const docId = randomUUID();
await t.context.doc.upsert({
spaceId: workspace.id,
docId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
{
let needsEmbedding = await t.context.copilotWorkspace.checkDocNeedEmbedded(
workspace.id,
docId
);
t.true(needsEmbedding, 'document with no embedding should need embedding');
}
{
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
let needsEmbedding = await t.context.copilotWorkspace.checkDocNeedEmbedded(
workspace.id,
docId
);
t.false(
needsEmbedding,
'document with recent embedding should not need embedding'
);
}
{
await t.context.doc.upsert({
spaceId: workspace.id,
docId,
blob: Uint8Array.from([4, 5, 6]),
timestamp: Date.now() + 1000, // Ensure timestamp is later
editorId: user.id,
});
let needsEmbedding = await t.context.copilotWorkspace.checkDocNeedEmbedded(
workspace.id,
docId
);
t.true(
needsEmbedding,
'document updated after embedding should need embedding'
);
}
});
test('should check embedding table', async t => {
{
const ret = await t.context.copilotWorkspace.checkEmbeddingAvailable();

View File

@@ -14,6 +14,7 @@ import {
CallMetric,
DocNotFound,
DocUpdateBlocked,
EventBus,
GatewayErrorWrapper,
metrics,
NotInSpace,
@@ -144,6 +145,7 @@ export class SpaceSyncGateway
constructor(
private readonly ac: AccessController,
private readonly event: EventBus,
private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly userspace: PgUserspaceDocStorageAdapter,
private readonly docReader: DocReader,
@@ -201,6 +203,7 @@ export class SpaceSyncGateway
await client.join(room);
}
} else {
this.event.emit('workspace.embedding', { workspaceId: spaceId });
await this.selectAdapter(client, spaceType).join(user.id, spaceId);
}

View File

@@ -175,6 +175,55 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
};
}
@Transactional()
async checkDocNeedEmbedded(workspaceId: string, docId: string) {
// NOTE: check if the document needs re-embedding.
// 1. check if there have been any recent updates to the document snapshot and update
// 2. check if the embedding is older than the snapshot and update
// 3. check if the embedding is older than 10 minutes (avoid frequent updates)
// if all conditions are met, re-embedding is required.
const result = await this.db.$queryRaw<{ needs_embedding: boolean }[]>`
SELECT
EXISTS (
WITH docs AS (
SELECT
s.workspace_id,
s.guid AS doc_id,
s.updated_at
FROM
snapshots s
WHERE
s.workspace_id = ${workspaceId}
AND s.guid = ${docId}
UNION
ALL
SELECT
u.workspace_id,
u.guid AS doc_id,
u.created_at AS updated_at
FROM
"updates" u
WHERE
u.workspace_id = ${workspaceId}
AND u.guid = ${docId}
)
SELECT
1
FROM
docs
LEFT JOIN ai_workspace_embeddings e
ON e.workspace_id = docs.workspace_id
AND e.doc_id = docs.doc_id
WHERE
e.updated_at IS NULL
OR docs.updated_at > e.updated_at
OR e.updated_at < NOW() - INTERVAL '10 minutes'
) AS needs_embedding;
`;
return result[0]?.needs_embedding ?? false;
}
// ================ embeddings ================
async checkEmbeddingAvailable(): Promise<boolean> {

View File

@@ -16,6 +16,7 @@ import { Models } from '../../../models';
import { CopilotStorage } from '../storage';
import { readStream } from '../utils';
import { OpenAIEmbeddingClient } from './embedding';
import type { Chunk, DocFragment } from './types';
import { EMBEDDING_DIMENSIONS, EmbeddingClient } from './types';
@Injectable()
@@ -78,16 +79,23 @@ export class CopilotContextDocJob {
@OnEvent('workspace.doc.embedding')
async addDocEmbeddingQueue(
docs: Events['workspace.doc.embedding'],
contextId?: string
options?: { contextId: string; priority: number }
) {
if (!this.supportEmbedding) return;
for (const { workspaceId, docId } of docs) {
await this.queue.add('copilot.embedding.docs', {
contextId,
workspaceId,
docId,
});
await this.queue.add(
'copilot.embedding.docs',
{
contextId: options?.contextId,
workspaceId,
docId,
},
{
jobId: `workspace:embedding:${workspaceId}:${docId}`,
priority: options?.priority ?? 1,
}
);
}
}
@@ -110,14 +118,26 @@ export class CopilotContextDocJob {
}: Events['workspace.embedding']) {
if (!this.supportEmbedding || !this.embeddingClient) return;
if (enableDocEmbedding === undefined) {
enableDocEmbedding =
await this.models.workspace.allowEmbedding(workspaceId);
}
if (enableDocEmbedding) {
const toBeEmbedDocIds =
await this.models.copilotWorkspace.findDocsToEmbed(workspaceId);
for (const docId of toBeEmbedDocIds) {
await this.queue.add('copilot.embedding.docs', {
workspaceId,
docId,
});
await this.queue.add(
'copilot.embedding.docs',
{
workspaceId,
docId,
},
{
jobId: `workspace:embedding:${workspaceId}:${docId}`,
priority: 1,
}
);
}
} else {
const controller = this.workspaceJobAbortController.get(workspaceId);
@@ -132,14 +152,25 @@ export class CopilotContextDocJob {
async addDocEmbeddingQueueFromEvent(doc: Events['doc.indexer.updated']) {
if (!this.supportEmbedding || !this.embeddingClient) return;
await this.queue.add('copilot.embedding.docs', {
workspaceId: doc.workspaceId,
docId: doc.workspaceId,
});
await this.queue.add(
'copilot.embedding.docs',
{
workspaceId: doc.workspaceId,
docId: doc.docId,
},
{
jobId: `workspace:embedding:${doc.workspaceId}:${doc.docId}`,
priority: 2,
}
);
}
@OnEvent('doc.indexer.deleted')
async deleteDocEmbeddingQueueFromEvent(doc: Events['doc.indexer.deleted']) {
await this.queue.remove(
`workspace:embedding:${doc.workspaceId}:${doc.docId}`,
'copilot.embedding.docs'
);
await this.models.copilotContext.deleteWorkspaceEmbedding(
doc.workspaceId,
doc.docId
@@ -221,6 +252,43 @@ export class CopilotContextDocJob {
}
}
private async getDocFragment(
workspaceId: string,
docId: string
): Promise<DocFragment | null> {
const docContent = await this.doc.getFullDocContent(workspaceId, docId);
const authors = await this.models.doc.getAuthors(workspaceId, docId);
if (docContent?.summary && authors) {
const { title = 'Untitled', summary } = docContent;
const { createdAt, updatedAt, createdByUser, updatedByUser } = authors;
return {
title,
summary,
createdAt: createdAt.toDateString(),
updatedAt: updatedAt.toDateString(),
createdBy: createdByUser?.name,
updatedBy: updatedByUser?.name,
};
}
return null;
}
private formatDocChunks(chunks: Chunk[], fragment: DocFragment): Chunk[] {
return chunks.map(chunk => ({
index: chunk.index,
content: [
`Title: ${fragment.title}`,
`Created at: ${fragment.createdAt}`,
`Updated at: ${fragment.updatedAt}`,
fragment.createdBy ? `Created by: ${fragment.createdBy}` : undefined,
fragment.updatedBy ? `Updated by: ${fragment.updatedBy}` : undefined,
chunk.content,
]
.filter(Boolean)
.join('\n'),
}));
}
private getWorkspaceSignal(workspaceId: string) {
let controller = this.workspaceJobAbortController.get(workspaceId);
if (!controller) {
@@ -241,39 +309,49 @@ export class CopilotContextDocJob {
const signal = this.getWorkspaceSignal(workspaceId);
try {
const content = await this.doc.getFullDocContent(workspaceId, docId);
if (signal.aborted) {
return;
} else if (content) {
// fast fall for empty doc, journal is easily to create a empty doc
if (content.summary) {
const embeddings = await this.embeddingClient.getFileEmbeddings(
new File([content.summary], `${content.title || 'Untitled'}.md`),
signal
);
const needEmbedding =
await this.models.copilotWorkspace.checkDocNeedEmbedded(
workspaceId,
docId
);
if (needEmbedding) {
if (signal.aborted) return;
const fragment = await this.getDocFragment(workspaceId, docId);
if (fragment) {
// fast fall for empty doc, journal is easily to create a empty doc
if (fragment.summary) {
const embeddings = await this.embeddingClient.getFileEmbeddings(
new File(
[fragment.summary],
`${fragment.title || 'Untitled'}.md`
),
chunks => this.formatDocChunks(chunks, fragment),
signal
);
for (const chunks of embeddings) {
for (const chunks of embeddings) {
await this.models.copilotContext.insertWorkspaceEmbedding(
workspaceId,
docId,
chunks
);
}
} else {
// for empty doc, insert empty embedding
const emptyEmbedding = {
index: 0,
content: '',
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0),
};
await this.models.copilotContext.insertWorkspaceEmbedding(
workspaceId,
docId,
chunks
[emptyEmbedding]
);
}
} else {
// for empty doc, insert empty embedding
const emptyEmbedding = {
index: 0,
content: '',
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0),
};
await this.models.copilotContext.insertWorkspaceEmbedding(
workspaceId,
docId,
[emptyEmbedding]
);
} else if (contextId) {
throw new DocNotFound({ spaceId: workspaceId, docId });
}
} else if (contextId) {
throw new DocNotFound({ spaceId: workspaceId, docId });
}
} catch (error: any) {
if (contextId) {

View File

@@ -498,7 +498,7 @@ export class CopilotContextResolver {
workspaceId: session.workspaceId,
docId,
})),
session.id
{ contextId: session.id, priority: 0 }
);
}
@@ -559,7 +559,7 @@ export class CopilotContextResolver {
await this.jobs.addDocEmbeddingQueue(
[{ workspaceId: session.workspaceId, docId: options.docId }],
session.id
{ contextId: session.id, priority: 0 }
);
return { ...record, status: record.status || null };

View File

@@ -3,6 +3,7 @@ import { File } from 'node:buffer';
import { z } from 'zod';
import { CopilotContextFileNotSupported } from '../../../base';
import type { PageDocContent } from '../../../core/utils/blocksuite';
import { ChunkSimilarity, Embedding } from '../../../models';
import { parseDoc } from '../../../native';
@@ -10,7 +11,7 @@ declare global {
interface Events {
'workspace.embedding': {
workspaceId: string;
enableDocEmbedding: boolean;
enableDocEmbedding?: boolean;
};
'workspace.doc.embedding': Array<{
@@ -53,6 +54,13 @@ declare global {
}
}
export type DocFragment = PageDocContent & {
createdAt: string;
createdBy?: string;
updatedAt: string;
updatedBy?: string;
};
export type Chunk = {
index: number;
content: string;
@@ -63,11 +71,12 @@ export const EMBEDDING_DIMENSIONS = 1024;
export abstract class EmbeddingClient {
async getFileEmbeddings(
file: File,
chunkMapper: (chunk: Chunk[]) => Chunk[],
signal?: AbortSignal
): Promise<Embedding[][]> {
const chunks = await this.getFileChunks(file, signal);
const chunkedEmbeddings = await Promise.all(
chunks.map(chunk => this.generateEmbeddings(chunk))
chunks.map(chunk => this.generateEmbeddings(chunkMapper(chunk)))
);
return chunkedEmbeddings;
}

View File

@@ -3,26 +3,24 @@ import { gqlFetcherFactory } from '@affine/graphql';
import { DummyConnection } from '../../connection';
const TIMEOUT = 15000;
export class HttpConnection extends DummyConnection {
readonly fetch = async (input: string, init?: RequestInit) => {
const externalSignal = init?.signal;
if (externalSignal?.aborted) {
throw externalSignal.reason;
}
const abortController = new AbortController();
externalSignal?.addEventListener('abort', reason => {
abortController.abort(reason);
});
const timeout = 15000;
const timeoutId = setTimeout(() => {
abortController.abort(new Error('request timeout'));
}, timeout);
const signals = [AbortSignal.timeout(TIMEOUT)];
if (externalSignal) signals.push(externalSignal);
const combinedSignal = AbortSignal.any(signals);
const res = await globalThis
.fetch(new URL(input, this.serverBaseUrl), {
...init,
signal: abortController.signal,
signal: combinedSignal,
headers: {
...this.requestHeaders,
...init?.headers,
@@ -30,16 +28,18 @@ export class HttpConnection extends DummyConnection {
},
})
.catch(err => {
const message =
err.name === 'TimeoutError' ? 'request timeout' : err.message;
throw new UserFriendlyError({
status: 504,
code: 'NETWORK_ERROR',
type: 'NETWORK_ERROR',
name: 'NETWORK_ERROR',
message: `Network error: ${err.message}`,
message: `Network error: ${message}`,
stacktrace: err.stack,
});
});
clearTimeout(timeoutId);
if (!res.ok && res.status !== 404) {
if (res.status === 413) {
throw new UserFriendlyError({

View File

@@ -49,7 +49,7 @@
"core-js": "^3.39.0",
"dayjs": "^1.11.13",
"eventemitter2": "^6.4.9",
"file-type": "^20.0.0",
"file-type": "^21.0.0",
"filesize": "^10.1.6",
"foxact": "^0.2.43",
"fuse.js": "^7.0.0",

View File

@@ -70,7 +70,12 @@ import {
JournalGroupHeader,
JournalValue,
} from './journal';
import { NumberDocListProperty, NumberValue } from './number';
import {
NumberDocListProperty,
NumberFilterValue,
NumberGroupHeader,
NumberValue,
} from './number';
import {
PageWidthDocListProperty,
PageWidthFilterValue,
@@ -158,8 +163,23 @@ export const WorkspacePropertyTypes = {
value: NumberValue,
name: 'com.affine.page-properties.property.number',
description: 'com.affine.page-properties.property.number.tooltips',
filterMethod: {
'<': '<',
'=': '=',
'≠': '≠',
'≥': '≥',
'≤': '≤',
'>': '>',
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
},
allowInGroupBy: true,
allowInOrderBy: true,
filterValue: NumberFilterValue,
defaultFilter: { method: 'is-not-empty' },
showInDocList: 'stack',
docListProperty: NumberDocListProperty,
groupHeader: NumberGroupHeader,
},
checkbox: {
icon: CheckBoxCheckLinearIcon,

View File

@@ -1,14 +1,20 @@
import { PropertyValue } from '@affine/component';
import { Input, Menu, type MenuRef, PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n';
import { NumberIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
type ChangeEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './number.css';
@@ -58,6 +64,109 @@ export const NumberValue = ({
);
};
export const NumberFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => {
const [tempValue, setTempValue] = useState(filter.value || '');
const [valueMenuOpen, setValueMenuOpen] = useState(false);
const menuRef = useRef<MenuRef>(null);
const t = useI18n();
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
useEffect(() => {
// update temp value with new filter value
setTempValue(filter.value || '');
}, [filter.value]);
const submitTempValue = useCallback(() => {
if (tempValue !== (filter.value || '')) {
onChange?.({
...filter,
value: tempValue,
});
}
}, [filter, onChange, tempValue]);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Escape') return;
submitTempValue();
setValueMenuOpen(false);
onDraftCompleted?.();
},
[submitTempValue, onDraftCompleted]
);
const handleInputEnter = useCallback(() => {
submitTempValue();
setValueMenuOpen(false);
onDraftCompleted?.();
}, [submitTempValue, onDraftCompleted]);
useEffect(() => {
if (
isDraft &&
(filter.method === 'is-not-empty' || filter.method === 'is-empty')
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<Menu
ref={menuRef}
rootOptions={{
open: valueMenuOpen,
onOpenChange: setValueMenuOpen,
onClose: onDraftCompleted,
}}
contentOptions={{
onPointerDownOutside: submitTempValue,
sideOffset: -28,
}}
items={
<Input
inputStyle={{
fontSize: cssVar('fontBase'),
}}
type="number"
inputMode="decimal"
autoFocus
autoSelect
value={tempValue}
onChange={value => {
setTempValue(value);
}}
onEnter={handleInputEnter}
onKeyDown={handleInputKeyDown}
style={{ height: 34, borderRadius: 4 }}
/>
}
>
{filter.value ? (
<span>{filter.value}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
) : null;
};
export const NumberDocListProperty = ({ value }: { value: number }) => {
if (value !== 0 && !value) {
return null;
@@ -65,3 +174,13 @@ export const NumberDocListProperty = ({ value }: { value: number }) => {
return <StackProperty icon={<NumberIcon />}>{value}</StackProperty>;
};
export const NumberGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
const t = useI18n();
const number = groupId || t['com.affine.filter.empty']();
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
{number}
</PlainTextDocGroupHeader>
);
};

View File

@@ -98,6 +98,16 @@ export const TagsFilterValue = ({
},
[filter, onChange, selectedTags]
);
useEffect(() => {
if (
isDraft &&
(filter.method === 'is-not-empty' || filter.method === 'is-empty')
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<WorkspaceTagsInlineEditor
placeholder={

View File

@@ -216,14 +216,25 @@ export const TextFilterValue = ({
if (e.key !== 'Escape') return;
submitTempValue();
setValueMenuOpen(false);
onDraftCompleted?.();
},
[submitTempValue]
[submitTempValue, onDraftCompleted]
);
const handleInputEnter = useCallback(() => {
submitTempValue();
setValueMenuOpen(false);
}, [submitTempValue]);
onDraftCompleted?.();
}, [submitTempValue, onDraftCompleted]);
useEffect(() => {
if (
isDraft &&
(filter.method === 'is-not-empty' || filter.method === 'is-empty')
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<Menu

View File

@@ -1,6 +1,7 @@
import { Button, Input, Modal, notify } from '@affine/component';
import { IntegrationService } from '@affine/core/modules/integration';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { PlusIcon, TodayIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
@@ -72,6 +73,11 @@ const AddSubscription = () => {
.then(() => {
setOpen(false);
setUrl('');
track.$.settingsPanel.integrationList.connectIntegration({
type: 'calendar',
control: 'Calendar Setting',
result: 'success',
});
})
.catch(() => {
notify.error({

View File

@@ -4,6 +4,7 @@ import {
IntegrationService,
} from '@affine/core/modules/integration';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useState } from 'react';
@@ -114,6 +115,10 @@ const UnsubscribeButton = ({ url, name }: { url: string; name: string }) => {
}),
onConfirm: () => {
calendar.deleteSubscription(url);
track.$.settingsPanel.integrationList.disconnectIntegration({
type: 'calendar',
control: 'Calendar Setting',
});
},
confirmText: t['com.affine.integration.calendar.unsubscribe'](),
confirmButtonOptions: {

View File

@@ -40,6 +40,7 @@ export const cardIcon = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2.icon.monotone,
});
export const cardContent = style([
spaceY,

View File

@@ -18,6 +18,7 @@ import { TrashPageFooter } from '@affine/core/components/pure/trash-page-footer'
import { TopTip } from '@affine/core/components/top-tip';
import { DocService } from '@affine/core/modules/doc';
import { EditorService } from '@affine/core/modules/editor';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { RecentDocsService } from '@affine/core/modules/quicksearch';
@@ -36,6 +37,7 @@ import { DisposableGroup } from '@blocksuite/affine/global/disposable';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
import {
AiIcon,
ExportIcon,
FrameIcon,
PropertyIcon,
TocIcon,
@@ -57,6 +59,7 @@ import { PageNotFound } from '../../404';
import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { DetailPageWrapper } from './detail-page-wrapper';
import { EditorAdapterPanel } from './tabs/adapter';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
@@ -103,6 +106,11 @@ const DetailPageImpl = memo(function DetailPageImpl() {
const enableAI = useEnableAI();
const featureFlagService = useService(FeatureFlagService);
const enableAdapterPanel = useLiveData(
featureFlagService.flags.enable_adapter_panel.$
);
useEffect(() => {
if (isActiveView) {
setActiveBlockSuiteEditor(editorContainer);
@@ -360,6 +368,16 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</Scrollable.Root>
</ViewSidebarTab>
{enableAdapterPanel && (
<ViewSidebarTab tabId="adapter" icon={<ExportIcon />}>
<Scrollable.Root className={styles.sidebarScrollArea}>
<Scrollable.Viewport>
<EditorAdapterPanel host={editorContainer?.host ?? null} />
</Scrollable.Viewport>
</Scrollable.Root>
</ViewSidebarTab>
)}
<GlobalPageHistoryModal />
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
</FrameworkScope>

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,74 @@
import { ServerService } from '@affine/core/modules/cloud';
import { AdapterPanel } from '@blocksuite/affine/fragments/adapter-panel';
import {
customImageProxyMiddleware,
docLinkBaseURLMiddlewareBuilder,
embedSyncedDocMiddleware,
titleMiddleware,
} from '@blocksuite/affine/shared/adapters';
import type { EditorHost } from '@blocksuite/affine/std';
import type { TransformerMiddleware } from '@blocksuite/affine/store';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useRef } from 'react';
import * as styles from './adapter.css';
const createImageProxyUrl = (baseUrl: string) => {
try {
return new URL(BUILD_CONFIG.imageProxyUrl, baseUrl).toString();
} catch (error) {
console.error('Failed to create image proxy url', error);
return '';
}
};
const createMiddlewares = (
host: EditorHost,
baseUrl: string
): TransformerMiddleware[] => {
const imageProxyUrl = createImageProxyUrl(baseUrl);
return [
docLinkBaseURLMiddlewareBuilder(baseUrl, host.store.workspace.id).get(),
titleMiddleware(host.store.workspace.meta.docMetas),
embedSyncedDocMiddleware('content'),
customImageProxyMiddleware(imageProxyUrl),
];
};
const getTransformerMiddlewares = (
host: EditorHost | null,
baseUrl: string
) => {
if (!host) return [];
return createMiddlewares(host, baseUrl);
};
// A wrapper for AdapterPanel
export const EditorAdapterPanel = ({ host }: { host: EditorHost | null }) => {
const server = useService(ServerService).server;
const adapterPanelRef = useRef<AdapterPanel | null>(null);
const onRefChange = useCallback(
(container: HTMLDivElement | null) => {
if (container && host && container.children.length === 0) {
adapterPanelRef.current = new AdapterPanel();
adapterPanelRef.current.store = host.store;
adapterPanelRef.current.transformerMiddlewares =
getTransformerMiddlewares(host, server.baseUrl);
container.append(adapterPanelRef.current);
}
},
[host, server]
);
useEffect(() => {
if (host && adapterPanelRef.current) {
adapterPanelRef.current.store = host.store;
adapterPanelRef.current.transformerMiddlewares =
getTransformerMiddlewares(host, server.baseUrl);
}
}, [host, server]);
return <div className={styles.root} ref={onRefChange} />;
};

View File

@@ -10,6 +10,7 @@ import { JournalService } from '@affine/core/modules/journal';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { FullDayIcon, PeriodIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
@@ -94,6 +95,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
await docsService.changeDocTitle(newDoc.id, title);
await docsService.addLinkedDoc(doc.id, newDoc.id);
}
track.doc.sidepanel.journal.createCalendarDocEvent();
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,97 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class NumberPropertyFilterProvider
extends Service
implements FilterProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'number'>;
const values$ = this.docsService.propertyValues$('custom:' + params.key);
const filterValue = Number(params.value);
if (method === 'is-not-empty') {
return values$.pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value !== undefined && value !== null && value !== '') {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-empty') {
return values$.pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value === undefined || value === null || value === '') {
match.add(id);
}
}
return match;
})
);
} else if (
method === '=' ||
method === '≠' ||
method === '>' ||
method === '<' ||
method === '≥' ||
method === '≤'
) {
return values$.pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
const numValue = Number(value);
switch (method) {
case '=':
if (Math.abs(numValue - filterValue) < Number.EPSILON) {
match.add(id);
}
break;
case '≠':
if (Math.abs(numValue - filterValue) >= Number.EPSILON) {
match.add(id);
}
break;
case '>':
if (numValue > filterValue) {
match.add(id);
}
break;
case '<':
if (numValue < filterValue) {
match.add(id);
}
break;
case '≥':
if (numValue >= filterValue) {
match.add(id);
}
break;
case '≤':
if (numValue <= filterValue) {
match.add(id);
}
break;
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${method}`);
}
}

View File

@@ -0,0 +1,38 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class NumberPropertyGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const result = new Map<string, Set<string>>();
for (const [id, value] of o) {
const number = Number(value);
if (Number.isNaN(number)) {
continue;
}
// normalize all number to string
const strValue = String(number);
const set = result.get(strValue) ?? new Set<string>();
set.add(id);
result.set(strValue, set);
}
return result;
})
);
}
}

View File

@@ -0,0 +1,30 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class NumberPropertyOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
return Array.from(o)
.map(v => [v[0], Number(v[1])])
.filter((i): i is [string, number] => !Number.isNaN(i[1])) // filter NaN value
.sort((a, b) => (a[1] - b[1]) * (isDesc ? -1 : 1))
.map(i => i[0]);
})
);
}
}

View File

@@ -17,6 +17,7 @@ import { EmptyJournalFilterProvider } from './impls/filters/empty-journal';
import { FavoriteFilterProvider } from './impls/filters/favorite';
import { IntegrationTypeFilterProvider } from './impls/filters/integration-type';
import { JournalFilterProvider } from './impls/filters/journal';
import { NumberPropertyFilterProvider } from './impls/filters/number';
import { PageWidthFilterProvider } from './impls/filters/page-width';
import { PropertyFilterProvider } from './impls/filters/property';
import { SharedFilterProvider } from './impls/filters/shared';
@@ -36,6 +37,7 @@ import { DocPrimaryModeGroupByProvider } from './impls/group-by/doc-primary-mode
import { EdgelessThemeGroupByProvider } from './impls/group-by/edgeless-theme';
import { IntegrationTypeGroupByProvider } from './impls/group-by/integration-type';
import { JournalGroupByProvider } from './impls/group-by/journal';
import { NumberPropertyGroupByProvider } from './impls/group-by/number';
import { PageWidthGroupByProvider } from './impls/group-by/page-width';
import { PropertyGroupByProvider } from './impls/group-by/property';
import { SystemGroupByProvider } from './impls/group-by/system';
@@ -52,6 +54,7 @@ import { DocPrimaryModeOrderByProvider } from './impls/order-by/doc-primary-mode
import { EdgelessThemeOrderByProvider } from './impls/order-by/edgeless-theme';
import { IntegrationTypeOrderByProvider } from './impls/order-by/integration-type';
import { JournalOrderByProvider } from './impls/order-by/journal';
import { NumberPropertyOrderByProvider } from './impls/order-by/number';
import { PageWidthOrderByProvider } from './impls/order-by/page-width';
import { PropertyOrderByProvider } from './impls/order-by/property';
import { SystemOrderByProvider } from './impls/order-by/system';
@@ -81,6 +84,9 @@ export function configureCollectionRulesModule(framework: Framework) {
.impl(FilterProvider('property:text'), TextPropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:number'), NumberPropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:tags'), TagsFilterProvider, [
TagService,
DocsService,
@@ -196,6 +202,9 @@ export function configureCollectionRulesModule(framework: Framework) {
.impl(GroupByProvider('property:text'), TextPropertyGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:number'), NumberPropertyGroupByProvider, [
DocsService,
])
.impl(
GroupByProvider('property:docPrimaryMode'),
DocPrimaryModeGroupByProvider,
@@ -293,6 +302,9 @@ export function configureCollectionRulesModule(framework: Framework) {
.impl(OrderByProvider('property:text'), TextPropertyOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:number'), NumberPropertyOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:date'), DatePropertyOrderByProvider, [
DocsService,
])

View File

@@ -327,6 +327,15 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: true,
},
enable_adapter_panel: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-adapter-panel.name',
description:
'com.affine.settings.workspace.experimental-features.enable-adapter-panel.description',
configurable: isCanaryBuild,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare

View File

@@ -25,7 +25,7 @@ export type WorkspacePropertyTypes = {
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
};
number: {
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
filter: '=' | '≠' | '>' | '<' | '≥' | '≤' | 'is-not-empty' | 'is-empty';
};
checkbox: {
filter: 'is' | 'is-not';

View File

@@ -5,7 +5,7 @@
"de": 94,
"el-GR": 94,
"en": 100,
"es-AR": 95,
"es-AR": 94,
"es-CL": 96,
"es": 94,
"fa": 94,

View File

@@ -5890,6 +5890,14 @@ export function useAFFiNEI18N(): {
* `Once enabled, you can preview HTML in code block.`
*/
["com.affine.settings.workspace.experimental-features.enable-code-block-html-preview.description"](): string;
/**
* `Adapter Panel`
*/
["com.affine.settings.workspace.experimental-features.enable-adapter-panel.name"](): string;
/**
* `Once enabled, you can preview adapter export content in the right side bar.`
*/
["com.affine.settings.workspace.experimental-features.enable-adapter-panel.description"](): string;
/**
* `Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.`
*/

View File

@@ -1471,6 +1471,8 @@
"com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.description": "Once enabled, switch table view to virtual scroll mode in Database Block.",
"com.affine.settings.workspace.experimental-features.enable-code-block-html-preview.name": "Code block HTML preview",
"com.affine.settings.workspace.experimental-features.enable-code-block-html-preview.description": "Once enabled, you can preview HTML in code block.",
"com.affine.settings.workspace.experimental-features.enable-adapter-panel.name": "Adapter Panel",
"com.affine.settings.workspace.experimental-features.enable-adapter-panel.description": "Once enabled, you can preview adapter export content in the right side bar.",
"com.affine.settings.workspace.not-owner": "Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.",
"com.affine.settings.workspace.preferences": "Preference",
"com.affine.settings.workspace.billing": "Team's Billing",

View File

@@ -163,7 +163,8 @@ type IntegrationEvents =
| 'selectIntegrationImport'
| 'confirmIntegrationImport'
| 'abortIntegrationImport'
| 'completeIntegrationImport';
| 'completeIntegrationImport'
| 'createCalendarDocEvent';
// END SECTION
// SECTION: journal
@@ -455,6 +456,7 @@ interface PageEvents extends PageDivision {
};
sidepanel: {
property: ['addProperty', 'editPropertyMeta'];
journal: ['createCalendarDocEvent'];
};
biDirectionalLinksPanel: {
$: ['toggle'];
@@ -576,7 +578,11 @@ type ImportArgs = {
};
type IntegrationArgs<T extends Record<string, any>> = {
type: string;
control: 'Readwise Card' | 'Readwise settings' | 'Readwise import list';
control:
| 'Readwise Card'
| 'Readwise settings'
| 'Readwise import list'
| 'Calendar Setting';
} & T;
type RecordingEventArgs = {

View File

@@ -2,6 +2,7 @@ import { expect, type Page } from '@playwright/test';
import { lightThemeV2 } from '@toeverything/theme/v2';
import {
assertEdgelessShapeType,
assertEdgelessTool,
changeShapeFillColor,
changeShapeFillColorToTransparent,
@@ -788,3 +789,21 @@ test('shape should be editable when re-enter canvas', async ({ page }) => {
await dblclickView(page, [50, 50]);
await expect(page.locator('edgeless-shape-text-editor')).toBeAttached();
});
test('shape tool should not be changed after adding new shape', async ({
page,
}) => {
await enterPlaygroundRoom(page);
await initEmptyEdgelessState(page);
await switchEditorMode(page);
await setEdgelessTool(page, 'shape');
await page.keyboard.press('s');
await waitNextFrame(page);
await assertEdgelessShapeType(page, 'ellipse');
await clickView(page, [0, 0]);
await page.keyboard.press('s');
await waitNextFrame(page);
await assertEdgelessShapeType(page, 'ellipse');
});

View File

@@ -86,9 +86,14 @@ test('toggle shapes shortcut', async ({ page }) => {
'roundedRect',
] as ShapeName[];
for (const shape of shapesInOrder) {
await page.keyboard.press('Shift+s');
await page.keyboard.press('s');
await assertEdgelessShapeType(page, shape);
}
for (const shape of shapesInOrder.reverse()) {
await assertEdgelessShapeType(page, shape);
await page.keyboard.press('Shift+s');
}
});
test('should not switch shapes in editing', async ({ page }) => {

View File

@@ -28,6 +28,7 @@ export const PackageList = [
'blocksuite/affine/components',
'blocksuite/affine/ext-loader',
'blocksuite/affine/foundation',
'blocksuite/affine/fragments/adapter-panel',
'blocksuite/affine/fragments/doc-title',
'blocksuite/affine/fragments/frame-panel',
'blocksuite/affine/fragments/outline',
@@ -475,6 +476,19 @@ export const PackageList = [
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/fragments/adapter-panel',
name: '@blocksuite/affine-fragment-adapter-panel',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/ext-loader',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/global',
'blocksuite/framework/std',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/fragments/doc-title',
name: '@blocksuite/affine-fragment-doc-title',
@@ -1475,6 +1489,7 @@ export type PackageName =
| '@blocksuite/data-view'
| '@blocksuite/affine-ext-loader'
| '@blocksuite/affine-foundation'
| '@blocksuite/affine-fragment-adapter-panel'
| '@blocksuite/affine-fragment-doc-title'
| '@blocksuite/affine-fragment-frame-panel'
| '@blocksuite/affine-fragment-outline'

View File

@@ -75,6 +75,7 @@
{ "path": "./blocksuite/affine/data-view" },
{ "path": "./blocksuite/affine/ext-loader" },
{ "path": "./blocksuite/affine/foundation" },
{ "path": "./blocksuite/affine/fragments/adapter-panel" },
{ "path": "./blocksuite/affine/fragments/doc-title" },
{ "path": "./blocksuite/affine/fragments/frame-panel" },
{ "path": "./blocksuite/affine/fragments/outline" },

View File

@@ -441,7 +441,7 @@ __metadata:
dayjs: "npm:^1.11.13"
eventemitter2: "npm:^6.4.9"
fake-indexeddb: "npm:^6.0.0"
file-type: "npm:^20.0.0"
file-type: "npm:^21.0.0"
filesize: "npm:^10.1.6"
foxact: "npm:^0.2.43"
fuse.js: "npm:^7.0.0"
@@ -2413,7 +2413,7 @@ __metadata:
"@lit/context": "npm:^1.1.2"
"@preact/signals-core": "npm:^1.8.0"
"@toeverything/theme": "npm:^1.1.14"
file-type: "npm:^20.0.0"
file-type: "npm:^21.0.0"
lit: "npm:^3.2.0"
minimatch: "npm:^10.0.1"
rxjs: "npm:^7.8.1"
@@ -2733,7 +2733,7 @@ __metadata:
"@lit/context": "npm:^1.1.2"
"@preact/signals-core": "npm:^1.8.0"
"@toeverything/theme": "npm:^1.1.14"
file-type: "npm:^20.0.0"
file-type: "npm:^21.0.0"
lit: "npm:^3.2.0"
minimatch: "npm:^10.0.1"
rxjs: "npm:^7.8.1"
@@ -3067,6 +3067,27 @@ __metadata:
languageName: unknown
linkType: soft
"@blocksuite/affine-fragment-adapter-panel@workspace:*, @blocksuite/affine-fragment-adapter-panel@workspace:blocksuite/affine/fragments/adapter-panel":
version: 0.0.0-use.local
resolution: "@blocksuite/affine-fragment-adapter-panel@workspace:blocksuite/affine/fragments/adapter-panel"
dependencies:
"@blocksuite/affine-components": "workspace:*"
"@blocksuite/affine-ext-loader": "workspace:*"
"@blocksuite/affine-model": "workspace:*"
"@blocksuite/affine-shared": "workspace:*"
"@blocksuite/global": "workspace:*"
"@blocksuite/icons": "npm:^2.2.12"
"@blocksuite/std": "workspace:*"
"@blocksuite/store": "workspace:*"
"@floating-ui/dom": "npm:^1.6.13"
"@lit/context": "npm:^1.1.2"
"@preact/signals-core": "npm:^1.8.0"
"@toeverything/theme": "npm:^1.1.14"
lit: "npm:^3.2.0"
rxjs: "npm:^7.8.1"
languageName: unknown
linkType: soft
"@blocksuite/affine-fragment-doc-title@workspace:*, @blocksuite/affine-fragment-doc-title@workspace:blocksuite/affine/fragments/doc-title":
version: 0.0.0-use.local
resolution: "@blocksuite/affine-fragment-doc-title@workspace:blocksuite/affine/fragments/doc-title"
@@ -4151,6 +4172,7 @@ __metadata:
"@blocksuite/affine-components": "workspace:*"
"@blocksuite/affine-ext-loader": "workspace:*"
"@blocksuite/affine-foundation": "workspace:*"
"@blocksuite/affine-fragment-adapter-panel": "workspace:*"
"@blocksuite/affine-fragment-doc-title": "workspace:*"
"@blocksuite/affine-fragment-frame-panel": "workspace:*"
"@blocksuite/affine-fragment-outline": "workspace:*"
@@ -4371,7 +4393,7 @@ __metadata:
"@types/lodash.clonedeep": "npm:^4.5.9"
"@types/lodash.ismatch": "npm:^4.4.9"
"@types/lodash.merge": "npm:^4.6.9"
file-type: "npm:^20.0.0"
file-type: "npm:^21.0.0"
lib0: "npm:^0.2.97"
lodash.clonedeep: "npm:^4.5.0"
lodash.ismatch: "npm:^4.4.0"
@@ -14658,7 +14680,7 @@ __metadata:
languageName: node
linkType: hard
"@tokenizer/inflate@npm:^0.2.6":
"@tokenizer/inflate@npm:^0.2.6, @tokenizer/inflate@npm:^0.2.7":
version: 0.2.7
resolution: "@tokenizer/inflate@npm:0.2.7"
dependencies:
@@ -21834,7 +21856,7 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:20.5.0, file-type@npm:^20.0.0":
"file-type@npm:20.5.0":
version: 20.5.0
resolution: "file-type@npm:20.5.0"
dependencies:
@@ -21846,6 +21868,18 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^21.0.0":
version: 21.0.0
resolution: "file-type@npm:21.0.0"
dependencies:
"@tokenizer/inflate": "npm:^0.2.7"
strtok3: "npm:^10.2.2"
token-types: "npm:^6.0.0"
uint8array-extras: "npm:^1.4.0"
checksum: 10/6980e8b0ef870a98b51ab2eac5db94a1884de8476fe49dc02d2f7e0c1d1d7d44d42b6c59e67867ae90f321ddf4edd00fcfda01821591e2fa05385d0e438a9dc1
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"