Compare commits

..

39 Commits

Author SHA1 Message Date
yoyoyohamapi
2dc69a3bef feat(core): block diff ui 2025-06-19 11:30:58 +08:00
yoyoyohamapi
e8d774a2ad feat(core): markdown-diff & patch apply 2025-06-18 11:12:39 +08:00
liuyi
a1abb60dec fix(server): should save end date of subscription in db (#12814)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Subscription end dates are now correctly saved and updated for Stripe
subscriptions, ensuring accurate display and management of subscription
periods.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-13 18:31:18 +08:00
EYHN
04f3d88e2c feat(nbstore): add more data to indexer (#12815)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Improved display of image and attachment blocks by ensuring image
captions are shown as content for images, while attachment names remain
as content for attachments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #12815** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

---------

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-06-13 18:30:57 +08:00
DarkSky
e98f035f97 feat(server): split embedding client (#12809) 2025-06-13 12:37:05 +08:00
fengmk2
1d4bc81e90 fix(server): use Query instead of Args (#12813)
#### PR Dependency Tree


* **PR #12813** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-06-13 04:12:54 +00:00
EYHN
deeea3428e feat(core): update build in server config (#12807)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added support for Apple as an OAuth login provider across all server
configurations.
	- Introduced Copilot Embedding as a new server feature.

- **Changes**
- Updated server features to replace Captcha with Indexer in all
configurations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-13 10:28:49 +08:00
fengmk2
8b0dd3c067 fix(server): increase doc-service liveness probe timeout from 1s to 5s (#12804)
yjs operations may take more than 1 second

before

![image](https://github.com/user-attachments/assets/feb7e375-ea73-4f5f-84f2-d85934f94844)

after

![image](https://github.com/user-attachments/assets/e760ee1f-a1c6-4f9d-8dbc-a4796efbd77a)


#### PR Dependency Tree


* **PR #12804** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

## Summary by CodeRabbit

- **New Features**
- Added configurable timeout settings for liveness and readiness probes
in Helm chart deployments, allowing users to specify probe timeout
duration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 21:04:37 +08:00
Richard Lora
8ca17864f1 fix(editor): show added or deleted rows immediately in grouped table and Kanban views (#12731)
https://github.com/user-attachments/assets/214fbe4f-b667-44b7-85a3-77ef4cfa8cca

This PR fixes a bug where adding or deleting rows in a grouped table
view did not visually update the UI until the user manually refreshed
the page or navigated away and back. The issue gave the impression that
the action had not completed.

Same issue for Kanban cards.

The result now is:
Users now see new rows or deleted rows reflected in real-time without
needing to reload or navigate away. This applies to both grouped table
views and Kanban cards.

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

- **Bug Fixes**
- Ensured the UI updates immediately after adding, deleting, or moving
cards and rows in Kanban and Table views on both mobile and desktop.
- Fixed issues where UI changes were not reflected after certain
actions, such as ungrouping, deleting, or inserting items.
- Improved row locking behavior during add and delete operations to
prevent UI inconsistencies.
- **Tests**
- Added comprehensive tests for row operations and menu interactions to
verify UI updates and correct method calls in data views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: zzj3720 <zuozijian1994@gmail.com>
2025-06-12 16:02:37 +08:00
Aadi
d2664480f7 fix(editor): unable to delete content backward in database title cell (#12738)
fix: https://github.com/toeverything/blocksuite/issues/8578

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

- **New Features**
- Improved keyboard event handling within inline editors for database
headers and table cells, enhancing user control over key interactions
like 'Tab' and 'Escape'.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 15:58:52 +08:00
Aadi
b986a39da3 fix(editor): cursor not visible on empty line when line numbers are disabled in code block (#12694)
This fixes an issue where, if line numbers are turned off in a code
block, focusing on an empty line would cause the text cursor to be
hidden.

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

- **Style**
- Updated code block layout to improve appearance when line numbers are
disabled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
2025-06-12 15:52:54 +08:00
Aadi
097a63362c fix(editor): firefox can't paste image in edgeless (#12729)
fix: https://github.com/toeverything/blocksuite/issues/8718
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **Bug Fixes**
- Improved clipboard handling to prevent creating empty notes when
pasting blank text.
- Enhanced detection of files and SVG images in clipboard content for
more reliable pasting behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 15:52:14 +08:00
EYHN
7284320355 fix(nbstore): fix search slow (#12800)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved search results by ensuring links to the current document are
properly excluded from related document and database searches.
- Enhanced session management in quick search by ensuring all active
sessions are cleaned up before processing new search results.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 15:43:55 +08:00
DarkSky
b4401a8abf chore(server): bump model version (#12798) 2025-06-12 07:40:28 +00:00
Aadi
0351fbcb86 fix(editor): remove duplicate event listeners (#12735)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved keyboard interaction reliability in text and rich text cells
by eliminating duplicate event listeners.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 15:13:54 +08:00
Peng Xiao
6eed9c686b fix(component): fix storybook migration v9 (#12797) 2025-06-12 07:01:58 +00:00
L-Sun
8d2214424c fix(editor): behavior of deleting at the start of line (#12787)
Close BS-3182, #12736 




#### PR Dependency Tree


* **PR #12787** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

## Summary by CodeRabbit

- **Bug Fixes**
- Improved the behavior when deleting empty lines and merging blocks,
ensuring more accurate handling of block deletion and cursor focus in
various scenarios.
- **Tests**
- Added new end-to-end tests to verify correct deletion of lines in
edgeless text and paragraph blocks, including checks for block removal
and cursor position.
- Introduced a utility function to retrieve block IDs for testing
purposes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 14:58:03 +08:00
Peng Xiao
d12954f8c3 fix(electron): web dev server ws does not work for electron renderer (#12711) 2025-06-12 14:44:30 +08:00
DarkSky
83733cd828 feat(server): improve prompts (#12788) 2025-06-12 13:31:16 +08:00
DarkSky
ed56f076ed feat(server): improve rerank performance (#12775)
fix AI-183
2025-06-12 13:31:01 +08:00
Cats Juice
2d17c265ca fix: memory leak due to missing unsubscribe (#12777)
- unsubscribe `Signal` not correctly
- missing un-subscription for `Livedata.signal`

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

- **Bug Fixes**
- Improved resource management to ensure subscriptions are properly
cleaned up, reducing potential memory leaks and improving overall app
stability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 12:35:14 +08:00
L-Sun
2a9f7e1835 fix(editor): can not clear embed card alias description (#12794)
Close
[BS-3600](https://linear.app/affine-design/issue/BS-3600/card-view下,编辑alias清空保存无效,无法实现清空)
Close
[BS-3599](https://linear.app/affine-design/issue/BS-3599/page-和canvas-mode下的note,对文本中的-embed-view-doc,隐藏掉-edit-按钮,避免误导,card)



#### PR Dependency Tree


* **PR #12794** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

- **New Features**
- The edit option has been removed from the toolbar for embedded synced
documents.
- **Bug Fixes**
- The description field is now always included when editing embedded
cards, even if left empty.
- **Tests**
- Removed tests related to editing and view switching of linked document
embeds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-12 12:33:57 +08:00
Fangdun Tsai
a71904e641 feat(editor): add replace action in attachment toolbar (#12512)
Closes:
[BS-3549](https://linear.app/affine-design/issue/BS-3549/附件-toolbar-上添加-replace-action)

[Screen Recording 2025-06-04 at 15.37.40.mov <span
class="graphite__hidden">(uploaded via Graphite)</span> <img
class="graphite__hidden"
src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/8ypiIKZXudF5a0tIgIzf/480c8690-7ec9-4188-92fd-ee3339afb558.mov"
/>](https://app.graphite.dev/media/video/8ypiIKZXudF5a0tIgIzf/480c8690-7ec9-4188-92fd-ee3339afb558.mov)



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

- **New Features**
- Added the ability to replace attachments directly from the toolbar,
allowing users to select and update files seamlessly.
- **Bug Fixes**
- Improved handling when replacing embedded attachments with unsupported
file types, ensuring the view falls back to a card view as needed.
- **Tests**
- Introduced end-to-end tests to verify attachment replacement and
correct UI behavior in both standard and edgeless editing modes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-11 06:57:31 +00:00
L-Sun
814364489f chore(editor): remove unused codes (#12784)
Continue #12778 

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

## Summary by CodeRabbit

- **Chores**
- Removed internal string utility functions related to markdown prefix
and horizontal rule detection. No impact on user-facing features.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-11 06:40:51 +00:00
L-Sun
24448659a4 fix(editor): support markdown transform when using IME (#12778)
Fix #12284 
Close
[BS-3517](https://linear.app/affine-design/issue/BS-3517/微软新注音输入法无法使用markdown语法)

This PR refactor the markdown transform during inputting, including:
- Transfrom markdown syntax input in `inlineEditor.slots.inputting`,
where we can detect the space character inputed by IME like Microsoft
Bopomofo, but `keydown` event can't.
- Remove `markdown-input.ts` which was used in `KeymapExtension` of
paragraph, and refactor with `InlineMarkdownExtension`
- Adjust existing `InlineMarkdownExtension` since the space is included
in text.
- Add two `InlineMarkdownExtension` for paragraph and list to impl
Heading1-6, number, bullet, to-do list conversion.

Other changes:
- Improve type hint for parameter of `store.addBlock`

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

## Summary by CodeRabbit

- **New Features**
- Added markdown shortcuts for creating code blocks and dividers in the
rich text editor.
- Introduced enhanced paragraph markdown support for headings and
blockquotes with inline markdown patterns.
- Integrated new list markdown extension supporting numbered, bulleted,
and todo lists with checked states.

- **Improvements**
- Updated markdown formatting patterns to require trailing spaces for
links, LaTeX, and inline styles, improving detection accuracy.
- Markdown transformations now respond to input events instead of
keydown for smoother editing experience.
- Added focus management after markdown transformations to maintain
seamless editing flow.

- **Bug Fixes**
- Removed unnecessary prevention of default behavior on space and
shift-space key presses in list and paragraph editors.

- **Refactor**
- Enhanced event handling and typing for editor input events, improving
reliability and maintainability.
- Refined internal prefix text extraction logic for markdown processing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-11 14:12:28 +08:00
darkskygit
c846c57a12 fix(server): list context status (#12771)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Bug Fixes**
  - Improved handling of document statuses to ensure documents without a finished or existing status are now explicitly marked as "processing" instead of remaining undefined.

- **Tests**
  - Added comprehensive new tests and snapshot entries to verify document status merging, including edge cases and concurrent operations, ensuring robust and consistent behavior.

- **Enhancements**
  - Updated context document listings to display the processing status for relevant documents.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-10 02:35:09 +00:00
L-Sun
e82c9d2ddc fix(editor): add title to edgeless page block ai context (#12763)
Close [BS-3590](https://linear.app/affine-design/issue/BS-3590/page-block-的标题没有被作为上下文输入)
2025-06-10 02:14:01 +00:00
yoyoyohamapi
3c29f62224 refactor(core): hide emebedding status tip if completed (#12720)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added real-time embedding status tracking and progress messages to the AI chat composer, with automatic updates every 10 seconds.
- **Refactor**
  - Simplified the embedding status tooltip to display a static message, removing dynamic status updates and hover-based refresh.
- **Tests**
  - Enhanced embedding status tooltip test by creating sample documents and extending visibility timeout to 50 seconds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-10 01:54:13 +00:00
fundon
b5ef361f87 fix(editor): toolbar shaking when setting inline style (#12765)
Closes: [BS-1748](https://linear.app/affine-design/issue/BS-1748/improvement-toolbar-应用样式后会移动)

[Screen Recording 2025-06-09 at 17.59.01.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/8ypiIKZXudF5a0tIgIzf/a3941ec8-6b97-48e5-ba9b-484deb792d44.mov" />](https://app.graphite.dev/media/video/8ypiIKZXudF5a0tIgIzf/a3941ec8-6b97-48e5-ba9b-484deb792d44.mov)

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

## Summary by CodeRabbit

- **New Features**
  - Improved animation and transition effects for inline toolbars, providing a smoother user experience when toolbars appear or disappear.
- **Bug Fixes**
  - Ensured that the inline attribute is correctly removed when the toolbar is hidden, preventing visual inconsistencies.
- **Style**
  - Updated toolbar transition timing for more natural animations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-09 11:19:13 +00:00
mkihr-ojisan
4fa85416ae fix: size of canvas in PDFPageRenderer (#12722)
Co-authored-by: Fangdun Tsai <fundon@pindash.io>
2025-06-09 14:37:44 +08:00
Aadi
f69a98eb8c fix(editor): pasting into database multiselect input auto adds a new tag (#12693)
Co-authored-by: 3720 <zuozijian1994@gmail.com>
2025-06-09 09:37:54 +08:00
renovate
115496aa8e chore: bump up nestjs to v11.1.3 (#12737)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@nestjs/common](https://nestjs.com) ([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common)) | [`11.1.2` -> `11.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.2/11.1.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@nestjs%2fcommon/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@nestjs%2fcommon/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@nestjs/core](https://nestjs.com) ([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core)) | [`11.1.2` -> `11.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.2/11.1.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@nestjs%2fcore/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@nestjs%2fcore/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@nestjs/platform-express](https://nestjs.com) ([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express)) | [`11.1.2` -> `11.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.2/11.1.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@nestjs%2fplatform-express/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@nestjs%2fplatform-express/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@nestjs/platform-socket.io](https://nestjs.com) ([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io)) | [`11.1.2` -> `11.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.2/11.1.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@nestjs%2fplatform-socket.io/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@nestjs%2fplatform-socket.io/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest) ([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets)) | [`11.1.2` -> `11.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.2/11.1.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@nestjs%2fwebsockets/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@nestjs%2fwebsockets/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.2/11.1.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

### [`v11.1.3`](https://redirect.github.com/nestjs/nest/compare/v11.1.2...1613f503cfd3fa5a6edddc0d7296aeca78f844a7)

[Compare Source](https://redirect.github.com/nestjs/nest/compare/v11.1.2...v11.1.3)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/core)</summary>

### [`v11.1.3`](https://redirect.github.com/nestjs/nest/compare/v11.1.2...1613f503cfd3fa5a6edddc0d7296aeca78f844a7)

[Compare Source](https://redirect.github.com/nestjs/nest/compare/v11.1.2...v11.1.3)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-express)</summary>

### [`v11.1.3`](https://redirect.github.com/nestjs/nest/compare/v11.1.2...1613f503cfd3fa5a6edddc0d7296aeca78f844a7)

[Compare Source](https://redirect.github.com/nestjs/nest/compare/v11.1.2...v11.1.3)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-socket.io)</summary>

### [`v11.1.3`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.3)

[Compare Source](https://redirect.github.com/nestjs/nest/compare/v11.1.2...v11.1.3)

#### v11.1.3 (2025-06-06)

##### Bug fixes

-   `core`
    -   [#&#8203;15201](https://redirect.github.com/nestjs/nest/pull/15201) fix(core): gracefully shutdown the app when repl exits ([@&#8203;dzhlobo](https://redirect.github.com/dzhlobo))

##### Enhancements

-   `common`
    -   [#&#8203;15209](https://redirect.github.com/nestjs/nest/pull/15209) feat: add string array type to disposition ([@&#8203;fjodor-rybakov](https://redirect.github.com/fjodor-rybakov))
-   `common`, `core`
    -   [#&#8203;15203](https://redirect.github.com/nestjs/nest/pull/15203) feat(core): defer initialization connected microservice ([@&#8203;isaryy](https://redirect.github.com/isaryy))

##### Dependencies

-   `platform-express`
    -   [#&#8203;15232](https://redirect.github.com/nestjs/nest/pull/15232) chore(deps): bump multer from 2.0.0 to 2.0.1 ([@&#8203;dependabot\[bot\]](https://redirect.github.com/apps/dependabot))

##### Committers: 3

-   Dmitry Zhlobo ([@&#8203;dzhlobo](https://redirect.github.com/dzhlobo))
-   [@&#8203;fjodor-rybakov](https://redirect.github.com/fjodor-rybakov)
-   [@&#8203;isaryy](https://redirect.github.com/isaryy)

</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 these updates 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:eyJjcmVhdGVkSW5WZXIiOiI0MC40MC4zIiwidXBkYXRlZEluVmVyIjoiNDAuNDAuMyIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2025-06-07 09:03:02 +00:00
EYHN
7aafbf12a5 chore(ios): update ios app version (#12734)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **Chores**
  - Updated the app version number to 0.22.2 for iOS.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-06 08:54:24 +00:00
pengx17
0f9b7d4a0d fix(electron): increase recording timeout (#12733)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Refactor**
  - Removed the automatic timeout that stopped recordings after 1.5 hours. Recordings will no longer be stopped automatically based on duration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-06 08:40:05 +00:00
renovate
2817b5aec4 chore: bump up @googleapis/androidpublisher version to v28 (#12713)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@googleapis/androidpublisher](https://redirect.github.com/googleapis/google-api-nodejs-client) | [`^27.0.0` -> `^28.0.0`](https://renovatebot.com/diffs/npm/@googleapis%2fandroidpublisher/27.0.0/28.0.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@googleapis%2fandroidpublisher/28.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@googleapis%2fandroidpublisher/28.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@googleapis%2fandroidpublisher/27.0.0/28.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@googleapis%2fandroidpublisher/27.0.0/28.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>googleapis/google-api-nodejs-client (@&#8203;googleapis/androidpublisher)</summary>

### [`v28.0.1`](https://redirect.github.com/googleapis/google-api-nodejs-client/releases/tag/v28.0.1): 28.0.1

[Compare Source](https://redirect.github.com/googleapis/google-api-nodejs-client/compare/v27.0.0...v28.0.1)

This release fixes the release bug in v28.0.0.

</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:eyJjcmVhdGVkSW5WZXIiOiI0MC40MC4zIiwidXBkYXRlZEluVmVyIjoiNDAuNDAuMyIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2025-06-06 08:24:22 +00:00
forehalo
72e66aca11 ci: do not deny rust nightly warnings 2025-06-06 12:02:36 +08:00
fundon
7d1f2adb7f fix(editor): support copying single image from edgeless and pasting to page (#12709)
Closes: [BS-3586](https://linear.app/affine-design/issue/BS-3586/复制白板图片,然后粘贴到-page,图片失败)

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

- **New Features**
  - Copying a single selected image in edgeless mode now places the image directly onto the system clipboard as a native image blob for smoother pasting.

- **Bug Fixes**
  - Enhanced clipboard handling to better manage image and text data inclusion, with improved fallback for snapshot HTML.

- **Tests**
  - Added an end-to-end test verifying image copy-paste functionality between edgeless and page editor modes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-06 01:17:58 +00:00
akumatus
512a908fd4 fix(core): generate the image cannot enter text prompt (#12717)
Close [AI-167](https://linear.app/affine-design/issue/AI-167)

![截屏2025-06-05 12.15.49.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/c1afff0e-1197-46dc-ae43-ff7257039509.png)

![截屏2025-06-05 12.14.07.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/1439b0a7-1cca-4848-aea2-84bc73c536c5.png)

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

- **New Features**
  - Improved AI panel behavior with explicit modes for input and answer generation, providing a more intuitive user experience when interacting with AI features.

- **Refactor**
  - Streamlined AI panel toggling logic for more consistent and predictable panel states during different actions.

- **Tests**
  - Enhanced AI image generation test to simulate user input and send actions for more accurate end-to-end validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-05 09:17:28 +00:00
liuyi
71be1d424a fix(server): oidc registration (#12723) 2025-06-05 09:16:21 +00:00
161 changed files with 2820 additions and 1831 deletions

View File

@@ -95,11 +95,13 @@ spec:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}

View File

@@ -36,6 +36,7 @@ resources:
probe:
initialDelaySeconds: 20
timeoutSeconds: 5
nodeSelector: {}
tolerations: []

View File

@@ -827,7 +827,6 @@ jobs:
- optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
RUSTFLAGS: -D warnings
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4

View File

@@ -22,7 +22,10 @@ import {
FileSizeLimitProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { formatSize } from '@blocksuite/affine-shared/utils';
import {
formatSize,
openSingleFileWith,
} from '@blocksuite/affine-shared/utils';
import {
AttachmentIcon,
ResetIcon,
@@ -31,7 +34,7 @@ import {
} from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import { nanoid, Slice } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { batch, computed, signal } from '@preact/signals-core';
import { html, type TemplateResult } from 'lit';
import { choose } from 'lit/directives/choose.js';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
@@ -42,7 +45,7 @@ import { filter } from 'rxjs/operators';
import { AttachmentEmbedProvider } from './embed';
import { styles } from './styles';
import { downloadAttachmentBlob, refreshData } from './utils';
import { downloadAttachmentBlob, getFileType, refreshData } from './utils';
type AttachmentResolvedStateInfo = ResolvedStateInfo & {
kind?: TemplateResult;
@@ -129,12 +132,50 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
// Refreshes the embed component.
reload = () => {
if (this.model.props.embed) {
this._refreshKey$.value = nanoid();
return;
}
batch(() => {
if (this.model.props.embed$.value) {
this._refreshKey$.value = nanoid();
return;
}
this.refreshData();
this.refreshData();
});
};
// Replaces the current attachment.
replace = async () => {
const state = this.resourceController.state$.peek();
if (state.uploading) return;
const file = await openSingleFileWith();
if (!file) return;
const sourceId = await this.std.store.blobSync.set(file);
const type = await getFileType(file);
const { name, size } = file;
let embed = this.model.props.embed$.value ?? false;
this.std.store.captureSync();
this.std.store.transact(() => {
this.std.store.updateBlock(this.blockId, {
name,
size,
type,
sourceId,
embed: false,
});
const provider = this.std.get(AttachmentEmbedProvider);
embed &&= provider.embedded(this.model);
if (embed) {
provider.convertTo(this.model);
}
// Reloads
this.reload();
});
};
private _selectBlock() {
@@ -403,7 +444,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
protected renderEmbedView = () => {
const { model, blobUrl } = this;
if (!model.props.embed || !blobUrl) return null;
if (!model.props.embed$.value || !blobUrl) return null;
const { std, _maxFileSize } = this;
const provider = std.get(AttachmentEmbedProvider);

View File

@@ -24,6 +24,7 @@ import {
DownloadIcon,
DuplicateIcon,
EditIcon,
ReplaceIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std';
@@ -139,27 +140,42 @@ export const attachmentViewDropdownMenu = {
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
@toggle=${onToggle}
.actions=${actions.value}
.context=${ctx}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
return html`<affine-view-dropdown-menu
@toggle=${onToggle}
.actions=${actions.value}
.context=${ctx}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`;
},
} as const satisfies ToolbarActionGroup<ToolbarAction>;
const replaceAction = {
id: 'c.replace',
tooltip: 'Replace attachment',
icon: ReplaceIcon(),
disabled(ctx) {
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
if (!block) return true;
const { downloading = false, uploading = false } =
block.resourceController.state$.value;
return downloading || uploading;
},
run(ctx) {
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
block?.replace().catch(console.error);
},
} as const satisfies ToolbarAction;
const downloadAction = {
id: 'c.download',
id: 'd.download',
tooltip: 'Download',
icon: DownloadIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
block?.download();
},
when: ctx => {
when(ctx) {
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
if (!model) return false;
// Current citation attachment block does not support download
@@ -168,7 +184,7 @@ const downloadAction = {
} as const satisfies ToolbarAction;
const captionAction = {
id: 'd.caption',
id: 'e.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
@@ -221,6 +237,7 @@ const builtinToolbarConfig = {
},
},
attachmentViewDropdownMenu,
replaceAction,
downloadAction,
captionAction,
{
@@ -354,13 +371,17 @@ const builtinSurfaceToolbarConfig = {
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
...replaceAction,
id: 'd.replace',
},
{
...downloadAction,
id: 'd.download',
id: 'e.download',
},
{
...captionAction,
id: 'e.caption',
id: 'f.caption',
},
],
when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1,

View File

@@ -0,0 +1,61 @@
import {
type CodeBlockModel,
CodeBlockSchema,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/std';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const CodeBlockMarkdownExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'code-block',
pattern: /^```([a-zA-Z0-9]*)\s$/,
action: ({ inlineEditor, inlineRange, prefixText, pattern }) => {
if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) {
return;
}
const match = prefixText.match(pattern);
if (!match) return;
const language = match[1];
if (!inlineEditor.rootElement) return;
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
const { model, std, store } = blockComponent;
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type === 'quote'
) {
return;
}
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
store.captureSync();
const codeId = store.addBlock<CodeBlockModel>(
CodeBlockSchema.model.flavour,
{ language },
parent,
index
);
if (model.text && model.text.length > prefixText.length) {
const text = model.text.clone();
store.addBlock('affine:paragraph', { text }, parent, index + 1);
text.delete(0, prefixText.length);
}
store.deleteBlock(model, { bringChildrenTo: parent });
focusTextModel(std, codeId);
},
});

View File

@@ -33,6 +33,10 @@ export const codeBlockStyles = css`
grid-template-columns: auto minmax(0, 1fr);
}
.affine-code-block-container.disable-line-numbers v-line {
grid-template-columns: unset;
}
.affine-code-block-container div:has(> v-line) {
display: grid;
}

View File

@@ -21,6 +21,7 @@ import { CodeKeymapExtension } from './code-keymap.js';
import { AFFINE_CODE_TOOLBAR_WIDGET } from './code-toolbar/index.js';
import { codeSlashMenuConfig } from './configs/slash-menu.js';
import { effects } from './effects.js';
import { CodeBlockMarkdownExtension } from './markdown.js';
const codeToolbarWidget = WidgetViewExtension(
'affine:code',
@@ -44,6 +45,7 @@ export class CodeBlockViewExtension extends ViewExtensionProvider {
BlockViewExtension('affine:code', literal`affine-code`),
SlashMenuConfigExtension('affine:code', codeSlashMenuConfig),
CodeKeymapExtension,
CodeBlockMarkdownExtension,
...getCodeClipboardExtensions(),
]);
context.register([

View File

@@ -331,7 +331,6 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
this.inlineEditor$.value?.selectAll();
}
};
this.addEventListener('keydown', selectAll);
this.disposables.addFromEvent(this, 'keydown', selectAll);
this.disposables.add(
effect(() => {

View File

@@ -209,10 +209,19 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
}
};
this.addEventListener('keydown', selectAll);
this.disposables.addFromEvent(this, 'keydown', selectAll);
}
private readonly _handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
if (event.key === 'Tab') {
event.preventDefault();
return;
}
event.stopPropagation();
}
};
override firstUpdated(props: Map<string, unknown>) {
super.firstUpdated(props);
this.richText.value?.updateComplete
@@ -233,6 +242,12 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
'paste',
this._onPaste
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {
this.disposables.add(
inlineEditor.slots.keydown.subscribe(this._handleKeyDown)
);
}
}
})
.catch(console.error);

View File

@@ -13,6 +13,7 @@
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",

View File

@@ -0,0 +1,63 @@
import {
type DividerBlockModel,
DividerBlockSchema,
ParagraphBlockModel,
ParagraphBlockSchema,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/std';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const DividerMarkdownExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'divider',
pattern: /^(-{3,}|\*{3,}|_{3,})\s$/,
action: ({ inlineEditor, inlineRange }) => {
if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) {
return;
}
if (!inlineEditor.rootElement) return;
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
const { model, std, store } = blockComponent;
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type !== 'quote'
) {
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
store.addBlock<DividerBlockModel>(
DividerBlockSchema.model.flavour,
{
children: model.children,
},
parent,
index
);
const nextBlock = parent.children.at(index + 1);
let id = nextBlock?.id;
if (!id) {
id = store.addBlock<ParagraphBlockModel>(
ParagraphBlockSchema.model.flavour,
{},
parent
);
}
focusTextModel(std, id);
}
},
});

View File

@@ -6,6 +6,7 @@ import { BlockViewExtension } from '@blocksuite/std';
import { literal } from 'lit/static-html.js';
import { effects } from './effects';
import { DividerMarkdownExtension } from './markdown';
export class DividerViewExtension extends ViewExtensionProvider {
override name = 'affine-divider-block';
@@ -19,6 +20,7 @@ export class DividerViewExtension extends ViewExtensionProvider {
super.setup(context);
context.register([
BlockViewExtension('affine:divider', literal`affine-divider`),
DividerMarkdownExtension,
]);
}
}

View File

@@ -10,6 +10,7 @@
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },

View File

@@ -107,10 +107,10 @@ export class EmbedHtmlFullscreenToolbar extends LitElement {
if (this._copied) return;
this.embedHtml.std.clipboard
.writeToClipboard(items => {
items['text/plain'] = this.embedHtml.model.props.html ?? '';
return items;
})
.writeToClipboard(items => ({
...items,
'text/plain': this.embedHtml.model.props.html ?? '',
}))
.then(() => {
this._copied = true;
setTimeout(() => (this._copied = false), 1500);

View File

@@ -11,6 +11,7 @@ import {
NativeClipboardProvider,
} from '@blocksuite/affine-shared/services';
import {
convertToPng,
formatSize,
getBlockProps,
isInsidePageEditor,
@@ -111,28 +112,6 @@ export async function resetImageSize(
block.store.updateBlock(model, props);
}
function convertToPng(blob: Blob): Promise<Blob | null> {
return new Promise(resolve => {
const reader = new FileReader();
reader.addEventListener('load', _ => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
c.toBlob(resolve, 'image/png');
};
img.onerror = () => resolve(null);
img.src = reader.result as string;
});
reader.addEventListener('error', () => resolve(null));
reader.readAsDataURL(blob);
});
}
export async function copyImageBlob(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {

View File

@@ -1,6 +1,5 @@
import { textKeymap } from '@blocksuite/affine-inline-preset';
import { ListBlockSchema } from '@blocksuite/affine-model';
import { markdownInput } from '@blocksuite/affine-rich-text';
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import { IS_MAC } from '@blocksuite/global/env';
import { KeymapExtension, TextSelection } from '@blocksuite/std';
@@ -125,20 +124,6 @@ export const ListKeymapExtension = KeymapExtension(
ctx.get('keyboardState').raw.preventDefault();
return true;
},
Space: ctx => {
if (!markdownInput(std)) {
return;
}
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Shift-Space': ctx => {
if (!markdownInput(std)) {
return;
}
ctx.get('keyboardState').raw.preventDefault();
return true;
},
};
},
{

View File

@@ -0,0 +1,91 @@
import {
type ListBlockModel,
ListBlockSchema,
type ListType,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels, toNumberedList } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/std';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const ListMarkdownExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'list',
// group 2: number
// group 3: bullet
// group 4: bullet
// group 5: todo
// group 6: todo checked
pattern: /^((\d+\.)|(-)|(\*)|(\[ ?\])|(\[x\]))\s$/,
action: ({ inlineEditor, pattern, inlineRange, prefixText }) => {
if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) {
return;
}
const match = prefixText.match(pattern);
if (!match) return;
let type: ListType;
if (match[2]) {
type = 'numbered';
} else if (match[3] || match[4]) {
type = 'bulleted';
} else if (match[5] || match[6]) {
type = 'todo';
} else {
return;
}
const checked = match[6] !== undefined;
if (!inlineEditor.rootElement) return;
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
const { model, std, store } = blockComponent;
if (!matchModels(model, [ParagraphBlockModel])) return;
if (type !== 'numbered') {
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
const id = store.addBlock<ListBlockModel>(
ListBlockSchema.model.flavour,
{
type: type,
text: model.text?.clone(),
children: model.children,
...(type === 'todo' ? { checked } : {}),
},
parent,
index
);
store.deleteBlock(model, { deleteChildren: false });
focusTextModel(std, id);
} else {
let order = parseInt(match[2]);
if (!Number.isInteger(order)) order = 1;
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
const id = toNumberedList(std, model, order);
if (!id) return;
focusTextModel(std, id);
}
},
});

View File

@@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js';
import { effects } from './effects.js';
import { ListKeymapExtension, ListTextKeymapExtension } from './list-keymap.js';
import { ListMarkdownExtension } from './markdown.js';
export class ListViewExtension extends ViewExtensionProvider {
override name = 'affine-list-block';
@@ -23,6 +24,7 @@ export class ListViewExtension extends ViewExtensionProvider {
BlockViewExtension('affine:list', literal`affine-list`),
ListKeymapExtension,
ListTextKeymapExtension,
ListMarkdownExtension,
]);
}
}

View File

@@ -0,0 +1,74 @@
import {
ListBlockModel,
ParagraphBlockModel,
ParagraphBlockSchema,
type ParagraphType,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/std';
import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const ParagraphMarkdownExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'heading',
pattern: /^((#{1,6})|(>))\s$/,
action: ({ inlineEditor, pattern, inlineRange, prefixText }) => {
if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) {
return;
}
const match = prefixText.match(pattern);
if (!match) return;
const type = (
match[2] ? `h${match[2].length}` : 'quote'
) as ParagraphType;
if (!inlineEditor.rootElement) return;
const blockComponent =
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
if (!blockComponent) return;
const { model, std, store } = blockComponent;
if (
!matchModels(model, [ParagraphBlockModel]) &&
matchModels(model, [ListBlockModel])
) {
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
store.deleteBlock(model, { deleteChildren: false });
const id = store.addBlock<ParagraphBlockModel>(
ParagraphBlockSchema.model.flavour,
{
type: type,
text: model.text?.clone(),
children: model.children,
},
parent,
index
);
focusTextModel(std, id);
} else if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type !== type
) {
store.captureSync();
inlineEditor.deleteText({
index: 0,
length: inlineRange.index,
});
store.updateBlock(model, { type });
focusTextModel(std, model.id);
}
},
});

View File

@@ -28,6 +28,7 @@ import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { repeat } from 'lit/directives/repeat.js';
import { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
import { paragraphBlockStyles } from './styles.js';
@@ -227,6 +228,12 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
}
override renderBlock(): TemplateResult<1> {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const { type$ } = this.model.props;
const collapsed = this.store.readonly
? this._readonlyCollapsed
@@ -341,6 +348,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
</div>
${children}
${widgets}
</div>
`;
}

View File

@@ -7,7 +7,6 @@ import {
import {
focusTextModel,
getInlineEditorByModel,
markdownInput,
} from '@blocksuite/affine-rich-text';
import {
calculateCollapsedSiblings,
@@ -148,10 +147,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
raw.preventDefault();
if (markdownInput(std, model.id)) {
return true;
}
if (model.props.type.startsWith('h') && model.props.collapsed) {
const parent = store.getParent(model);
if (!parent) return true;
@@ -199,20 +194,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
event.preventDefault();
return true;
},
Space: ctx => {
if (!markdownInput(std)) {
return;
}
ctx.get('keyboardState').raw.preventDefault();
return true;
},
'Shift-Space': ctx => {
if (!markdownInput(std)) {
return;
}
ctx.get('keyboardState').raw.preventDefault();
return true;
},
Tab: ctx => {
const [success] = std.command
.chain()

View File

@@ -20,6 +20,7 @@ import { EMBED_BLOCK_MODEL_LIST } from '@blocksuite/affine-shared/consts';
import type { ExtendedModel } from '@blocksuite/affine-shared/types';
import {
focusTitle,
getDocTitleInlineEditor,
getPrevContentBlock,
matchModels,
} from '@blocksuite/affine-shared/utils';
@@ -45,10 +46,6 @@ export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
const parent = doc.getParent(model);
if (!parent) return false;
if (matchModels(parent, [EdgelessTextBlockModel])) {
return true;
}
const prevBlock = getPrevContentBlock(editorHost, model);
if (!prevBlock) {
return handleNoPreviousSibling(editorHost, model);
@@ -123,36 +120,63 @@ function handleNoPreviousSibling(editorHost: EditorHost, model: ExtendedModel) {
const parent = doc.getParent(model);
if (!parent) return false;
if (matchModels(parent, [NoteBlockModel]) && parent.isPageBlock()) {
const focusFirstBlockStart = () => {
const firstBlock = parent.firstChild();
if (firstBlock) {
focusTextModel(editorHost.std, firstBlock.id, 0);
}
};
if (matchModels(parent, [NoteBlockModel])) {
const hasTitleEditor = getDocTitleInlineEditor(editorHost);
const rootModel = model.store.root as RootBlockModel;
const title = rootModel.props.title;
const shouldHandleTitle = parent.isPageBlock() && hasTitleEditor;
doc.captureSync();
let textLength = 0;
if (text) {
textLength = text.length;
title.join(text);
if (shouldHandleTitle) {
let textLength = 0;
if (text) {
textLength = text.length;
title.join(text);
}
if (model.children.length > 0 || doc.getNext(model)) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
}
// no other blocks, preserve a empty line
else {
text?.clear();
}
focusTitle(editorHost, title.length - textLength);
return true;
}
// Preserve at least one block to be able to focus on container click
if (doc.getNext(model) || model.children.length > 0) {
if (
text?.length === 0 &&
(model.children.length > 0 || doc.getNext(model))
) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
} else {
text?.clear();
focusFirstBlockStart();
return true;
}
focusTitle(editorHost, title.length - textLength);
return true;
}
if (
matchModels(parent, [EdgelessTextBlockModel]) ||
model.children.length > 0
matchModels(parent, [EdgelessTextBlockModel]) &&
text?.length === 0 &&
(model.children.length > 0 || doc.getNext(model))
) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
focusFirstBlockStart();
return true;
}

View File

@@ -2,9 +2,13 @@ import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import { literal } from 'lit/static-html.js';
import { z } from 'zod';
import { effects } from './effects';
import { ParagraphMarkdownExtension } from './markdown.js';
import { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
import {
ParagraphKeymapExtension,
@@ -22,11 +26,6 @@ const placeholders = {
quote: '',
};
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import { z } from 'zod';
import { effects } from './effects';
const optionsSchema = z.object({
getPlaceholder: z.optional(
z.function().args(z.instanceof(ParagraphBlockModel)).returns(z.string())
@@ -61,6 +60,7 @@ export class ParagraphViewExtension extends ViewExtensionProvider<
ParagraphBlockConfigExtension({
getPlaceholder,
}),
ParagraphMarkdownExtension,
]);
}
}

View File

@@ -35,6 +35,7 @@ import {
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
convertToPng,
isInsidePageEditor,
isTopLevelBlock,
isUrlInClipboard,
@@ -67,7 +68,7 @@ import * as Y from 'yjs';
import { PageClipboard } from '../../clipboard/index.js';
import { getSortedCloneElements } from '../utils/clone-utils.js';
import { isCanvasElementWithText } from '../utils/query.js';
import { isCanvasElementWithText, isImageBlock } from '../utils/query.js';
import { createElementsFromClipboardDataCommand } from './command.js';
import {
isPureFileInClipboard,
@@ -126,6 +127,49 @@ export class EdgelessClipboardController extends PageClipboard {
return;
}
// Only when an image is selected, it can be pasted normally to page mode.
if (elements.length === 1 && isImageBlock(elements[0])) {
const element = elements[0];
const sourceId = element.props.sourceId$.peek();
if (!sourceId) return;
await this.std.clipboard.writeToClipboard(async items => {
const job = this.std.store.getTransformer();
await job.assetsManager.readFromBlob(sourceId);
let blob = job.assetsManager.getAssets().get(sourceId) ?? null;
if (!blob) {
return items;
}
let type = blob.type;
let supported = false;
try {
supported = ClipboardItem?.supports(type) ?? false;
} catch (err) {
console.error(err);
}
// TODO(@fundon): when converting jpeg to png, image may become larger and exceed the limit.
if (!supported) {
type = 'image/png';
blob = await convertToPng(blob);
}
if (blob) {
return {
...items,
[`${type}`]: blob,
};
}
return items;
});
return;
}
await this.std.clipboard.writeToClipboard(async _items => {
const data = await prepareClipboardData(elements, this.std);
return {
@@ -562,6 +606,10 @@ export class EdgelessClipboardController extends PageClipboard {
}
private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) {
if (content === '') {
return;
}
const { x, y } = this.toolManager.lastMousePos$.peek();
const noteProps = {

View File

@@ -69,37 +69,39 @@ export async function prepareClipboardData(
export function isPureFileInClipboard(clipboardData: DataTransfer) {
const types = clipboardData.types;
return (
(types.length === 1 && types[0] === 'Files') ||
(types.length === 2 &&
(types.includes('text/plain') || types.includes('text/html')) &&
types.includes('Files'))
);
const allowedTypes = new Set([
'Files',
'text/plain',
'text/html',
'application/x-moz-file',
]);
return types.includes('Files') && types.every(type => allowedTypes.has(type));
}
export function tryGetSvgFromClipboard(clipboardData: DataTransfer) {
const types = clipboardData.types;
try {
const parser = new DOMParser();
const svgDoc = parser.parseFromString(
clipboardData.getData('text/plain'),
'image/svg+xml'
);
const svg = svgDoc.documentElement;
if (types.length === 1 && types[0] !== 'text/plain') {
if (svg.tagName !== 'svg' || !svg.hasAttribute('xmlns')) {
return null;
}
const svgContent = DOMPurify.sanitize(svgDoc.documentElement, {
USE_PROFILES: { svg: true },
});
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
const file = new File([blob], 'pasted-image.svg', {
type: 'image/svg+xml',
});
return file;
} catch {
return null;
}
const parser = new DOMParser();
const svgDoc = parser.parseFromString(
clipboardData.getData('text/plain'),
'image/svg+xml'
);
const svg = svgDoc.documentElement;
if (svg.tagName !== 'svg' || !svg.hasAttribute('xmlns')) {
return null;
}
const svgContent = DOMPurify.sanitize(svgDoc.documentElement, {
USE_PROFILES: { svg: true },
});
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
const file = new File([blob], 'pasted-image.svg', { type: 'image/svg+xml' });
return file;
}
export function edgelessElementsBoundFromRawData(

View File

@@ -647,6 +647,16 @@ export class TableCell extends SignalWatcher(
return this.richText$.value?.inlineEditor;
}
private readonly _handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
if (e.key === 'Tab') {
e.preventDefault();
return;
}
e.stopPropagation();
}
};
override connectedCallback() {
super.connectedCallback();
if (this.readonly) {
@@ -659,10 +669,7 @@ export class TableCell extends SignalWatcher(
this.inlineEditor?.selectAll();
}
};
this.addEventListener('keydown', selectAll);
this.disposables.add(() => {
this.removeEventListener('keydown', selectAll);
});
this.disposables.addFromEvent(this, 'keydown', selectAll);
this.disposables.addFromEvent(this, 'click', (e: MouseEvent) => {
e.stopPropagation();
requestAnimationFrame(() => {
@@ -679,6 +686,13 @@ export class TableCell extends SignalWatcher(
}
this.richText$.value?.updateComplete
.then(() => {
const inlineEditor = this.inlineEditor;
if (inlineEditor) {
this.disposables.add(
inlineEditor.slots.keydown.subscribe(this._handleKeyDown)
);
}
this.disposables.add(
effect(() => {
const richText = this.richText$.value;

View File

@@ -195,8 +195,7 @@ export class EmbedCardEditModal extends SignalWatcher(
const description = this.description$.value.trim();
const props: AliasInfo = { title };
if (description) props.description = description;
const props: AliasInfo = { title, description };
this.onSave?.(std, blockComponent, props);

View File

@@ -112,7 +112,10 @@ export class GroupTrait {
return;
}
const { staticMap, groupInfo } = staticInfo;
const groupMap: Record<string, Group> = { ...staticMap };
const groupMap: Record<string, Group> = {};
Object.entries(staticMap).forEach(([key, group]) => {
groupMap[key] = new Group(key, group.value, groupInfo, this);
});
this.view.rows$.value.forEach(row => {
const value = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id)
.jsonValue$.value;
@@ -182,6 +185,7 @@ export class GroupTrait {
) {}
addToGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
const groupInfo = this.groupInfo$.value;
if (!groupMap || !groupInfo) {
@@ -254,6 +258,7 @@ export class GroupTrait {
toGroupKey: string,
position: InsertToPosition
) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
@@ -290,6 +295,7 @@ export class GroupTrait {
}
moveGroupTo(groupKey: string, position: InsertToPosition) {
this.view.lockRows(false);
const groups = this.groupsDataList$.value;
if (!groups) {
return;
@@ -305,6 +311,7 @@ export class GroupTrait {
}
removeFromGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
@@ -323,6 +330,7 @@ export class GroupTrait {
}
updateValue(rows: string[], value: unknown) {
this.view.lockRows(false);
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;

View File

@@ -128,6 +128,7 @@ export abstract class SingleViewBase<
);
rowsDelete(rows: string[]): void {
this.lockRows(false);
this.dataSource.rowDelete(rows);
}
@@ -258,6 +259,7 @@ export abstract class SingleViewBase<
abstract propertyGetOrCreate(propertyId: string): Property;
rowAdd(insertPosition: InsertToPosition | number): string {
this.lockRows(false);
return this.dataSource.rowAdd(insertPosition);
}

View File

@@ -61,10 +61,12 @@ export class MobileKanbanGroup extends SignalWatcher(
private readonly clickAddCard = () => {
this.view.addCard('end', this.group.key);
this.requestUpdate();
};
private readonly clickAddCardInStart = () => {
this.view.addCard('start', this.group.key);
this.requestUpdate();
};
private readonly clickGroupOptions = (e: MouseEvent) => {
@@ -79,12 +81,14 @@ export class MobileKanbanGroup extends SignalWatcher(
this.group.rows.forEach(row => {
this.group.manager.removeFromGroup(row.rowId, this.group.key);
});
this.requestUpdate();
},
}),
menu.action({
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(this.group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
],

View File

@@ -66,7 +66,9 @@ export class MobileKanbanViewUILogic extends DataViewUILogicBase<
addRow = (position: InsertToPosition) => {
if (this.readonly) return;
return this.view.rowAdd(position);
const id = this.view.rowAdd(position);
this.ui$.value?.requestUpdate();
return id;
};
focusFirstCell = () => {};

View File

@@ -83,6 +83,7 @@ export const popCardMenu = (
{ before: true, id: cardId },
groupKey
);
kanbanViewLogic.ui$.value?.requestUpdate();
},
}),
menu.action({
@@ -97,6 +98,7 @@ export const popCardMenu = (
{ before: false, id: cardId },
groupKey
);
kanbanViewLogic.ui$.value?.requestUpdate();
},
}),
],
@@ -111,6 +113,7 @@ export const popCardMenu = (
prefix: DeleteIcon(),
select: () => {
kanbanViewLogic.view.rowsDelete([cardId]);
kanbanViewLogic.ui$.value?.requestUpdate();
},
}),
],

View File

@@ -128,6 +128,7 @@ export class KanbanSelectionController implements ReactiveController {
if (selection.selectionType === 'card') {
this.view.rowsDelete(selection.cards.map(v => v.cardId));
this.selection = undefined;
this.logic.ui$.value?.requestUpdate();
}
}

View File

@@ -110,6 +110,7 @@ export class KanbanGroup extends SignalWatcher(
isEditing: true,
};
});
this.requestUpdate();
};
private readonly clickAddCardInStart = () => {
@@ -127,6 +128,7 @@ export class KanbanGroup extends SignalWatcher(
isEditing: true,
};
});
this.requestUpdate();
};
private readonly clickGroupOptions = (e: MouseEvent) => {
@@ -139,12 +141,14 @@ export class KanbanGroup extends SignalWatcher(
this.group.rows.forEach(row => {
this.group.manager.removeFromGroup(row.rowId, this.group.key);
});
this.requestUpdate();
},
}),
menu.action({
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(this.group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);

View File

@@ -73,6 +73,7 @@ export class KanbanViewUILogic extends DataViewUILogicBase<
rowId,
});
}
this.ui$.value?.requestUpdate();
return rowId;
};

View File

@@ -51,10 +51,12 @@ export class MobileTableGroup extends SignalWatcher(
private readonly clickAddRow = () => {
this.view.rowAdd('end', this.group?.key);
this.requestUpdate();
};
private readonly clickAddRowInStart = () => {
this.view.rowAdd('start', this.group?.key);
this.requestUpdate();
};
private readonly clickGroupOptions = (e: MouseEvent) => {
@@ -77,6 +79,7 @@ export class MobileTableGroup extends SignalWatcher(
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);

View File

@@ -38,6 +38,7 @@ export const popMobileRowMenu = (
prefix: DeleteIcon(),
select: () => {
view.rowsDelete([rowId]);
tableViewLogic.ui$.value?.requestUpdate();
},
}),
],

View File

@@ -44,6 +44,7 @@ export class TableClipboardController implements ReactiveController {
}
if (deleteRows.length) {
this.logic.view.rowsDelete(deleteRows);
this.logic.ui$.value?.requestUpdate();
}
}
this.clipboard
@@ -79,6 +80,14 @@ export class TableClipboardController implements ReactiveController {
const event = _context.get('clipboardState').raw;
event.stopPropagation();
const active = document.activeElement as HTMLElement | null;
if (
active &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')
) {
return true;
}
const clipboardData = event.clipboardData;
if (!clipboardData) return;

View File

@@ -30,6 +30,7 @@ export class TableHotkeysController implements ReactiveController {
const rows = TableViewRowSelection.rowsIds(selection);
this.selectionController.selection = undefined;
this.logic.view.rowsDelete(rows);
this.logic.ui$.value?.requestUpdate();
return;
}
const {

View File

@@ -376,6 +376,7 @@ export class TableSelectionController implements ReactiveController {
deleteRow(rowId: string) {
this.view.rowsDelete([rowId]);
this.focusToCell('up');
this.logic.ui$.value?.requestUpdate();
}
focusFirstCell() {

View File

@@ -45,6 +45,7 @@ export class TableGroupFooter extends WithDisposable(ShadowlessElement) {
private readonly clickAddRow = () => {
const group = this.group$.value;
const rowId = this.tableViewManager.rowAdd('end', group?.key);
this.requestUpdate();
requestAnimationFrame(() => {
const rowIndex = this.selectionController.getRow(group?.key, rowId)

View File

@@ -58,6 +58,7 @@ export class TableGroupHeader extends SignalWatcher(
return;
}
this.tableViewManager.rowAdd('start', group.key);
this.requestUpdate();
const selectionController = this.selectionController;
selectionController.selection = undefined;
requestAnimationFrame(() => {
@@ -95,6 +96,7 @@ export class TableGroupHeader extends SignalWatcher(
name: 'Delete Cards',
select: () => {
this.tableViewManager.rowsDelete(group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);

View File

@@ -71,6 +71,7 @@ export const popRowMenu = (
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowsDelete(rows);
selectionController.logic.ui$.value?.requestUpdate();
},
}),
],

View File

@@ -43,6 +43,7 @@ export class TableClipboardController implements ReactiveController {
}
if (deleteRows.length) {
this.logic.view.rowsDelete(deleteRows);
this.logic.ui$.value?.requestUpdate();
}
}
this.clipboard
@@ -78,6 +79,14 @@ export class TableClipboardController implements ReactiveController {
const event = _context.get('clipboardState').raw;
event.stopPropagation();
const active = document.activeElement as HTMLElement | null;
if (
active &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')
) {
return true;
}
const clipboardData = event.clipboardData;
if (!clipboardData) return;

View File

@@ -28,6 +28,7 @@ export class TableHotkeysController implements ReactiveController {
const rows = TableViewRowSelection.rowsIds(selection);
this.selectionController.selection = undefined;
this.logic.view.rowsDelete(rows);
this.logic.ui$.value?.requestUpdate();
return;
}
const {

View File

@@ -351,6 +351,7 @@ export class TableSelectionController implements ReactiveController {
deleteRow(rowId: string) {
this.view.rowsDelete([rowId]);
this.focusToCell('up');
this.logic.ui$.value?.requestUpdate();
}
focusFirstCell() {

View File

@@ -83,6 +83,7 @@ export class TableGroup extends SignalWatcher(
},
isEditing: true,
});
this.requestUpdate();
});
};
@@ -102,6 +103,7 @@ export class TableGroup extends SignalWatcher(
},
isEditing: true,
});
this.requestUpdate();
});
};
@@ -125,6 +127,7 @@ export class TableGroup extends SignalWatcher(
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);

View File

@@ -71,6 +71,7 @@ export const popRowMenu = (
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowsDelete(rows);
selectionController.logic.ui$.value?.requestUpdate();
},
}),
],

View File

@@ -10,7 +10,7 @@ export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'latex',
pattern:
/(?:\$\$)(?<content>[^$]+)(?:\$\$)$|(?<blockPrefix>\$\$\$\$)|(?<inlinePrefix>\$\$)$/g,
/(?:\$\$)(?<content>[^$]+)(?:\$\$)\s$|(?<blockPrefix>\$\$\$\$)\s$|(?<inlinePrefix>\$\$)\s$/g,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = pattern.exec(prefixText);
if (!match || !match.groups) return;
@@ -33,22 +33,10 @@ export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
const ifEdgelessText = blockComponent.closest('affine-edgeless-text');
if (blockPrefix === '$$$$') {
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
undoManager.stopCapturing();
inlineEditor.deleteText({
index: inlineRange.index - 4,
index: inlineRange.index - 5,
length: 5,
});
@@ -88,34 +76,22 @@ export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
}
if (inlinePrefix === '$$') {
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
undoManager.stopCapturing();
inlineEditor.deleteText({
index: inlineRange.index - 2,
index: inlineRange.index - 3,
length: 3,
});
inlineEditor.insertText(
{
index: inlineRange.index - 2,
index: inlineRange.index - 3,
length: 0,
},
' '
);
inlineEditor.formatText(
{
index: inlineRange.index - 2,
index: inlineRange.index - 3,
length: 1,
},
{
@@ -129,7 +105,7 @@ export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(
inlineRange.index - 2 + 1
inlineRange.index - 3 + 1
);
if (!textPoint) return;
@@ -159,21 +135,9 @@ export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
if (!content || content.length === 0) return;
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
undoManager.stopCapturing();
const startIndex = inlineRange.index - 2 - content.length - 2;
const startIndex = inlineRange.index - 1 - 2 - content.length - 2;
inlineEditor.deleteText({
index: startIndex,
length: 2 + content.length + 2 + 1,

View File

@@ -3,27 +3,18 @@ import { InlineMarkdownExtension } from '@blocksuite/std/inline';
export const LinkExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'link',
pattern: /.*\[(.+?)\]\((.+?)\)$/,
pattern: /.*\[(.+?)\]\((.+?)\)\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const linkText = match[1];
const linkUrl = match[2];
const annotatedText = match[0].slice(-linkText.length - linkUrl.length - 4);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
const annotatedText = match[0].slice(
-(linkText.length + linkUrl.length + 4 + 1),
-1
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();

View File

@@ -59,7 +59,7 @@ export const StrikeInlineSpecExtension =
export const CodeInlineSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'code',
name: 'inline-code',
schema: z.literal(true).optional().nullable().catch(undefined),
match: delta => {
return !!delta.attributes?.code;

View File

@@ -13,7 +13,7 @@ import type { ExtensionType } from '@blocksuite/store';
export const BoldItalicMarkdown = InlineMarkdownExtension<AffineTextAttributes>(
{
name: 'bolditalic',
pattern: /.*\*{3}([^\s*][^*]*[^\s*])\*{3}$|.*\*{3}([^\s*])\*{3}$/,
pattern: /.*\*{3}([^\s*][^*]*[^\s*])\*{3}\s$|.*\*{3}([^\s*])\*{3}\s$/,
action: ({
inlineEditor,
prefixText,
@@ -25,20 +25,11 @@ export const BoldItalicMarkdown = InlineMarkdownExtension<AffineTextAttributes>(
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 3 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
const annotatedText = match[0].slice(
-(targetText.length + 3 * 2 + 1),
-1
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
@@ -54,18 +45,13 @@ export const BoldItalicMarkdown = InlineMarkdownExtension<AffineTextAttributes>(
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 3,
length: 3,
index: inlineRange.index - 4,
length: 4,
});
inlineEditor.deleteText({
index: startIndex,
length: 3,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 6,
length: 0,
@@ -76,26 +62,14 @@ export const BoldItalicMarkdown = InlineMarkdownExtension<AffineTextAttributes>(
export const BoldMarkdown = InlineMarkdownExtension<AffineTextAttributes>({
name: 'bold',
pattern: /.*\*{2}([^\s][^*]*[^\s*])\*{2}$|.*\*{2}([^\s*])\*{2}$/,
pattern: /.*\*{2}([^\s][^*]*[^\s*])\*{2}\s$|.*\*{2}([^\s*])\*{2}\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 2 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
const annotatedText = match[0].slice(-(targetText.length + 2 * 2 + 1), -1);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
@@ -110,18 +84,13 @@ export const BoldMarkdown = InlineMarkdownExtension<AffineTextAttributes>({
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 2,
length: 2,
index: inlineRange.index - 3,
length: 3,
});
inlineEditor.deleteText({
index: startIndex,
length: 2,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 4,
length: 0,
@@ -131,26 +100,14 @@ export const BoldMarkdown = InlineMarkdownExtension<AffineTextAttributes>({
export const ItalicExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'italic',
pattern: /.*\*{1}([^\s][^*]*[^\s*])\*{1}$|.*\*{1}([^\s*])\*{1}$/,
pattern: /.*\*{1}([^\s][^*]*[^\s*])\*{1}\s$|.*\*{1}([^\s*])\*{1}\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 1 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
const annotatedText = match[0].slice(-(targetText.length + 1 * 2 + 1), -1);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
@@ -165,18 +122,13 @@ export const ItalicExtension = InlineMarkdownExtension<AffineTextAttributes>({
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 1,
length: 1,
index: inlineRange.index - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 2,
length: 0,
@@ -187,7 +139,7 @@ export const ItalicExtension = InlineMarkdownExtension<AffineTextAttributes>({
export const StrikethroughExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'strikethrough',
pattern: /.*~{2}([^\s][^~]*[^\s])~{2}$|.*~{2}([^\s~])~{2}$/,
pattern: /.*~{2}([^\s][^~]*[^\s])~{2}\s$|.*~{2}([^\s~])~{2}\s$/,
action: ({
inlineEditor,
prefixText,
@@ -199,20 +151,11 @@ export const StrikethroughExtension =
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 2 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
const annotatedText = match[0].slice(
-targetText.length - (2 * 2 + 1),
-1
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
@@ -227,12 +170,8 @@ export const StrikethroughExtension =
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 2,
length: 2,
index: inlineRange.index - 3,
length: 3,
});
inlineEditor.deleteText({
index: startIndex,
@@ -249,7 +188,7 @@ export const StrikethroughExtension =
export const UnderthroughExtension =
InlineMarkdownExtension<AffineTextAttributes>({
name: 'underthrough',
pattern: /.*~{1}([^\s][^~]*[^\s~])~{1}$|.*~{1}([^\s~])~{1}$/,
pattern: /.*~{1}([^\s][^~]*[^\s~])~{1}\s$|.*~{1}([^\s~])~{1}\s$/,
action: ({
inlineEditor,
prefixText,
@@ -261,20 +200,11 @@ export const UnderthroughExtension =
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 1 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
const annotatedText = match[0].slice(
-(targetText.length + 1 * 2 + 1),
-1
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
@@ -289,12 +219,8 @@ export const UnderthroughExtension =
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: inlineRange.index - 1,
length: 1,
index: inlineRange.index - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,
@@ -310,26 +236,14 @@ export const UnderthroughExtension =
export const CodeExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'code',
pattern: /.*`([^\s][^`]*[^\s])`$|.*`([^\s`])`$/,
pattern: /.*`([^\s][^`]*[^\s])`\s$|.*`([^\s`])`\s$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 1 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
const annotatedText = match[0].slice(-(targetText.length + 1 * 2 + 1), -1);
const startIndex = inlineRange.index - annotatedText.length - 1;
undoManager.stopCapturing();
@@ -344,12 +258,8 @@ export const CodeExtension = InlineMarkdownExtension<AffineTextAttributes>({
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 1,
length: 1,
index: inlineRange.index - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,

View File

@@ -10,6 +10,5 @@ export {
onModelTextUpdated,
selectTextModel,
} from './dom';
export { markdownInput } from './markdown';
export { RichText } from './rich-text';
export * from './utils';

View File

@@ -1,42 +0,0 @@
import {
DividerBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
import { beforeConvert } from './utils.js';
export function toDivider(
std: BlockStdScope,
model: BlockModel,
prefix: string
) {
const { store: doc } = std;
if (
matchModels(model, [DividerBlockModel]) ||
(matchModels(model, [ParagraphBlockModel]) && model.props.type === 'quote')
) {
return;
}
const parent = doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
beforeConvert(std, model, prefix.length);
const blockProps = {
children: model.children,
};
doc.addBlock('affine:divider', blockProps, parent, index);
const nextBlock = parent.children[index + 1];
let id = nextBlock?.id;
if (!id) {
id = doc.addBlock('affine:paragraph', {}, parent);
}
focusTextModel(std, id);
return id;
}

View File

@@ -1 +0,0 @@
export { markdownInput } from './markdown-input.js';

View File

@@ -1,54 +0,0 @@
import {
type ListProps,
type ListType,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { matchModels, toNumberedList } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
import { beforeConvert } from './utils.js';
export function toList(
std: BlockStdScope,
model: BlockModel,
listType: ListType,
prefix: string,
otherProperties?: Partial<ListProps>
) {
if (!matchModels(model, [ParagraphBlockModel])) {
return;
}
const { store: doc } = std;
const parent = doc.getParent(model);
if (!parent) return;
beforeConvert(std, model, prefix.length);
if (listType !== 'numbered') {
const index = parent.children.indexOf(model);
const blockProps = {
type: listType,
text: model.text?.clone(),
children: model.children,
...otherProperties,
};
doc.deleteBlock(model, {
deleteChildren: false,
});
const id = doc.addBlock('affine:list', blockProps, parent, index);
focusTextModel(std, id);
return id;
}
let order = parseInt(prefix.slice(0, -1));
if (!Number.isInteger(order)) order = 1;
const id = toNumberedList(std, model, order);
if (!id) return;
focusTextModel(std, id);
return id;
}

View File

@@ -1,98 +0,0 @@
import {
CalloutBlockModel,
CodeBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
isHorizontalRuleMarkdown,
isMarkdownPrefix,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, TextSelection } from '@blocksuite/std';
import { getInlineEditorByModel } from '../dom.js';
import { toDivider } from './divider.js';
import { toList } from './list.js';
import { toParagraph } from './paragraph.js';
import { toCode } from './to-code.js';
import { getPrefixText } from './utils.js';
export function markdownInput(
std: BlockStdScope,
id?: string
): string | undefined {
if (!id) {
const selection = std.selection;
const text = selection.find(TextSelection);
id = text?.from.blockId;
}
if (!id) return;
const model = std.store.getBlock(id)?.model;
if (!model) return;
const inline = getInlineEditorByModel(std, model);
if (!inline) return;
const range = inline.getInlineRange();
if (!range) return;
const prefixText = getPrefixText(inline);
if (!isMarkdownPrefix(prefixText)) return;
const isParagraph = matchModels(model, [ParagraphBlockModel]);
const isHeading = isParagraph && model.props.type.startsWith('h');
const isParagraphQuoteBlock = isParagraph && model.props.type === 'quote';
const isCodeBlock = matchModels(model, [CodeBlockModel]);
if (
isHeading ||
isParagraphQuoteBlock ||
isCodeBlock ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const lineInfo = inline.getLine(range.index);
if (!lineInfo) return;
const { lineIndex, rangeIndexRelatedToLine } = lineInfo;
if (lineIndex !== 0 || rangeIndexRelatedToLine > prefixText.length) return;
// try to add code block
const codeMatch = prefixText.match(/^```([a-zA-Z0-9]*)$/g);
if (codeMatch) {
return toCode(std, model, prefixText, codeMatch[0].slice(3));
}
if (isHorizontalRuleMarkdown(prefixText.trim())) {
return toDivider(std, model, prefixText);
}
switch (prefixText.trim()) {
case '[]':
case '[ ]':
return toList(std, model, 'todo', prefixText, {
checked: false,
});
case '[x]':
return toList(std, model, 'todo', prefixText, {
checked: true,
});
case '-':
case '*':
return toList(std, model, 'bulleted', prefixText);
case '#':
return toParagraph(std, model, 'h1', prefixText);
case '##':
return toParagraph(std, model, 'h2', prefixText);
case '###':
return toParagraph(std, model, 'h3', prefixText);
case '####':
return toParagraph(std, model, 'h4', prefixText);
case '#####':
return toParagraph(std, model, 'h5', prefixText);
case '######':
return toParagraph(std, model, 'h6', prefixText);
case '>':
return toParagraph(std, model, 'quote', prefixText);
default:
return toList(std, model, 'numbered', prefixText);
}
}

View File

@@ -1,49 +0,0 @@
import {
ParagraphBlockModel,
type ParagraphType,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
import { beforeConvert } from './utils.js';
export function toParagraph(
std: BlockStdScope,
model: BlockModel,
type: ParagraphType,
prefix: string
) {
const { store: doc } = std;
if (!matchModels(model, [ParagraphBlockModel])) {
const parent = doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
beforeConvert(std, model, prefix.length);
const blockProps = {
type: type,
text: model.text?.clone(),
children: model.children,
};
doc.deleteBlock(model, { deleteChildren: false });
const id = doc.addBlock('affine:paragraph', blockProps, parent, index);
focusTextModel(std, id);
return id;
}
if (matchModels(model, [ParagraphBlockModel]) && model.props.type !== type) {
beforeConvert(std, model, prefix.length);
doc.updateBlock(model, { type });
focusTextModel(std, model.id);
}
// If the model is already a paragraph with the same type, do nothing
return model.id;
}

View File

@@ -1,42 +0,0 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
export function toCode(
std: BlockStdScope,
model: BlockModel,
prefixText: string,
language: string | null
) {
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type === 'quote'
) {
return;
}
const doc = model.store;
const parent = doc.getParent(model);
if (!parent) {
return;
}
doc.captureSync();
const index = parent.children.indexOf(model);
const codeId = doc.addBlock('affine:code', { language }, parent, index);
if (model.text && model.text.length > prefixText.length) {
const text = model.text.clone();
doc.addBlock('affine:paragraph', { text }, parent, index + 1);
text.delete(0, prefixText.length);
}
doc.deleteBlock(model, { bringChildrenTo: parent });
focusTextModel(std, codeId);
return codeId;
}

View File

@@ -1,39 +0,0 @@
import type { BlockStdScope } from '@blocksuite/std';
import type { InlineEditor } from '@blocksuite/std/inline';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
export function getPrefixText(inlineEditor: InlineEditor) {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return '';
const firstLineEnd = inlineEditor.yTextString.search(/\n/);
if (firstLineEnd !== -1 && inlineRange.index > firstLineEnd) {
return '';
}
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return '';
const [leafStart, offsetStart] = textPoint;
return leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
}
export function beforeConvert(
std: BlockStdScope,
model: BlockModel,
index: number
) {
const { text } = model;
if (!text) return;
// Add a space after the text, then stop capturing
// So when the user undo, the prefix will be restored with a `space`
// Ex. (| is the cursor position)
// *| <- user input
// <space> -> bullet list
// *<space>| -> undo
text.insert(' ', index);
focusTextModel(std, model.id, index + 1);
std.store.captureSync();
text.delete(0, index + 1);
}

View File

@@ -22,6 +22,7 @@ import * as Y from 'yjs';
import { z } from 'zod';
import { onVBeforeinput, onVCompositionEnd } from './hooks.js';
import { getPrefixText } from './utils.js';
interface RichTextStackItem {
meta: Map<'richtext-v-range', InlineRange | null>;
@@ -186,38 +187,60 @@ export class RichText extends WithDisposable(ShadowlessElement) {
const markdownMatches = this.markdownMatches;
if (markdownMatches) {
inlineEditor.disposables.addFromEvent(
this.inlineEventSource ?? this.inlineEditorContainer,
'keydown',
(e: KeyboardEvent) => {
if (e.key !== ' ' && e.key !== 'Enter') return;
const markdownTransform = (isEnter: boolean = false) => {
let inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange || inlineRange.length > 0) return;
let prefixText = getPrefixText(inlineEditor);
if (isEnter) prefixText = `${prefixText} `;
const nearestLineBreakIndex = inlineEditor.yTextString
.slice(0, inlineRange.index)
.lastIndexOf('\n');
const prefixText = inlineEditor.yTextString.slice(
nearestLineBreakIndex + 1,
inlineRange.index
);
for (const match of markdownMatches) {
const { pattern, action } = match;
if (prefixText.match(pattern)) {
action({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager: this.undoManager,
for (const match of markdownMatches) {
const { pattern, action } = match;
if (prefixText.match(pattern)) {
if (isEnter) {
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
e.preventDefault();
break;
inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
}
action({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager: this.undoManager,
});
return true;
}
}
return false;
};
inlineEditor.disposables.add(
inlineEditor.slots.inputting.subscribe(data => {
if (!inlineEditor.isComposing && data === ' ') {
markdownTransform();
}
})
);
inlineEditor.disposables.add(
inlineEditor.slots.keydown.subscribe(event => {
if (event.key === 'Enter' && markdownTransform(true)) {
event.stopPropagation();
event.preventDefault();
}
})
);
}

View File

@@ -52,3 +52,17 @@ export function clearMarksOnDiscontinuousInput(
}
});
}
export function getPrefixText(inlineEditor: InlineEditor) {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange || inlineRange.length > 0) return '';
const nearestLineBreakIndex = inlineEditor.yTextString
.slice(0, inlineRange.index)
.lastIndexOf('\n');
const prefixText = inlineEditor.yTextString.slice(
nearestLineBreakIndex + 1,
inlineRange.index
);
return prefixText;
}

View File

@@ -10,6 +10,8 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.12",
@@ -63,7 +65,8 @@
"./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts",
"./services": "./src/services/index.ts",
"./adapters": "./src/adapters/index.ts"
"./adapters": "./src/adapters/index.ts",
"./test-utils": "./src/test-utils/index.ts"
},
"files": [
"src",

View File

@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getFirstBlockCommand', () => {

View File

@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getLastBlockCommand', () => {

View File

@@ -1,13 +1,13 @@
/**
* @vitest-environment happy-dom
*/
import '../../helpers/affine-test-utils';
import '../../../test-utils/affine-test-utils';
import type { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
import { affine, block } from '../../helpers/affine-template';
import { affine, block } from '../../../test-utils';
describe('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => {

View File

@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {

View File

@@ -1,7 +1,7 @@
import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { affine } from './affine-template';
import { affine } from '../../test-utils';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {

View File

@@ -1,29 +1,32 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
import { Container } from '@blocksuite/global/di';
import { TextSelection } from '@blocksuite/std';
import { type Block, type Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { type Block, type Store, Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
// Extensions array
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
const manager = new StoreExtensionManager(getInternalStoreExtensions());
const extensions = manager.get('store');
// // Extensions array
// const extensions = [
// RootBlockSchemaExtension,
// NoteBlockSchemaExtension,
// ParagraphBlockSchemaExtension,
// ListBlockSchemaExtension,
// ImageBlockSchemaExtension,
// DatabaseBlockSchemaExtension,
// CodeBlockSchemaExtension,
// RootStoreExtension,
// NoteStoreExtension,
// ParagraphStoreExtension,
// ListStoreExtension,
// ImageStoreExtension,
// DatabaseStoreExtension,
// CodeStoreExtension
// ];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
@@ -75,8 +78,11 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const store = doc.getStore({ extensions });
const container = new Container();
extensions.forEach(extension => {
extension.setup(container);
});
const store = doc.getStore({ extensions, provider: container.provider() });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string

View File

@@ -63,10 +63,8 @@ function compareBlocks(
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
return false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < actual.children.length; i++) {
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
return false;
for (const [i, child] of actual.children.entries()) {
if (!compareBlocks(child, expected.children[i], compareId)) return false;
}
return true;

View File

@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error
// @ts-expect-error dev-only
host.command = std.command;
host.selection = std.selection;

View File

@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';

View File

@@ -176,9 +176,7 @@ export async function openFilesWith(
resolve(input.files ? Array.from(input.files) : null);
});
// The `cancel` event fires when the user cancels the dialog.
input.addEventListener('cancel', () => {
resolve(null);
});
input.addEventListener('cancel', () => resolve(null));
// Show the picker.
if ('showPicker' in HTMLInputElement.prototype) {
input.showPicker();
@@ -188,16 +186,16 @@ export async function openFilesWith(
});
}
export function openSingleFileWith(
export async function openSingleFileWith(
acceptType?: AcceptTypes
): Promise<File | null> {
return openFilesWith(acceptType, false).then(files => files?.at(0) ?? null);
const files = await openFilesWith(acceptType, false);
return files?.at(0) ?? null;
}
export async function getImageFilesFromLocal() {
const imageFiles = await openFilesWith('Images');
if (!imageFiles) return [];
return imageFiles;
const files = await openFilesWith('Images');
return files ?? [];
}
export function downloadBlob(blob: Blob, name: string) {

View File

@@ -26,3 +26,34 @@ export function readImageSize(file: File | Blob) {
img.src = sanitizedURL;
});
}
export function convertToPng(blob: Blob): Promise<Blob | null> {
return new Promise(resolve => {
const reader = new FileReader();
reader.addEventListener('load', _ => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
const ctx = c.getContext('2d');
if (!ctx) {
resolve(null);
return;
}
ctx.drawImage(img, 0, 0);
c.toBlob(resolve, 'image/png');
};
img.onerror = () => resolve(null);
img.src = reader.result as string;
});
reader.addEventListener('error', () => resolve(null));
reader.readAsDataURL(blob);
});
}

View File

@@ -73,35 +73,3 @@ export function substringMatchScore(name: string, query: string) {
// normalize
return 0.5 * score;
}
/**
* Checks if the prefix is a markdown prefix.
* Ex. 1. 2. 3. - * [] [ ] [x] # ## ### #### ##### ###### --- *** ___ > ```
*/
export function isMarkdownPrefix(prefix: string) {
return (
!!prefix.match(
/^(\d+\.|-|\*|\[ ?\]|\[x\]|(#){1,6}|>|```([a-zA-Z0-9]*))$/
) || isHorizontalRuleMarkdown(prefix)
);
}
/**
* Checks if the prefix is a valid markdown horizontal rule - https://www.markdownguide.org/basic-syntax/#horizontal-rules
* @param prefix - The string to check for horizontal rule syntax
* @returns boolean - True if the string represents a valid horizontal rule
*
* Valid horizontal rules:
* - Three or more consecutive hyphens (e.g., "---", "----")
* - Three or more consecutive asterisks (e.g., "***", "****")
* - Three or more consecutive underscores (e.g., "___", "____")
*
* Invalid examples:
* - Mixed characters (e.g., "--*", "-*-")
* - Less than three characters (e.g., "--", "**")
*/
export function isHorizontalRuleMarkdown(prefix: string) {
const horizontalRulePattern = /^(-{3,}|\*{3,}|_{3,})$/;
return !!prefix.match(horizontalRulePattern);
}

View File

@@ -80,6 +80,11 @@ export class AffineToolbarWidget extends WidgetComponent {
}
}
editor-toolbar[data-open][data-inline='true'] {
transition-property: opacity, overlay, display, transform;
transition-timing-function: ease;
}
editor-toolbar[data-placement='inner'] {
background-color: unset;
box-shadow: unset;
@@ -536,9 +541,7 @@ export class AffineToolbarWidget extends WidgetComponent {
);
});
return () => {
subscription.unsubscribe();
};
disposables.add(subscription);
})
);
@@ -632,6 +635,7 @@ export class AffineToolbarWidget extends WidgetComponent {
// Hides toolbar
if (Flag.None === value || flags.check(Flag.Hiding, value)) {
if ('inline' in toolbar.dataset) delete toolbar.dataset.inline;
if (toolbar.dataset.open) delete toolbar.dataset.open;
// Closes dropdown menus
toolbar

View File

@@ -29,7 +29,7 @@ import {
shift,
size,
} from '@floating-ui/dom';
import { html, nothing, render } from 'lit';
import { html, render } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { keyed } from 'lit/directives/keyed.js';
@@ -297,11 +297,22 @@ export function renderToolbar(
render(
join(
renderActions(primaryActionGroup, context),
innerToolbar ? nothing : renderToolbarSeparator()
innerToolbar ? null : renderToolbarSeparator()
),
toolbar
);
// Avoids shaking
if (flavour === 'affine:note' && context.std.range.value) {
if (!('inline' in toolbar.dataset)) {
toolbar.dataset.inline = '';
} else {
toolbar.dataset.inline = 'true';
}
} else {
delete toolbar.dataset.inline;
}
if (toolbar.dataset.open) return;
toolbar.dataset.open = 'true';
}
@@ -358,14 +369,22 @@ function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
const innerToolbar = context.placement$.value === 'inner';
const ids = action.id.split('.');
const id = ids[ids.length - 1];
const label = action.label ?? action.tooltip ?? id;
const actived =
typeof action.active === 'function'
? action.active(context)
: action.active;
const disabled =
typeof action.disabled === 'function'
? action.disabled(context)
: action.disabled;
return html`
<editor-icon-button
data-testid=${ifDefined(id)}
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
?active=${typeof action.active === 'function'
? action.active(context)
: action.active}
?disabled=${action.disabled}
aria-label=${ifDefined(label)}
?active=${actived}
?disabled=${disabled}
.tooltip=${action.tooltip}
.iconContainerPadding=${innerToolbar ? 4 : 2}
.iconSize=${innerToolbar ? '16px' : undefined}
@@ -383,17 +402,24 @@ function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
const innerToolbar = context.placement$.value === 'inner';
const ids = action.id.split('.');
const id = ids[ids.length - 1];
const label = action.label ?? action.tooltip ?? id;
const actived =
typeof action.active === 'function'
? action.active(context)
: action.active;
const disabled =
typeof action.disabled === 'function'
? action.disabled(context)
: action.disabled;
const destructive = action.variant === 'destructive' ? 'delete' : undefined;
return html`
<editor-menu-action
data-testid=${ifDefined(id)}
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
class="${ifDefined(
action.variant === 'destructive' ? 'delete' : undefined
)}"
?active=${typeof action.active === 'function'
? action.active(context)
: action.active}
?disabled=${action.disabled}
aria-label=${ifDefined(label)}
class="${ifDefined(destructive)}"
?active=${actived}
?disabled=${disabled}
.tooltip=${ifDefined(action.tooltip)}
.iconContainerPadding=${innerToolbar ? 4 : 2}
.iconSize=${innerToolbar ? '16px' : undefined}

View File

@@ -118,10 +118,16 @@ Get the root block of the store.
### addBlock()
> **addBlock**(`flavour`, `blockProps`, `parent?`, `parentIndex?`): `string`
> **addBlock**\<`T`\>(`flavour`, `blockProps`, `parent?`, `parentIndex?`): `string`
Creates and adds a new block to the store
#### Type Parameters
##### T
`T` *extends* `BlockModel`\<`object`\> = `BlockModel`\<`object`\>
#### Parameters
##### flavour
@@ -132,7 +138,7 @@ The block's flavour (type)
##### blockProps
`Partial`\<`BlockSysProps` & `Record`\<`string`, `unknown`\> & `Omit`\<`BlockProps`, `"flavour"`\>\> = `{}`
`Partial`\<`BlockProps` \| `PropsOfModel`\<`T`\> & `BlockSysProps`\> = `{}`
Optional properties for the new block

View File

@@ -124,18 +124,23 @@ export class Clipboard extends LifeCycleWatcher {
copySlice = async (slice: Slice) => {
const adapterKeys = this._adapters.map(adapter => adapter.mimeType);
await this.writeToClipboard(async _items => {
const items = { ..._items };
await this.writeToClipboard(async items => {
const filtered = (
await Promise.all(
adapterKeys.map(async type => {
const item = await this._getClipboardItem(slice, type);
if (typeof item === 'string') {
return [type, item];
}
return null;
})
)
).filter((adapter): adapter is string[] => Boolean(adapter));
await Promise.all(
adapterKeys.map(async type => {
const item = await this._getClipboardItem(slice, type);
if (typeof item === 'string') {
items[type] = item;
}
})
);
return items;
return {
...items,
...Object.fromEntries(filtered),
};
});
};
@@ -263,49 +268,56 @@ export class Clipboard extends LifeCycleWatcher {
}
async writeToClipboard(
updateItems: (
items: Record<string, unknown>
) => Promise<Record<string, unknown>> | Record<string, unknown>
updateItems: <T extends Record<string, unknown>>(items: T) => Promise<T> | T
) {
const _items = {
const items = await updateItems<
Partial<{
'text/plain': string;
'text/html': string;
'image/png': string | Blob;
}>
>({
'text/plain': '',
'text/html': '',
'image/png': '',
};
const items = await updateItems(_items);
const text = items['text/plain'] as string;
const innerHTML = items['text/html'] as string;
const png = items['image/png'] as string | Blob;
});
const text = items['text/plain'] ?? '';
const innerHTML = items['text/html'] ?? '';
const image = items['image/png'];
delete items['text/plain'];
delete items['text/html'];
delete items['image/png'];
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
const htmlBlob = new Blob([html], {
type: 'text/html',
});
const clipboardItems: Record<string, Blob> = {
'text/html': htmlBlob,
};
const clipboardItems: Record<string, Blob> = {};
if (image) {
const type = 'image/png';
delete items[type];
if (typeof image === 'string') {
clipboardItems[type] = new Blob([image], { type });
} else if (image instanceof Blob) {
clipboardItems[type] = image;
}
}
if (text.length > 0) {
const textBlob = new Blob([text], {
type: 'text/plain',
});
clipboardItems['text/plain'] = textBlob;
const type = 'text/plain';
clipboardItems[type] = new Blob([text], { type });
}
if (png instanceof Blob) {
clipboardItems['image/png'] = png;
} else if (png.length > 0) {
const pngBlob = new Blob([png], {
type: 'image/png',
});
clipboardItems['image/png'] = pngBlob;
const hasInnerHTML = Boolean(innerHTML.length);
const isEmpty = Object.keys(clipboardItems).length === 0;
// If there are no items, fall back to snapshot.
if (hasInnerHTML || isEmpty) {
const type = 'text/html';
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
clipboardItems[type] = new Blob([html], { type });
}
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
}
}

View File

@@ -165,8 +165,9 @@ export class InlineEditor<
inlineRangeSync: new Subject<Range | null>(),
/**
* Corresponding to the `compositionUpdate` and `beforeInput` events, and triggered only when the `inlineRange` is not null.
* The parameter is the `event.data`.
*/
inputting: new Subject<void>(),
inputting: new Subject<string>(),
/**
* Triggered only when the `inlineRange` is not null.
*/

View File

@@ -119,7 +119,7 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
this.editor as never
);
this.editor.slots.inputting.next();
this.editor.slots.inputting.next(event.data ?? '');
};
private readonly _onClick = (event: MouseEvent) => {
@@ -181,10 +181,10 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
});
}
this.editor.slots.inputting.next();
this.editor.slots.inputting.next(event.data ?? '');
};
private readonly _onCompositionStart = () => {
private readonly _onCompositionStart = (event: CompositionEvent) => {
this._isComposing = true;
if (!this.editor.rootElement) return;
// embeds is not editable and it will break IME
@@ -201,9 +201,11 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
} else {
this._compositionInlineRange = null;
}
this.editor.slots.inputting.next(event.data ?? '');
};
private readonly _onCompositionUpdate = () => {
private readonly _onCompositionUpdate = (event: CompositionEvent) => {
if (!this.editor.rootElement || !this.editor.rootElement.isConnected) {
return;
}
@@ -216,7 +218,7 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
)
return;
this.editor.slots.inputting.next();
this.editor.slots.inputting.next(event.data ?? '');
};
private readonly _onKeyDown = (event: KeyboardEvent) => {
@@ -359,13 +361,9 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
'compositionupdate',
this._onCompositionUpdate
);
this.editor.disposables.addFromEvent(
eventSource,
'compositionend',
(event: CompositionEvent) => {
this._onCompositionEnd(event).catch(console.error);
}
);
this.editor.disposables.addFromEvent(eventSource, 'compositionend', e => {
this._onCompositionEnd(e).catch(console.error);
});
this.editor.disposables.addFromEvent(
eventSource,
'keydown',

View File

@@ -740,9 +740,9 @@ export class Store {
*
* @category Block CRUD
*/
addBlock(
addBlock<T extends BlockModel = BlockModel>(
flavour: string,
blockProps: Partial<BlockProps & Omit<BlockProps, 'flavour'>> = {},
blockProps: Partial<(PropsOfModel<T> & BlockSysProps) | BlockProps> = {},
parent?: BlockModel | string | null,
parentIndex?: number
): string {

View File

@@ -52,6 +52,7 @@ Generated by [AVA](https://avajs.dev).
[
{
id: 'docId1',
status: 'processing',
},
]

View File

@@ -11,11 +11,11 @@ import { JobQueue } from '../base';
import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth';
import { DocReader } from '../core/doc';
import { CopilotContextService } from '../plugins/copilot/context';
import {
CopilotContextDocJob,
CopilotContextService,
} from '../plugins/copilot/context';
import { MockEmbeddingClient } from '../plugins/copilot/context/embedding';
CopilotEmbeddingJob,
MockEmbeddingClient,
} from '../plugins/copilot/embedding';
import { prompts, PromptService } from '../plugins/copilot/prompt';
import {
CopilotProviderFactory,
@@ -65,7 +65,7 @@ const test = ava as TestFn<{
app: TestingApp;
db: PrismaClient;
context: CopilotContextService;
jobs: CopilotContextDocJob;
jobs: CopilotEmbeddingJob;
prompt: PromptService;
factory: CopilotProviderFactory;
storage: CopilotStorage;
@@ -115,7 +115,7 @@ test.before(async t => {
const context = app.get(CopilotContextService);
const prompt = app.get(PromptService);
const storage = app.get(CopilotStorage);
const jobs = app.get(CopilotContextDocJob);
const jobs = app.get(CopilotEmbeddingJob);
t.context.app = app;
t.context.db = db;

View File

@@ -13,11 +13,11 @@ import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota';
import { ContextCategories, WorkspaceModel } from '../models';
import { CopilotModule } from '../plugins/copilot';
import { CopilotContextService } from '../plugins/copilot/context';
import {
CopilotContextDocJob,
CopilotContextService,
} from '../plugins/copilot/context';
import { MockEmbeddingClient } from '../plugins/copilot/context/embedding';
CopilotEmbeddingJob,
MockEmbeddingClient,
} from '../plugins/copilot/embedding';
import { prompts, PromptService } from '../plugins/copilot/prompt';
import {
CopilotProviderFactory,
@@ -69,7 +69,7 @@ const test = ava as TestFn<{
workspaceEmbedding: CopilotWorkspaceService;
factory: CopilotProviderFactory;
session: ChatSessionService;
jobs: CopilotContextDocJob;
jobs: CopilotEmbeddingJob;
storage: CopilotStorage;
workflow: CopilotWorkflowService;
executors: {
@@ -127,7 +127,7 @@ test.before(async t => {
const storage = module.get(CopilotStorage);
const context = module.get(CopilotContextService);
const jobs = module.get(CopilotContextDocJob);
const jobs = module.get(CopilotEmbeddingJob);
const transcript = module.get(CopilotTranscriptionService);
const workspaceEmbedding = module.get(CopilotWorkspaceService);

View File

@@ -53,3 +53,195 @@ Generated by [AVA](https://avajs.dev).
> should return true when embedding table is available
true
## should merge doc status correctly
> basic doc status merge
[
{
id: 'doc1',
status: 'processing',
},
{
id: 'doc2',
status: 'processing',
},
{
id: 'doc3',
status: 'failed',
},
{
id: 'doc4',
status: 'processing',
},
]
> mixed doc status merge
[
{
id: 'doc5',
status: 'finished',
},
{
id: 'doc5',
status: 'finished',
},
{
id: 'doc6',
status: 'processing',
},
{
id: 'doc6',
status: 'failed',
},
{
id: 'doc7',
status: 'processing',
},
]
> edge cases results
[
{
case: 0,
length: 1,
statuses: [
'processing',
],
},
{
case: 1,
length: 1,
statuses: [
'processing',
],
},
{
case: 2,
length: 100,
statuses: [
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
'processing',
],
},
]
## should handle concurrent mergeDocStatus calls
> concurrent calls results
[
{
call: 1,
status: 'finished',
},
{
call: 2,
status: 'finished',
},
{
call: 3,
status: 'processing',
},
]

View File

@@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto';
import { AiSession, PrismaClient, User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { Config } from '../../base';
import { ContextEmbedStatus } from '../../models/common/copilot';
import { CopilotContextModel } from '../../models/copilot-context';
import { CopilotSessionModel } from '../../models/copilot-session';
import { CopilotWorkspaceConfigModel } from '../../models/copilot-workspace';
@@ -236,3 +238,173 @@ test('should check embedding table', async t => {
// t.false(ret, 'should return false when embedding table is not available');
// }
});
test('should merge doc status correctly', async t => {
const createDoc = (id: string, status?: string) => ({
id,
createdAt: Date.now(),
...(status && { status: status as any }),
});
const createDocWithEmbedding = async (docId: string) => {
await t.context.db.snapshot.create({
data: {
workspaceId: workspace.id,
id: docId,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
updatedAt: new Date(),
createdAt: new Date(),
},
});
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
};
const emptyResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
[]
);
t.deepEqual(emptyResult, []);
const basicDocs = [
createDoc('doc1'),
createDoc('doc2'),
createDoc('doc3', 'failed'),
createDoc('doc4', 'processing'),
];
const basicResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
basicDocs
);
t.snapshot(
basicResult.map(d => ({ id: d.id, status: d.status })),
'basic doc status merge'
);
{
await createDocWithEmbedding('doc5');
const mixedDocs = [
createDoc('doc5'),
createDoc('doc5', 'processing'),
createDoc('doc6'),
createDoc('doc6', 'failed'),
createDoc('doc7'),
];
const mixedResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
mixedDocs
);
t.snapshot(
mixedResult.map(d => ({ id: d.id, status: d.status })),
'mixed doc status merge'
);
const hasEmbeddingStub = Sinon.stub(
t.context.copilotContext,
'hasWorkspaceEmbedding'
).resolves(new Set<string>());
const stubResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
[createDoc('doc5')]
);
t.is(stubResult[0].status, ContextEmbedStatus.processing);
hasEmbeddingStub.restore();
}
{
const testCases = [
{
workspaceId: 'invalid-workspace',
docs: [{ id: 'doc1', createdAt: Date.now() }],
},
{
workspaceId: workspace.id,
docs: [{ id: 'doc1', createdAt: Date.now(), status: undefined as any }],
},
{
workspaceId: workspace.id,
docs: Array.from({ length: 100 }, (_, i) => ({
id: `doc-${i}`,
createdAt: Date.now() + i,
})),
},
];
const results = await Promise.all(
testCases.map(testCase =>
t.context.copilotContext.mergeDocStatus(
testCase.workspaceId,
testCase.docs
)
)
);
t.snapshot(
results.map((result, index) => ({
case: index,
length: result.length,
statuses: result.map(d => d.status),
})),
'edge cases results'
);
}
});
test('should handle concurrent mergeDocStatus calls', async t => {
await t.context.db.snapshot.create({
data: {
workspaceId: workspace.id,
id: 'concurrent-doc',
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
updatedAt: new Date(),
createdAt: new Date(),
},
});
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
'concurrent-doc',
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
const concurrentDocs = [
[{ id: 'concurrent-doc', createdAt: Date.now() }],
[{ id: 'concurrent-doc', createdAt: Date.now() + 1000 }],
[{ id: 'non-existent-doc', createdAt: Date.now() }],
];
const results = await Promise.all(
concurrentDocs.map(docs =>
t.context.copilotContext.mergeDocStatus(workspace.id, docs)
)
);
t.snapshot(
results.map((result, index) => ({
call: index + 1,
status: result[0].status,
})),
'concurrent calls results'
);
});

View File

@@ -36,6 +36,10 @@ test.beforeEach(async t => {
workspace = await t.context.models.workspace.create(user.id);
});
test.afterEach.always(async () => {
mock.reset();
});
test.after.always(async t => {
await t.context.app.close();
});
@@ -176,6 +180,15 @@ test('should get doc content in json format', async t => {
summary: 'test summary',
})
.expect(200);
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=false`)
.set('x-access-token', t.context.crypto.sign(docId))
.expect({
title: 'test title',
summary: 'test summary',
})
.expect(200);
t.pass();
});
@@ -184,7 +197,7 @@ test('should get full doc content in json format', async t => {
mock.method(t.context.databaseDocReader, 'getFullDocContent', async () => {
return {
title: 'test title',
summary: 'test summary',
summary: 'test summary full',
};
});
@@ -194,7 +207,7 @@ test('should get full doc content in json format', async t => {
.set('x-access-token', t.context.crypto.sign(docId))
.expect({
title: 'test title',
summary: 'test summary',
summary: 'test summary full',
})
.expect(200);
t.pass();

View File

@@ -4,10 +4,10 @@ import {
Logger,
Param,
Post,
Query,
RawBody,
Res,
} from '@nestjs/common';
import { Args } from '@nestjs/graphql';
import type { Response } from 'express';
import { NotFound, SkipThrottle } from '../../base';
@@ -78,11 +78,12 @@ export class DocRpcController {
async getDocContent(
@Param('workspaceId') workspaceId: string,
@Param('docId') docId: string,
@Args('full', { nullable: true }) fullContent?: boolean
@Query('full') fullContent?: string
) {
const content = fullContent
? await this.docReader.getFullDocContent(workspaceId, docId)
: await this.docReader.getDocContent(workspaceId, docId);
const content =
fullContent === 'true'
? await this.docReader.getFullDocContent(workspaceId, docId)
: await this.docReader.getDocContent(workspaceId, docId);
if (!content) {
throw new NotFound('Doc not found');
}

View File

@@ -605,6 +605,7 @@ Generated by [AVA](https://avajs.dev).
'BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA=',
],
blockId: 'lcZphIJe63',
content: '',
docId: 'doc-0',
flavour: 'affine:image',
parentBlockId: '6x7ALjUDjj',
@@ -619,6 +620,7 @@ Generated by [AVA](https://avajs.dev).
'HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8=',
],
blockId: 'JlgVJdWU12',
content: '',
docId: 'doc-0',
flavour: 'affine:image',
parentBlockId: '6x7ALjUDjj',
@@ -633,6 +635,7 @@ Generated by [AVA](https://avajs.dev).
'ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0=',
],
blockId: 'lht7AqBqnF',
content: '',
docId: 'doc-0',
flavour: 'affine:image',
parentBlockId: '6x7ALjUDjj',
@@ -1236,6 +1239,7 @@ Generated by [AVA](https://avajs.dev).
'BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA=',
],
blockId: 'lcZphIJe63',
content: '',
docId: 'doc-0',
flavour: 'affine:image',
parentBlockId: '6x7ALjUDjj',
@@ -1250,6 +1254,7 @@ Generated by [AVA](https://avajs.dev).
'HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8=',
],
blockId: 'JlgVJdWU12',
content: '',
docId: 'doc-0',
flavour: 'affine:image',
parentBlockId: '6x7ALjUDjj',
@@ -1264,6 +1269,7 @@ Generated by [AVA](https://avajs.dev).
'ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0=',
],
blockId: 'lht7AqBqnF',
content: '',
docId: 'doc-0',
flavour: 'affine:image',
parentBlockId: '6x7ALjUDjj',

View File

@@ -91,7 +91,9 @@ export class CopilotContextModel extends BaseModel {
const status = finishedDoc.has(doc.id)
? ContextEmbedStatus.finished
: undefined;
doc.status = status || doc.status;
// NOTE: when the document has not been synchronized to the server or is in the embedding queue
// the status will be empty, fallback to processing if no status is provided
doc.status = status || doc.status || ContextEmbedStatus.processing;
}
return docs;

View File

@@ -1,3 +1,2 @@
export { CopilotContextDocJob } from './job';
export { CopilotContextResolver, CopilotContextRootResolver } from './resolver';
export { CopilotContextService } from './service';

View File

@@ -44,12 +44,12 @@ import {
FileChunkSimilarity,
Models,
} from '../../../models';
import { CopilotEmbeddingJob } from '../embedding';
import { COPILOT_LOCKER, CopilotType } from '../resolver';
import { ChatSessionService } from '../session';
import { CopilotStorage } from '../storage';
import { MAX_EMBEDDABLE_SIZE } from '../types';
import { readStream } from '../utils';
import { CopilotContextDocJob } from './job';
import { CopilotContextService } from './service';
@InputType()
@@ -387,7 +387,7 @@ export class CopilotContextResolver {
private readonly models: Models,
private readonly mutex: RequestMutex,
private readonly context: CopilotContextService,
private readonly jobs: CopilotContextDocJob,
private readonly jobs: CopilotEmbeddingJob,
private readonly storage: CopilotStorage
) {}

View File

@@ -14,11 +14,10 @@ import {
ContextFile,
Models,
} from '../../../models';
import { type EmbeddingClient, getEmbeddingClient } from '../embedding';
import { PromptService } from '../prompt';
import { CopilotProviderFactory } from '../providers';
import { getEmbeddingClient } from './embedding';
import { ContextSession } from './session';
import type { EmbeddingClient } from './types';
const CONTEXT_SESSION_KEY = 'context-session';
@@ -158,14 +157,15 @@ export class CopilotContextService implements OnApplicationBootstrap {
const embedding = await this.embeddingClient.getEmbedding(content, signal);
if (!embedding) return [];
const chunks = await this.models.copilotWorkspace.matchFileEmbedding(
const fileChunks = await this.models.copilotWorkspace.matchFileEmbedding(
workspaceId,
embedding,
topK * 2,
threshold
);
if (!fileChunks.length) return [];
return this.embeddingClient.reRank(content, chunks, topK, signal);
return this.embeddingClient.reRank(content, fileChunks, topK, signal);
}
async matchWorkspaceDocs(
@@ -179,14 +179,16 @@ export class CopilotContextService implements OnApplicationBootstrap {
const embedding = await this.embeddingClient.getEmbedding(content, signal);
if (!embedding) return [];
const workspace = await this.models.copilotContext.matchWorkspaceEmbedding(
embedding,
workspaceId,
topK * 2,
threshold
);
const workspaceChunks =
await this.models.copilotContext.matchWorkspaceEmbedding(
embedding,
workspaceId,
topK * 2,
threshold
);
if (!workspaceChunks.length) return [];
return this.embeddingClient.reRank(content, workspace, topK);
return this.embeddingClient.reRank(content, workspaceChunks, topK, signal);
}
@OnEvent('workspace.doc.embed.failed')

View File

@@ -11,11 +11,11 @@ import {
FileChunkSimilarity,
Models,
} from '../../../models';
import { EmbeddingClient } from './types';
import { EmbeddingClient } from '../embedding';
export class ContextSession implements AsyncDisposable {
constructor(
private readonly client: EmbeddingClient,
private readonly client: EmbeddingClient | undefined,
private readonly contextId: string,
private readonly config: ContextConfig,
private readonly models: Models,
@@ -204,6 +204,7 @@ export class ContextSession implements AsyncDisposable {
scopedThreshold: number = 0.85,
threshold: number = 0.5
): Promise<FileChunkSimilarity[]> {
if (!this.client) return [];
const embedding = await this.client.getEmbedding(content, signal);
if (!embedding) return [];
@@ -256,6 +257,7 @@ export class ContextSession implements AsyncDisposable {
scopedThreshold: number = 0.85,
threshold: number = 0.5
) {
if (!this.client) return [];
const embedding = await this.client.getEmbedding(content, signal);
if (!embedding) return [];

Some files were not shown because too many files have changed in this diff Show More