Compare commits

..

37 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
159 changed files with 2792 additions and 1811 deletions
@@ -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 }}
@@ -36,6 +36,7 @@ resources:
probe:
initialDelaySeconds: 20
timeoutSeconds: 5
nodeSelector: {}
tolerations: []
-1
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
@@ -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);
@@ -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,
@@ -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);
},
});
@@ -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;
}
@@ -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([
@@ -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(() => {
@@ -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);
@@ -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:*",
@@ -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);
}
},
});
@@ -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,
]);
}
}
@@ -10,6 +10,7 @@
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
@@ -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);
+1 -22
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
) {
@@ -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;
},
};
},
{
@@ -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);
}
},
});
@@ -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,
]);
}
}
@@ -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);
}
},
});
@@ -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>
`;
}
@@ -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()
@@ -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;
}
@@ -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,
]);
}
}
@@ -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 = {
@@ -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(
@@ -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;
@@ -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);
@@ -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;
@@ -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);
}
@@ -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();
},
}),
],
@@ -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 = () => {};
@@ -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();
},
}),
],
@@ -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();
}
}
@@ -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();
},
}),
]);
@@ -73,6 +73,7 @@ export class KanbanViewUILogic extends DataViewUILogicBase<
rowId,
});
}
this.ui$.value?.requestUpdate();
return rowId;
};
@@ -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();
},
}),
]);
@@ -38,6 +38,7 @@ export const popMobileRowMenu = (
prefix: DeleteIcon(),
select: () => {
view.rowsDelete([rowId]);
tableViewLogic.ui$.value?.requestUpdate();
},
}),
],
@@ -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;
@@ -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 {
@@ -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() {
@@ -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)
@@ -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();
},
}),
]);
@@ -71,6 +71,7 @@ export const popRowMenu = (
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowsDelete(rows);
selectionController.logic.ui$.value?.requestUpdate();
},
}),
],
@@ -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;
@@ -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 {
@@ -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() {
@@ -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();
},
}),
]);
@@ -71,6 +71,7 @@ export const popRowMenu = (
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowsDelete(rows);
selectionController.logic.ui$.value?.requestUpdate();
},
}),
],
@@ -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,
+5 -14
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();
@@ -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;
+36 -126
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,
-1
View File
@@ -10,6 +10,5 @@ export {
onModelTextUpdated,
selectTextModel,
} from './dom';
export { markdownInput } from './markdown';
export { RichText } from './rich-text';
export * from './utils';
@@ -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;
}
@@ -1 +0,0 @@
export { markdownInput } from './markdown-input.js';
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
+49 -26
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();
}
})
);
}
+14
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;
}
+4 -1
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",
@@ -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', () => {
@@ -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', () => {
@@ -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', () => {
@@ -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', () => {
@@ -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', () => {
@@ -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
@@ -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;
@@ -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;
@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';
@@ -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) {
@@ -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);
});
}
@@ -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);
}
@@ -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
+41 -15
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}
@@ -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
@@ -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)]);
}
}
@@ -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.
*/
@@ -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',
@@ -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 {
@@ -52,6 +52,7 @@ Generated by [AVA](https://avajs.dev).
[
{
id: 'docId1',
status: 'processing',
},
]
@@ -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;
@@ -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);
@@ -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',
},
]
@@ -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'
);
});
@@ -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();
@@ -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');
}
@@ -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',
@@ -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;
@@ -1,3 +1,2 @@
export { CopilotContextDocJob } from './job';
export { CopilotContextResolver, CopilotContextRootResolver } from './resolver';
export { CopilotContextService } from './service';
@@ -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
) {}
@@ -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')
@@ -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