Merge branch 'develop' into fix/clipboard

This commit is contained in:
QiShaoXuan 2022-08-11 22:59:57 +08:00
commit 7d101edf71
86 changed files with 2029 additions and 844 deletions

View File

@ -1,12 +1,12 @@
{
"projectName": "toeverything",
"projectName": "AFFiNE",
"projectOwner": "toeverything",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"imageSize": 50,
"commit": false,
"commitConvention": "angular",
"contributorsPerLine": 7,
@ -115,11 +115,120 @@
"login": "uptonking",
"name": "Jin Yao",
"avatar_url": "https://avatars.githubusercontent.com/u/11391549?v=4",
"profile": "https://github.com/uptonking?tab=repositories&type=source",
"profile": "https://github.com/uptonking",
"contributions": [
"code",
"doc"
]
},
{
"login": "HeJiachen-PM",
"name": "HeJiachen-PM",
"avatar_url": "https://avatars.githubusercontent.com/u/79301703?v=4",
"profile": "https://github.com/HeJiachen-PM",
"contributions": [
"doc"
]
},
{
"login": "Yipei-Operation",
"name": "Yipei Wei",
"avatar_url": "https://avatars.githubusercontent.com/u/79373028?v=4",
"profile": "https://github.com/Yipei-Operation",
"contributions": [
"doc"
]
},
{
"login": "fanjing22",
"name": "fanjing22",
"avatar_url": "https://avatars.githubusercontent.com/u/109729699?v=4",
"profile": "https://github.com/fanjing22",
"contributions": [
"design"
]
},
{
"login": "Svaney-ssman",
"name": "Svaney",
"avatar_url": "https://avatars.githubusercontent.com/u/110808979?v=4",
"profile": "https://github.com/Svaney-ssman",
"contributions": [
"design"
]
},
{
"login": "xell",
"name": "Guozhu Liu",
"avatar_url": "https://avatars.githubusercontent.com/u/132558?v=4",
"profile": "http://xell.me/",
"contributions": [
"design"
]
},
{
"login": "fyZheng07",
"name": "fyZheng07",
"avatar_url": "https://avatars.githubusercontent.com/u/63830919?v=4",
"profile": "https://github.com/fyZheng07",
"contributions": [
"eventOrganizing",
"userTesting"
]
},
{
"login": "CJSS",
"name": "CJSS",
"avatar_url": "https://avatars.githubusercontent.com/u/4605025?v=4",
"profile": "https://github.com/CJSS",
"contributions": [
"doc"
]
},
{
"login": "CarlosZoft",
"name": "Carlos Rafael ",
"avatar_url": "https://avatars.githubusercontent.com/u/62192072?v=4",
"profile": "https://github.com/clean-software",
"contributions": [
"code"
]
},
{
"login": "caleboleary",
"name": "Caleb OLeary",
"avatar_url": "https://avatars.githubusercontent.com/u/12816579?v=4",
"profile": "https://github.com/caleboleary",
"contributions": [
"code"
]
},
{
"login": "JimmFly",
"name": "JimmFly",
"avatar_url": "https://avatars.githubusercontent.com/u/102217452?v=4",
"profile": "https://github.com/JimmFly",
"contributions": [
"code"
]
},
{
"login": "westongraham",
"name": "Weston Graham",
"avatar_url": "https://avatars.githubusercontent.com/u/89493023?v=4",
"profile": "https://github.com/westongraham",
"contributions": [
"doc"
]
},
{
"login": "pointmax",
"name": "pointmax",
"avatar_url": "https://avatars.githubusercontent.com/u/49361135?v=4",
"profile": "https://github.com/pointmax",
"contributions": [
"doc"
]
}
]
}

View File

@ -2,7 +2,7 @@ FROM node:16-alpine as builder
WORKDIR /app
COPY . .
RUN apk add g++ make python3 git libpng-dev
RUN npm i -g pnpm@7 && pnpm i --frozen-lockfile --store=node_modules/.pnpm-store && pnpm run build:local
RUN npm i -g pnpm@7 && pnpm i --frozen-lockfile --store=node_modules/.pnpm-store && pnpm run build:local --skip-nx-cache
FROM node:16-alpine as relocate
WORKDIR /app
@ -18,4 +18,4 @@ WORKDIR /app
COPY --from=relocate /app .
EXPOSE 3000
CMD ["caddy", "run"]
CMD ["caddy", "run"]

View File

@ -4,6 +4,7 @@
"editor.formatOnSaveMode": "file",
"prettier.prettierPath": "./node_modules/prettier",
"cSpell.words": [
"AUTOINCREMENT",
"Backlinks",
"blockdb",
"booktitle",
@ -12,6 +13,7 @@
"cssmodule",
"datasource",
"fflate",
"fstore",
"groq",
"howpublished",
"immer",

45
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,45 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, available at <https://www.contributor-covenant.org/version/1/4/code-of-conduct.html>
For answers to common questions about this code of conduct, see <https://www.contributor-covenant.org/faq>

88
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,88 @@
# Welcome to ourcontributing guide <!-- omit in toc -->
Thank you for investing your time in contributing to our project! Any contribution you make will be reflected on our GitHub :sparkles:.
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
In this guide you will get an overview of the contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR.
Use the table of contents icon on the top left corner of this document to get to a specific section of this guide quickly.
## New contributor guide
To get an overview of the project, read the [README](README.md). Here are some resources to help you get started with open source contributions:
- [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
- [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git)
- [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
## Getting started
To navigate our codebase with confidence, see [the introduction to working in the docs repository](/contributing/working-in-docs-repository.md) :confetti_ball:. For more information on how we write our markdown files, see [the GitHub Markdown reference](contributing/content-markup-reference.md).
Check to see what [types of contributions](/contributing/types-of-contributions.md) we accept before making changes. Some of them don't even require writing a single line of code :sparkles:.
### Issues
#### Create a new issue
If you spot a problem, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/toeverything/AFFiNE/issues/new/choose).
#### Solve an issue
Scan through our [existing issues](https://github.com/toeverything/AFFiNE/issues) to find one that interests you. You can narrow down the search using `labels` as filters. See [Labels](/contributing/how-to-use-labels.md) for more information. As a general rule, we dont assign issues to anyone. If you find an issue to work on, you are welcome to open a PR with a fix.
### Make Changes
#### Make changes in the UI
Click **Make a contribution** at the bottom of any docs page to make small changes such as a typo, sentence fix, or a broken link. This takes you to the `.md` file where you can make your changes and [create a pull request](#pull-request) for a review.
#### Make changes in a codespace
For more information about using a codespace for working on GitHub documentation, see "[Working in a codespace](https://github.com/github/docs/blob/main/contributing/codespace.md)."
#### Make changes locally
1. [Install Git LFS](https://docs.github.com/en/github/managing-large-files/versioning-large-files/installing-git-large-file-storage).
2. Fork the repository.
- Using GitHub Desktop:
- [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop.
- Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)!
- Using the command line:
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
3. Install or update to **Node.js v16**. For more information, see [the development guide](contributing/development.md).
4. Create a working branch and start with your changes!
### Commit your update
Commit the changes once you are happy with them.
Once your changes are ready, don't forget to self-review to speed up the review process:zap:.
### Pull Request
When you're finished with the changes, create a pull request, also known as a PR.
- Fill the "Ready for review" template so that we can review your PR. This template helps reviewers understand your changes as well as the purpose of your pull request.
- Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one.
- Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge.
Once you submit your PR, a Docs team member will review your proposal. We may ask questions or request for additional information.
- We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch.
- As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations).
- If you run into any merge issues, checkout this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to help you resolve merge conflicts and other issues.
### Your PR is merged!
Congratulations :tada::tada: The AFFiNE team thanks you :sparkles:.
Once your PR is merged, your contributions will be publicly visible on the our GitHub.
Now that you are part of the AFFiNE community, see how else you can join and help over at [Gitbook](https://docs.affine.pro/affine/)

View File

@ -18,7 +18,7 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
-->
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[all-contributors-badge]: https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square
[all-contributors-badge]: https://img.shields.io/badge/all_contributors-23-orange.svg?style=flat-square
<!-- ALL-CONTRIBUTORS-BADGE:END -->
@ -46,8 +46,8 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
# How to use
If you have experience in front-end development, please [refer to here](https://affine.gitbook.io/affine/basic-documentation/contribute-to-affine); if you want to experience our latest version, please wait a moment, we will launch a web version in the near future.
And, thanks to Lee who [made a desktop build with Tauri](https://github.com/m1911star/affine-client) for you to try out.
If you have experience in front-end development, you may wish to refer to our [documentation](https://docs.affine.pro/affine/basic-documentation/contribute-to-affine) to learn more about deploying your own version or contributing further to development. For those intersting in trying our latest version, please bear with us as we are planning to launch a web version soon.
Also, thanks to Lee who has made a [desktop build with Tauri](https://github.com/m1911star/affine-client) for you to try out.
Please notice that AFFiNE is still under Alpha stage and is not ready for production use.
# Table of contents
@ -85,13 +85,13 @@ Please notice that AFFiNE is still under Alpha stage and is not ready for produc
## Create your story
We want your data always to be yours, and we don't want to make any sacrifice to your accessibility. Your data is always local-stored first, yet we support real-time collaboration on a peer-to-peer basis. We don't think "privacy-first" is a good excuse for not supporting modern web features.
Collaboration isn't only necessary for teams -- you may take and insert pics on your phone, then edit them on your desktop, and share them with your collaborators.
Affine is fully built with web technologies so that consistency and accessibility are always guaranteed on Mac, Windows and Linux. The local file system support will be available when version 0.0.1beta is released.
We want your data always to be yours, without any sacrifice to your accessibility. Your data is always stored local first, yet we support real-time collaboration on a peer-to-peer basis. We don't think "privacy-first" is a good excuse for not supporting modern web features.
And when it comes to collaboration, these features are not just necessarily for teams -- you can take and insert pictures on your phone, edit them from your desktop, and then share them with your collaborators.
Affine is fully built with web technologies to ensure consistency and accessibility on Mac, Windows and Linux. The local file system support will be available when version 0.0.1beta is released.
# Documentation
AFFiNE is not yet ready for production use. To install, you may check how to build or deploy the AFFiNE in [quick-start](https://affine.gitbook.io/affine/basic-documentation/contribute-to-affine/quick-start). For the full documentation, please view it [here](https://affine.gitbook.io/affine/).
AFFiNE is not yet ready for production use. For installation, you may check how to build or deploy AFFiNE from our [quick-start](https://docs.affine.pro/affine/basic-documentation/contribute-to-affine/quick-start) guide. Alternatively, you can view our [full documentation](https://docs.affine.pro/affine/).
## Getting Started with development
@ -107,28 +107,28 @@ Get our latest [release notes](https://github.com/toeverything/AFFiNE/wiki) from
# Feature requests
Please go to [Feature request](https://github.com/toeverything/AFFiNE/issues).
Please go to [feature requests](https://github.com/toeverything/AFFiNE/issues).
# FAQ
Get quick help on [Telegram](https://t.me/affineworkos) and [Discord](https://discord.gg/yz6tGVsf5p) along with other developers and contributors.
Get quick help on [Telegram](https://t.me/affineworkos) or [Discord](https://discord.gg/yz6tGVsf5p) and join our community of developers and contributors.
Latest news and technology sharing on [Twitter](https://twitter.com/AffineOfficial), [Medium](https://medium.com/@affineworkos) and [AFFiNE Blog](https://blog.affine.pro/).
Our latest news can be found on [Twitter](https://twitter.com/AffineOfficial), [Medium](https://medium.com/@affineworkos) and the [AFFiNE Blog](https://blog.affine.pro/).
# The Philosophy of AFFiNE
Timothy Berners-Lee once taught us about the idea of the semantic web, where all the data can be interpreted in any form while the "truth" is kept. This gives our best image of an ideal knowledge base by far, that sorting of information, planning of project and goals as well as creating of knowledge can be all together.
We have witnessed waves of paradigm shift so many times. At first, everything was noted on office-like apps or DSL like LaTeX, then we found todo-list apps and WYSIWYG markdown editors better for writing and planning. Finally, here comes Notion and Miro, who take advantage of the idea of blocks to further liberate our creativity.
It is all perfect... If there are not so many waste operations and redundant information. And, we insist that privacy first should always be given by default.
It is all perfect... without waste operations and redundant information. And, we insist that privacy first should always be given by default.
That's why we are making AFFiNE. Some of the most important features are:
- Transformable
- Every block can be transformed equally well as a database
- e.g. you can now set up a to-do with MarkDown in text view and edit it in kanban view.
- Every doc can be turned into a whiteboard
- Every block can be transformed equally
- e.g. you can create a todo in Markdown in the text view and then later edit it in the kanban view.
- Every document can be turned into a whiteboard
- An always good-to-read, structured docs-form page is the best for your notes, but a boundless doodle surface is better for collaboration and creativity.
- Atomic
- The basic element of affine are blocks, not pages.
- The basic elements of AFFiNE are blocks, not pages.
- Blocks can be directly reused and synced between pages.
- Pages and blocks are searched and organized based on connected graphs, not tree-like paths.
- Dual-link and semantic search are fully supported.
@ -136,8 +136,7 @@ That's why we are making AFFiNE. Some of the most important features are:
- Data is always stored locally by default
- CRDTs are applied so that peer-to-peer collaboration is possible.
We really appreciate the idea of Monday, Airtable and Notion databases. They inspired what we think is right for task management. But we don't like the repeated works -- we don't want to set a todo easily with markdown but end up re-write it again in kanban or other databases.
With AFFiNE, every block group has infinite views, for you to keep your single source of truth.
We appreciate the ideas of Monday, Airtable, and Notion databases. They have inspired us and shaped our product, helping us get it right when it comes to task management. But we also do things differently. We don't like doing things again and again. It's easy to set a todo with Markdown, but then why do you need to repeat and recreate data for a kanban or other databases. This is the power of AFFiNE. With AFFiNE, every block group has infinite views, for you to keep your single source of data, a signle source of truth.
We would like to give special thanks to the innovators and pioneers who greatly inspired us:
@ -172,19 +171,35 @@ For help, discussion about best practices, or any other conversation that would
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://darksky.eu.org/"><img src="https://avatars.githubusercontent.com/u/25152247?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DarkSky</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=darkskygit" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=darkskygit" title="Documentation">📖</a></td>
<td align="center"><a href="http://zhangchi.page/"><img src="https://avatars.githubusercontent.com/u/5910926?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chi Zhang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=tzhangchi" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=tzhangchi" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/alt1o"><img src="https://avatars.githubusercontent.com/u/21084335?v=4?s=100" width="100px;" alt=""/><br /><sub><b>wang xinglong</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=alt1o" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=alt1o" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/DiamondThree"><img src="https://avatars.githubusercontent.com/u/24630517?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DiamondThree</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=DiamondThree" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=DiamondThree" title="Documentation">📖</a></td>
<td align="center"><a href="https://lawvs.github.io/profile/"><img src="https://avatars.githubusercontent.com/u/18554747?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Whitewater</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=lawvs" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=lawvs" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/zuoxiaodong0815"><img src="https://avatars.githubusercontent.com/u/53252747?v=4?s=100" width="100px;" alt=""/><br /><sub><b>xiaodong zuo</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=zuoxiaodong0815" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=zuoxiaodong0815" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/SaikaSakura"><img src="https://avatars.githubusercontent.com/u/11530942?v=4?s=100" width="100px;" alt=""/><br /><sub><b>MingLIang Wang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=SaikaSakura" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=SaikaSakura" title="Documentation">📖</a></td>
<td align="center"><a href="https://darksky.eu.org/"><img src="https://avatars.githubusercontent.com/u/25152247?v=4?s=50" width="50px;" alt=""/><br /><sub><b>DarkSky</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=darkskygit" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=darkskygit" title="Documentation">📖</a></td>
<td align="center"><a href="http://zhangchi.page/"><img src="https://avatars.githubusercontent.com/u/5910926?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Chi Zhang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=tzhangchi" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=tzhangchi" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/alt1o"><img src="https://avatars.githubusercontent.com/u/21084335?v=4?s=50" width="50px;" alt=""/><br /><sub><b>wang xinglong</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=alt1o" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=alt1o" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/DiamondThree"><img src="https://avatars.githubusercontent.com/u/24630517?v=4?s=50" width="50px;" alt=""/><br /><sub><b>DiamondThree</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=DiamondThree" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=DiamondThree" title="Documentation">📖</a></td>
<td align="center"><a href="https://lawvs.github.io/profile/"><img src="https://avatars.githubusercontent.com/u/18554747?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Whitewater</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=lawvs" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=lawvs" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/zuoxiaodong0815"><img src="https://avatars.githubusercontent.com/u/53252747?v=4?s=50" width="50px;" alt=""/><br /><sub><b>xiaodong zuo</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=zuoxiaodong0815" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=zuoxiaodong0815" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/SaikaSakura"><img src="https://avatars.githubusercontent.com/u/11530942?v=4?s=50" width="50px;" alt=""/><br /><sub><b>MingLIang Wang</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=SaikaSakura" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=SaikaSakura" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/QiShaoXuan"><img src="https://avatars.githubusercontent.com/u/22772830?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Qi</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=QiShaoXuan" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=QiShaoXuan" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mitsuhatu"><img src="https://avatars.githubusercontent.com/u/110213079?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mitsuhatu</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=mitsuhatu" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=mitsuhatu" title="Documentation">📖</a></td>
<td align="center"><a href="https://shockwave.me/"><img src="https://avatars.githubusercontent.com/u/15013925?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Austaras</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Austaras" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=Austaras" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/uptonking?tab=repositories&type=source"><img src="https://avatars.githubusercontent.com/u/11391549?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jin Yao</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=uptonking" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=uptonking" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/QiShaoXuan"><img src="https://avatars.githubusercontent.com/u/22772830?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Qi</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=QiShaoXuan" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=QiShaoXuan" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mitsuhatu"><img src="https://avatars.githubusercontent.com/u/110213079?v=4?s=50" width="50px;" alt=""/><br /><sub><b>mitsuhatu</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=mitsuhatu" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=mitsuhatu" title="Documentation">📖</a></td>
<td align="center"><a href="https://shockwave.me/"><img src="https://avatars.githubusercontent.com/u/15013925?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Austaras</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Austaras" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=Austaras" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/uptonking"><img src="https://avatars.githubusercontent.com/u/11391549?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Jin Yao</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=uptonking" title="Code">💻</a> <a href="https://github.com/toeverything/AFFiNE/commits?author=uptonking" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/HeJiachen-PM"><img src="https://avatars.githubusercontent.com/u/79301703?v=4?s=50" width="50px;" alt=""/><br /><sub><b>HeJiachen-PM</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=HeJiachen-PM" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Yipei-Operation"><img src="https://avatars.githubusercontent.com/u/79373028?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Yipei Wei</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=Yipei-Operation" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/fanjing22"><img src="https://avatars.githubusercontent.com/u/109729699?v=4?s=50" width="50px;" alt=""/><br /><sub><b>fanjing22</b></sub></a><br /><a href="#design-fanjing22" title="Design">🎨</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Svaney-ssman"><img src="https://avatars.githubusercontent.com/u/110808979?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Svaney</b></sub></a><br /><a href="#design-Svaney-ssman" title="Design">🎨</a></td>
<td align="center"><a href="http://xell.me/"><img src="https://avatars.githubusercontent.com/u/132558?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Guozhu Liu</b></sub></a><br /><a href="#design-xell" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/fyZheng07"><img src="https://avatars.githubusercontent.com/u/63830919?v=4?s=50" width="50px;" alt=""/><br /><sub><b>fyZheng07</b></sub></a><br /><a href="#eventOrganizing-fyZheng07" title="Event Organizing">📋</a> <a href="#userTesting-fyZheng07" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/CJSS"><img src="https://avatars.githubusercontent.com/u/4605025?v=4?s=50" width="50px;" alt=""/><br /><sub><b>CJSS</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=CJSS" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/clean-software"><img src="https://avatars.githubusercontent.com/u/62192072?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Carlos Rafael </b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=CarlosZoft" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/caleboleary"><img src="https://avatars.githubusercontent.com/u/12816579?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Caleb OLeary</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=caleboleary" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/JimmFly"><img src="https://avatars.githubusercontent.com/u/102217452?v=4?s=50" width="50px;" alt=""/><br /><sub><b>JimmFly</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=JimmFly" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/westongraham"><img src="https://avatars.githubusercontent.com/u/89493023?v=4?s=50" width="50px;" alt=""/><br /><sub><b>Weston Graham</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=westongraham" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/pointmax"><img src="https://avatars.githubusercontent.com/u/49361135?v=4?s=50" width="50px;" alt=""/><br /><sub><b>pointmax</b></sub></a><br /><a href="https://github.com/toeverything/AFFiNE/commits?author=pointmax" title="Documentation">📖</a></td>
</tr>
</table>

View File

@ -1,3 +1,3 @@
export const getTitle = () => cy.get('span[title]');
export const getDoc = () => cy.contains('Doc');
export const getBoard = () => cy.contains('Board');
export const getDoc = () => cy.contains('Paper');
export const getBoard = () => cy.contains('Edgeless');

View File

@ -1,11 +1,7 @@
/* eslint-disable filename-rules/match */
import { useEffect, useRef, type UIEvent, useState } from 'react';
import { useParams } from 'react-router';
import {
MuiBox as Box,
MuiCircularProgress as CircularProgress,
styled,
} from '@toeverything/components/ui';
import { AffineEditor } from '@toeverything/components/affine-editor';
import {
CalendarHeatmap,
@ -15,10 +11,13 @@ import {
import { CollapsibleTitle } from '@toeverything/components/common';
import {
useShowSpaceSidebar,
useUserAndSpaces,
usePageClientWidth,
} from '@toeverything/datasource/state';
import { services } from '@toeverything/datasource/db-service';
import {
MuiBox as Box,
MuiCircularProgress as CircularProgress,
styled,
} from '@toeverything/components/ui';
import { WorkspaceName } from './workspace-name';
import { CollapsiblePageTree } from './collapsible-page-tree';

View File

@ -1,34 +1,34 @@
import React, { useCallback, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
MuiBox as Box,
MuiButton as Button,
MuiCollapse as Collapse,
MuiIconButton as IconButton,
styled,
} from '@toeverything/components/ui';
import {
AddIcon,
ArrowDropDownIcon,
ArrowRightIcon,
} from '@toeverything/components/icons';
import { services } from '@toeverything/datasource/db-service';
import {
usePageTree,
useCalendarHeatmap,
usePageTree,
} from '@toeverything/components/layout';
import { AddIcon } from '@toeverything/components/icons';
import {
IconButton,
MuiBox as Box,
MuiCollapse as Collapse,
styled,
} from '@toeverything/components/ui';
import { services } from '@toeverything/datasource/db-service';
import React, { useCallback, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
const StyledContainer = styled('div')({
const StyledBtn = styled('div')({
height: '32px',
display: 'flex',
alignItems: 'center',
});
const StyledBtn = styled('div')({
color: '#98ACBD',
textTransform: 'none',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
userSelect: 'none',
flex: 1,
marginLeft: '12px',
});
export type CollapsiblePageTreeProps = {
@ -72,7 +72,7 @@ export function CollapsiblePageTree(props: CollapsiblePageTreeProps) {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: 1,
paddingRight: '12px',
'&:hover': {
background: '#f5f7f8',
borderRadius: '5px',
@ -81,27 +81,18 @@ export function CollapsiblePageTree(props: CollapsiblePageTreeProps) {
onMouseEnter={() => setNewPageBtnVisible(true)}
onMouseLeave={() => setNewPageBtnVisible(false)}
>
<StyledContainer>
{open ? (
<ArrowDropDownIcon sx={{ color: '#566B7D' }} />
) : (
<ArrowRightIcon sx={{ color: '#566B7D' }} />
)}
<StyledBtn onClick={() => setOpen(prev => !prev)}>
{title}
</StyledBtn>
</StyledContainer>
<StyledBtn onClick={() => setOpen(prev => !prev)}>
{title}
</StyledBtn>
{newPageBtnVisible && (
<AddIcon
style={{
width: '20px',
height: '20px',
color: '#98ACBD',
cursor: 'pointer',
}}
<IconButton
size="small"
hoverColor="#E0E6EB"
onClick={create_page}
/>
>
<AddIcon />
</IconButton>
)}
</Box>
{children ? (

View File

@ -1,43 +1,78 @@
import { useState } from 'react';
import { MuiDivider as Divider, styled } from '@toeverything/components/ui';
import { styled } from '@toeverything/components/ui';
import type { ValueOf } from '@toeverything/utils';
const StyledTabs = styled('div')({
width: '100%',
height: '12px',
marginTop: '12px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
});
const StyledDivider = styled(Divider, {
shouldForwardProp: (prop: string) => !['isActive'].includes(prop),
})<{ isActive?: boolean }>(({ isActive }) => {
const StyledTabs = styled('div')(({ theme }) => {
return {
flex: 1,
backgroundColor: isActive ? '#3E6FDB' : '#ECF1FB',
borderWidth: '2px',
width: '100%',
height: '30px',
marginTop: '12px',
display: 'flex',
fontSize: '12px',
fontWeight: '600',
};
});
const StyledTabTitle = styled('div')<{
isActive?: boolean;
isDisabled?: boolean;
}>`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
line-height: 18px;
padding-top: 4px;
border-top: 2px solid #ecf1fb;
position: relative;
cursor: pointer;
color: ${({ theme, isActive }) =>
isActive ? theme.affine.palette.primary : 'rgba(62, 111, 219, 0.6)'};
&::after {
content: '';
width: 0;
height: 2px;
background-color: ${({ isActive, theme }) =>
isActive
? theme.affine.palette.primary
: 'rgba(62, 111, 219, 0.6)'};
position: absolute;
left: 100%;
top: -2px;
transition: all 0.2s;
}
&.active {
&::after {
width: 100%;
left: 0;
transition-delay: 0.1s;
}
& ~ div::after {
left: 0;
}
}
`;
const TAB_TITLE = {
PAGES: 'pages',
GALLERY: 'gallery',
TOC: 'toc',
} as const;
const TabMap = new Map<TabKey, TabValue>([
['PAGES', 'pages'],
['GALLERY', 'gallery'],
['TOC', 'toc'],
const TabMap = new Map<TabKey, { value: TabValue; disabled?: boolean }>([
['PAGES', { value: 'pages' }],
['GALLERY', { value: 'gallery', disabled: true }],
['TOC', { value: 'toc' }],
]);
type TabKey = keyof typeof TAB_TITLE;
type TabValue = ValueOf<typeof TAB_TITLE>;
const Tabs = () => {
const [activeTab, setActiveTab] = useState<TabValue>(TAB_TITLE.PAGES);
const [activeValue, setActiveTab] = useState<TabValue>(TAB_TITLE.PAGES);
const onClick = (v: TabValue) => {
setActiveTab(v);
@ -45,13 +80,19 @@ const Tabs = () => {
return (
<StyledTabs>
{[...TabMap.entries()].map(([k, v]) => {
{[...TabMap.entries()].map(([k, { value, disabled = false }]) => {
const isActive = activeValue === value;
return (
<StyledDivider
key={v}
isActive={v === activeTab}
onClick={() => onClick(v)}
/>
<StyledTabTitle
key={value}
className={isActive ? 'active' : ''}
isActive={isActive}
isDisabled={disabled}
onClick={() => onClick(value)}
>
{k}
</StyledTabTitle>
);
})}
</StyledTabs>

View File

@ -1,13 +1,16 @@
import {
styled,
MuiOutlinedInput as OutlinedInput,
} from '@toeverything/components/ui';
import { styled, Input } from '@toeverything/components/ui';
import { PinIcon } from '@toeverything/components/icons';
import {
useUserAndSpaces,
useShowSpaceSidebar,
} from '@toeverything/datasource/state';
import React, { useCallback, useEffect, useState } from 'react';
import React, {
ChangeEvent,
KeyboardEvent,
useCallback,
useEffect,
useState,
} from 'react';
import { services } from '@toeverything/datasource/db-service';
import { Logo } from './components/logo/Logo';
@ -124,24 +127,24 @@ export const WorkspaceName = () => {
};
}, [currentSpaceId, fetchWorkspaceName]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
setInRename(false);
}
},
[]
);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
setInRename(false);
}
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
services.api.userConfig.setWorkspaceName(
async (e: ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
await setWorkspaceName(name);
await services.api.userConfig.setWorkspaceName(
currentSpaceId,
e.currentTarget.value
name
);
},
[]
[currentSpaceId]
);
return (
@ -165,7 +168,8 @@ export const WorkspaceName = () => {
<StyledWorkspace>
{inRename ? (
<WorkspaceReNameContainer>
<OutlinedInput
<Input
autoFocus
style={{ width: '140px', height: '28px' }}
value={workspaceName}
onChange={handleChange}

View File

@ -91,9 +91,9 @@ module.exports = function (webpackConfig) {
priority: -9,
chunks: 'all',
},
vender: {
vendor: {
test: /([\\/]node_modules[\\/]|polyfills|@nrwl)/,
name: 'vender',
name: 'vendor',
priority: -10,
chunks: 'all',
},
@ -148,6 +148,12 @@ module.exports = function (webpackConfig) {
}
}
config.module.rules.unshift({
test: /\.wasm$/,
type: 'asset/resource',
});
config.resolve.fallback = { crypto: false, fs: false, path: false };
addEmotionBabelPlugin(config);
config.plugins = [

View File

@ -52,7 +52,7 @@ const Alternatives = styled(Box)<{ width: string }>(({ width }) => ({
height: '128px',
transform: 'translateY(-8px)',
overflowY: 'hidden',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
width,
height: '48px',
transform: 'translateY(0)',
@ -64,7 +64,7 @@ const Alternatives = styled(Box)<{ width: string }>(({ width }) => ({
left: '0%',
top: '0%',
lineHeight: '96px',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
lineHeight: '32px',
},
},
@ -109,7 +109,7 @@ const Product = () => {
);
const maxWidth = useMemo(() => _alternativesSize[idx], [idx]);
const [active, setActive] = useState(false);
const matches = useMediaQuery('(max-width: 768px)');
const matches = useMediaQuery('(max-width: 1024px)');
useEffect(() => {
const handle = setInterval(() => {
@ -128,7 +128,14 @@ const Product = () => {
return (
<Alternatives
width={`${maxWidth}em`}
sx={{ margin: 'auto', marginRight: '1em', transition: 'width .5s' }}
sx={{
margin: 'auto',
marginRight: '1em',
transition: 'width .5s',
'@media (max-width: 1024px)': {
width: '8em',
},
}}
>
<Box
className={clsx(
@ -144,7 +151,7 @@ const Product = () => {
color: '#06449d',
textAlign: 'right',
overflow: 'hidden',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
fontSize: '32px',
},
}}
@ -162,7 +169,7 @@ const Product = () => {
marginTop: '96px',
textAlign: 'right',
overflow: 'hidden',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
marginTop: '48px',
},
}}
@ -173,7 +180,7 @@ const Product = () => {
sx={{
color: '#06449d',
overflow: 'hidden',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
fontSize: '32px',
},
}}
@ -191,7 +198,7 @@ const AffineImage = styled('img')({
});
const GitHub = (props: { center?: boolean; flat?: boolean }) => {
const matches = useMediaQuery('(max-width: 768px)');
const matches = useMediaQuery('(max-width: 1024px)');
return (
<Button
@ -204,6 +211,10 @@ const GitHub = (props: { center?: boolean; flat?: boolean }) => {
{...{
sx: {
margin: 'auto 1em',
fontSize: '24px',
'@media (max-width: 1024px)': {
fontSize: '16px',
},
...(props.flat
? {
padding: matches ? '0' : '0 0.5em',
@ -231,7 +242,7 @@ const GitHub = (props: { center?: boolean; flat?: boolean }) => {
};
export function App() {
const matches = useMediaQuery('(max-width: 768px)');
const matches = useMediaQuery('(max-width: 1024px)');
return (
<CssVarsProvider>
@ -256,6 +267,10 @@ export function App() {
sx={{
padding: matches ? '0' : '0 0.5em',
':hover': { backgroundColor: 'unset' },
fontSize: '24px',
'@media (max-width: 1024px)': {
fontSize: '16px',
},
}}
>
AFFiNE
@ -276,6 +291,10 @@ export function App() {
sx={{
padding: matches ? '0' : '0 0.5em',
':hover': { backgroundColor: 'unset' },
fontSize: '24px',
'@media (max-width: 1024px)': {
fontSize: '16px',
},
}}
size="lg"
>
@ -302,7 +321,7 @@ export function App() {
fontWeight={900}
sx={{
marginRight: '0.25em',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
fontSize: '32px',
marginRight: 0,
},
@ -314,7 +333,7 @@ export function App() {
fontSize="96px"
fontWeight={900}
sx={{
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
fontSize: '32px',
},
}}
@ -347,7 +366,7 @@ export function App() {
sx={{
color: '#06449d',
margin: 'auto',
'@media (max-width: 768px)': {
'@media (max-width: 1024px)': {
fontSize: '32px',
},
}}

View File

@ -81,9 +81,9 @@ module.exports = function (webpackConfig) {
priority: -9,
chunks: 'all',
},
vender: {
vendor: {
test: /([\\/]node_modules[\\/]|polyfills|@nrwl)/,
name: 'vender',
name: 'vendor',
priority: -10,
chunks: 'all',
},

View File

@ -1,5 +1,5 @@
/* eslint-disable filename-rules/match */
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { initializeApp } from 'firebase/app';
import {
GoogleAuthProvider,
@ -8,15 +8,7 @@ import {
browserLocalPersistence,
} from 'firebase/auth';
import { LogoImg } from '@toeverything/components/common';
import {
MuiButton,
MuiBox,
MuiGrid,
MuiSnackbar,
} from '@toeverything/components/ui';
import { Error } from './../error';
import { MuiButton } from '@toeverything/components/ui';
const _firebaseConfig = {
apiKey: 'AIzaSyD7A_VyGaKTXsPqtga9IbwrEsbWWc4rH3Y',
@ -75,7 +67,7 @@ const GoogleIcon = () => (
</svg>
);
export const Firebase = () => {
export const Firebase = (props: { onError: () => void }) => {
const [auth, provider] = useMemo(() => {
const auth = getAuth(_app);
auth.setPersistence(browserLocalPersistence);
@ -83,8 +75,6 @@ export const Firebase = () => {
return [auth, provider];
}, []);
const [error, setError] = useState(false);
const handleAuth = useCallback(() => {
signInWithPopup(auth, provider).catch(error => {
const errorCode = error.code;
@ -92,53 +82,19 @@ export const Firebase = () => {
const email = error.customData.email;
const credential = GoogleAuthProvider.credentialFromError(error);
console.log(errorCode, errorMessage, email, credential);
setError(true);
setTimeout(() => setError(false), 3000);
props.onError();
});
}, [auth, provider]);
}, [auth, props, provider]);
return (
<MuiGrid container>
<MuiSnackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={error}
message="Login failed, please check if you have permission"
/>
<MuiGrid item xs={8}>
<Error
title="Welcome to AFFiNE"
subTitle="blocks of knowledge to power your team"
action1Text="Login &nbsp; or &nbsp; Register"
/>
</MuiGrid>
<MuiGrid item xs={4}>
<MuiBox
onSubmit={handleAuth}
onClick={handleAuth}
style={{
textAlign: 'center',
width: '300px',
margin: '300px auto 20px auto',
}}
sx={{ mt: 1 }}
>
<LogoImg
style={{
width: '100px',
}}
/>
<MuiButton
variant="outlined"
fullWidth
style={{ textTransform: 'none' }}
startIcon={<GoogleIcon />}
>
Continue with Google
</MuiButton>
</MuiBox>
</MuiGrid>
</MuiGrid>
<MuiButton
variant="outlined"
fullWidth
style={{ textTransform: 'none' }}
startIcon={<GoogleIcon />}
onClick={handleAuth}
>
Continue with Google
</MuiButton>
);
};

View File

@ -0,0 +1,87 @@
/* eslint-disable filename-rules/match */
import { useEffect, useMemo } from 'react';
import { LogoIcon } from '@toeverything/components/icons';
import { MuiButton } from '@toeverything/components/ui';
import { services } from '@toeverything/datasource/db-service';
import { useLocalTrigger } from '@toeverything/datasource/state';
const cleanupWorkspace = (workspace: string) =>
new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(workspace);
req.addEventListener('error', e => reject(e));
req.addEventListener('blocked', e => reject(e));
req.addEventListener('upgradeneeded', e => reject(e));
req.addEventListener('success', e => resolve(e));
});
const requestPermission = async (workspace: string) => {
await cleanupWorkspace(workspace);
const dirHandler = await window.showDirectoryPicker({
id: 'AFFiNE_' + workspace,
mode: 'readwrite',
startIn: 'documents',
});
const fileHandle = await dirHandler.getFileHandle('affine.db', {
create: true,
});
const file = await fileHandle.getFile();
const initialData = new Uint8Array(await file.arrayBuffer());
const exporter = async (contents: Uint8Array) => {
try {
const writable = await fileHandle.createWritable();
await writable.write(contents);
await writable.close();
} catch (e) {
console.log(e);
}
};
await services.api.editorBlock.setupDataExporter(
workspace,
new Uint8Array(initialData),
exporter
);
};
export const FileSystem = (props: { onError: () => void }) => {
const onSelected = useLocalTrigger();
const apiSupported = useMemo(() => {
try {
return 'showOpenFilePicker' in window;
} catch (e) {
return false;
}
}, []);
useEffect(() => {
if (process.env['NX_E2E']) {
onSelected();
}
}, []);
return (
<MuiButton
variant="outlined"
fullWidth
style={{ textTransform: 'none' }}
startIcon={<LogoIcon />}
onClick={async () => {
try {
if (apiSupported) {
await requestPermission('AFFiNE');
onSelected();
} else {
onSelected();
}
} catch (e) {
props.onError();
}
}}
>
{apiSupported ? 'Sync to Disk' : 'Try Live Demo'}
</MuiButton>
);
};

View File

@ -1,11 +1,64 @@
/* eslint-disable filename-rules/match */
import { useCallback, useState } from 'react';
import { LogoImg } from '@toeverything/components/common';
import { MuiBox, MuiGrid, MuiSnackbar } from '@toeverything/components/ui';
// import { Authing } from './authing';
import { Firebase } from './firebase';
import { FileSystem } from './fs';
import { Error } from './../error';
export function Login() {
const [error, setError] = useState(false);
const onError = useCallback(() => {
setError(true);
setTimeout(() => setError(false), 3000);
}, []);
return (
<>
{/* <Authing /> */}
<Firebase />
</>
<MuiGrid container>
<MuiSnackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={error}
message="Login failed, please check if you have permission"
/>
<MuiGrid item xs={8}>
<Error
title="Welcome to AFFiNE"
subTitle="blocks of knowledge to power your team"
action1Text="Login &nbsp; or &nbsp; Register"
/>
</MuiGrid>
<MuiGrid item xs={4}>
{' '}
<MuiBox
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
rowGap: '1em',
width: '300px',
margin: '300px auto 20px auto',
}}
sx={{ mt: 1 }}
>
<LogoImg
style={{
width: '100px',
}}
/>
{/* {((process.env['NX_LOCAL'] || true) && ( */}
<FileSystem onError={onError} />
{/* )) ||
null}
{((!process.env['NX_LOCAL'] || true) && ( */}
<Firebase onError={onError} />
{/* )) ||
null} */}{' '}
</MuiBox>
</MuiGrid>
</MuiGrid>
);
}

View File

@ -1,5 +1,13 @@
/* eslint-disable max-lines */
import * as React from 'react';
import {
memo,
useEffect,
useLayoutEffect,
useRef,
useMemo,
useState,
type RefObject,
} from 'react';
import { Renderer } from '@tldraw/core';
import { styled } from '@toeverything/components/ui';
import {
@ -132,13 +140,13 @@ export function Tldraw({
getSession,
tools,
}: TldrawProps) {
const [sId, set_sid] = React.useState(id);
const [sId, setSid] = useState(id);
const { pageClientWidth } = usePageClientWidth();
// page padding left and right total 300px
const editorShapeInitSize = pageClientWidth - 300;
// Create a new app when the component mounts.
const [app, setApp] = React.useState(() => {
const [app, setApp] = useState(() => {
const app = new TldrawApp({
id,
callbacks,
@ -151,7 +159,7 @@ export function Tldraw({
});
// Create a new app if the `id` prop changes.
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (id === sId) return;
const newApp = new TldrawApp({
id,
@ -161,14 +169,14 @@ export function Tldraw({
tools,
});
set_sid(id);
setSid(id);
setApp(newApp);
}, [sId, id]);
// Update the document if the `document` prop changes but the ids,
// are the same, or else load a new document if the ids are different.
React.useEffect(() => {
useEffect(() => {
if (!document) return;
if (document.id === app.document.id) {
@ -179,34 +187,34 @@ export function Tldraw({
}, [document, app]);
// Disable assets when the `disableAssets` prop changes.
React.useEffect(() => {
useEffect(() => {
app.setDisableAssets(disableAssets);
}, [app, disableAssets]);
// Change the page when the `currentPageId` prop changes.
React.useEffect(() => {
useEffect(() => {
if (!currentPageId) return;
app.changePage(currentPageId);
}, [currentPageId, app]);
// Toggle the app's readOnly mode when the `readOnly` prop changes.
React.useEffect(() => {
useEffect(() => {
app.readOnly = readOnly;
}, [app, readOnly]);
// Toggle the app's darkMode when the `darkMode` prop changes.
React.useEffect(() => {
useEffect(() => {
if (darkMode !== app.settings.isDarkMode) {
app.toggleDarkMode();
}
}, [app, darkMode]);
// Update the app's callbacks when any callback changes.
React.useEffect(() => {
useEffect(() => {
app.callbacks = callbacks || {};
}, [app, callbacks]);
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
if (!window.document?.fonts) return;
@ -260,7 +268,7 @@ interface InnerTldrawProps {
showSponsorLink?: boolean;
}
const InnerTldraw = React.memo(function InnerTldraw({
const InnerTldraw = memo(function InnerTldraw({
id,
autofocus,
showPages,
@ -276,7 +284,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
}: InnerTldrawProps) {
const app = useTldrawApp();
const rWrapper = React.useRef<HTMLDivElement>(null);
const rWrapper = useRef<HTMLDivElement>(null);
const state = app.useStore();
@ -299,7 +307,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
TLDR.get_shape_util(page.shapes[selectedIds[0]].type).hideResizeHandles;
// Custom rendering meta, with dark mode for shapes
const meta: TDMeta = React.useMemo(() => {
const meta: TDMeta = useMemo(() => {
return { isDarkMode: settings.isDarkMode, app };
}, [settings.isDarkMode, app]);
@ -308,7 +316,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
: appState.selectByContain;
// Custom theme, based on darkmode
const theme = React.useMemo(() => {
const theme = useMemo(() => {
const { selectByContain } = appState;
const { isDarkMode, isCadSelectMode } = settings;
@ -373,9 +381,11 @@ const InnerTldraw = React.memo(function InnerTldraw({
!isSelecting ||
!settings.showCloneHandles ||
pageState.camera.zoom < 0.2;
return (
<StyledLayout
ref={rWrapper}
panning={settings.forcePanning}
tabIndex={-0}
penColor={app?.appState?.currentStyle?.stroke}
>
@ -477,17 +487,17 @@ const InnerTldraw = React.memo(function InnerTldraw({
);
});
const OneOff = React.memo(function OneOff({
const OneOff = memo(function OneOff({
focusableRef,
autofocus,
}: {
autofocus?: boolean;
focusableRef: React.RefObject<HTMLDivElement>;
focusableRef: RefObject<HTMLDivElement>;
}) {
useKeyboardShortcuts(focusableRef);
useStylesheet();
React.useEffect(() => {
useEffect(() => {
if (autofocus) {
focusableRef.current?.focus();
}
@ -496,8 +506,8 @@ const OneOff = React.memo(function OneOff({
return null;
});
const StyledLayout = styled('div')<{ penColor: string }>(
({ theme, penColor }) => {
const StyledLayout = styled('div')<{ penColor: string; panning: boolean }>(
({ theme, panning, penColor }) => {
return {
position: 'relative',
height: '100%',
@ -509,6 +519,7 @@ const StyledLayout = styled('div')<{ penColor: string }>(
overflow: 'hidden',
boxSizing: 'border-box',
outline: 'none',
cursor: panning ? 'grab' : 'unset',
'& .tl-container': {
position: 'absolute',

View File

@ -6,6 +6,7 @@ import {
Tooltip,
PopoverContainer,
IconButton,
useTheme,
} from '@toeverything/components/ui';
import {
FrameIcon,
@ -71,6 +72,7 @@ export const ToolsPanel: FC<{ app: TldrawApp }> = ({ app }) => {
const activeTool = app.useStore(activeToolSelector);
const isToolLocked = app.useStore(toolLockedSelector);
const theme = useTheme();
return (
<PopoverContainer
@ -105,7 +107,8 @@ export const ToolsPanel: FC<{ app: TldrawApp }> = ({ app }) => {
style={{
color:
activeTool === type
? 'blue'
? theme.affine.palette
.primary
: '',
}}
onClick={() => {

View File

@ -219,8 +219,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
isPointing = false;
isForcePanning = false;
editingStartTime = -1;
fileSystemHandle: FileSystemHandle | null = null;
@ -262,7 +260,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
constructor(props: TldrawAppCtorProps) {
super(
TldrawApp.default_state,
TldrawApp.defaultState,
props.id,
TldrawApp.version,
(prev, next, prevVersion) => {
@ -326,9 +324,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
);
this.patchState({
...TldrawApp.default_state,
...TldrawApp.defaultState,
appState: {
...TldrawApp.default_state.appState,
...TldrawApp.defaultState.appState,
status: TDStatus.Idle,
},
});
@ -1473,13 +1471,13 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.replace_state(
{
...TldrawApp.default_state,
...TldrawApp.defaultState,
settings: {
...this.state.settings,
},
document: migrate(document, TldrawApp.version),
appState: {
...TldrawApp.default_state.appState,
...TldrawApp.defaultState.appState,
...this.state.appState,
currentPageId: Object.keys(document.pages)[0],
disableAssets: this.disableAssets,
@ -3913,7 +3911,11 @@ export class TldrawApp extends StateManager<TDSnapshot> {
break;
}
case ' ': {
this.isForcePanning = true;
this.patchState({
settings: {
forcePanning: true,
},
});
this.spaceKey = true;
break;
}
@ -3976,7 +3978,12 @@ export class TldrawApp extends StateManager<TDSnapshot> {
break;
}
case ' ': {
this.isForcePanning = false;
this.patchState({
settings: {
forcePanning:
this.currentTool.type === TDShapeType.HandDraw,
},
});
this.spaceKey = false;
break;
}
@ -4069,13 +4076,18 @@ export class TldrawApp extends StateManager<TDSnapshot> {
this.pan(delta);
// When panning, we also want to call onPointerMove, except when "force panning" via spacebar / middle wheel button (it's called elsewhere in that case)
if (!this.isForcePanning)
if (!this.useStore.getState().settings.forcePanning)
this.onPointerMove(info, e as unknown as React.PointerEvent);
};
onZoom: TLWheelEventHandler = (info, e) => {
if (this.state.appState.status !== TDStatus.Idle) return;
const delta = info.delta[2] / 50;
// Normalize zoom scroll
// Fix https://github.com/toeverything/AFFiNE/issues/135
const delta =
Math.abs(info.delta[2]) > 10
? 0.2 * Math.sign(info.delta[2])
: info.delta[2] / 50;
this.zoomBy(delta, info.point);
this.onPointerMove(info, e as unknown as React.PointerEvent);
};
@ -4093,7 +4105,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
onPointerMove: TLPointerEventHandler = (info, e) => {
this.previousPoint = this.currentPoint;
this.updateInputs(info, e);
if (this.isForcePanning && this.isPointing) {
if (this.useStore.getState().settings.forcePanning && this.isPointing) {
this.onPan?.(
{ ...info, delta: Vec.neg(info.delta) },
e as unknown as WheelEvent
@ -4117,20 +4129,23 @@ export class TldrawApp extends StateManager<TDSnapshot> {
onPointerDown: TLPointerEventHandler = (info, e) => {
if (e.buttons === 4) {
this.isForcePanning = true;
this.patchState({
settings: {
forcePanning: true,
},
});
} else if (this.isPointing) {
return;
}
this.isPointing = true;
this.originPoint = this.getPagePoint(info.point).concat(info.pressure);
this.updateInputs(info, e);
if (this.isForcePanning) return;
if (this.useStore.getState().settings.forcePanning) return;
this.currentTool.onPointerDown?.(info, e);
};
onPointerUp: TLPointerEventHandler = (info, e) => {
this.isPointing = false;
if (!this.shiftKey) this.isForcePanning = false;
this.updateInputs(info, e);
this.currentTool.onPointerUp?.(info, e);
};
@ -4517,7 +4532,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
assets: {},
};
static default_state: TDSnapshot = {
static defaultState: TDSnapshot = {
settings: {
isCadSelectMode: false,
isPenMode: false,
@ -4527,6 +4542,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
isSnapping: false,
isDebugMode: false,
isReadonlyMode: false,
forcePanning: false,
keepStyleMenuOpen: false,
nudgeDistanceLarge: 16,
nudgeDistanceSmall: 1,

View File

@ -18,34 +18,19 @@ export class HandDrawTool extends BaseTool {
/* ----------------- Event Handlers ----------------- */
override onPointerDown: TLPointerEventHandler = () => {
if (this.app.readOnly) return;
if (this.status !== Status.Idle) return;
this.set_status(Status.Pointing);
override onEnter = () => {
this.app.patchState({
settings: {
forcePanning: true,
},
});
};
override onPointerMove: TLPointerEventHandler = (info, e) => {
if (this.app.readOnly) return;
const delta = Vec.div(info.delta, this.app.camera.zoom);
const prev = this.app.camera.point;
const next = Vec.sub(prev, delta);
if (Vec.isEqual(next, prev)) return;
switch (this.status) {
case Status.Pointing: {
this.app.pan(Vec.neg(delta));
break;
}
}
};
override onPointerUp: TLPointerEventHandler = () => {
this.set_status(Status.Idle);
};
override onCancel = () => {
this.set_status(Status.Idle);
override onExit = () => {
this.app.patchState({
settings: {
forcePanning: false,
},
});
};
}

View File

@ -84,6 +84,7 @@ export interface TDSnapshot {
isPenMode: boolean;
isReadonlyMode: boolean;
isZoomSnap: boolean;
forcePanning: boolean;
keepStyleMenuOpen: boolean;
nudgeDistanceSmall: number;
nudgeDistanceLarge: number;

View File

@ -13,6 +13,7 @@ const StyledContainer = styled('div')({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
paddingLeft: '12px',
'&:hover': {
background: '#f5f7f8',
borderRadius: '5px',
@ -36,11 +37,6 @@ export function CollapsibleTitle(props: CollapsibleTitleProps) {
return (
<>
<StyledContainer onClick={() => setOpen(prev => !prev)}>
{open ? (
<ArrowDropDownIcon sx={{ color: '#566B7D' }} />
) : (
<ArrowRightIcon sx={{ color: '#566B7D' }} />
)}
<div
style={{
color: '#98ACBD',

View File

@ -36,6 +36,9 @@ export class GridBlock extends BaseView {
}
return block.remove();
}
if (block.childrenIds.length === 0) {
return block.remove();
}
return true;
}
}

View File

@ -1,6 +1,8 @@
import {
addNewGroup,
LINE_GAP,
RecastScene,
TAG_GAP,
useCurrentView,
useOnSelect,
} from '@toeverything/components/editor-core';
@ -38,6 +40,7 @@ const GroupActionWrapper = styled('div')(({ theme }) => ({
visibility: 'hidden',
fontSize: theme.affine.typography.xs.fontSize,
color: theme.affine.palette.icons,
opacity: 0.6,
'.line': {
flex: 1,
height: '15px',
@ -60,7 +63,7 @@ const GroupContainer = styled('div')<{ isSelect?: boolean }>(
({ isSelect, theme }) => ({
background: theme.affine.palette.white,
border: '2px solid rgba(236,241,251,.5)',
padding: `15px 16px 0 16px`,
padding: `15px 16px ${LINE_GAP - TAG_GAP * 2}px 16px`,
borderRadius: '10px',
...(isSelect
? {

View File

@ -156,7 +156,13 @@ export const CardContainer = (props: CardContainerProps) => {
const { kanban } = useKanban();
const { containerIds, items: dataSource, activeId } = props;
return (
<KanbanContainer>
<KanbanContainer
onMouseDown={e => {
// Fix https://github.com/toeverything/AFFiNE/issues/29
// Prevent active selection when dragging kanban card
e.stopPropagation();
}}
>
{containerIds.map((containerId, idx) => {
const items = dataSource[containerId];

View File

@ -60,6 +60,9 @@ export const CardContext = (props: Props) => {
const StyledCardContainer = styled('div')`
cursor: pointer;
&:hover {
z-index: 1;
}
&:focus-within {
z-index: 1;
}

View File

@ -86,7 +86,7 @@ export const PageView: FC<CreateView> = ({ block, editor }) => {
alwaysShowPlaceholder
ref={textRef}
className={'title'}
supportMarkdown={true}
supportMarkdown={false}
handleEnter={onTextEnter}
placeholder={'Untitled'}
block={block}
@ -109,12 +109,15 @@ export const PageView: FC<CreateView> = ({ block, editor }) => {
);
};
const PageTitleBlock = styled('div')({
'.title': {
fontSize: Theme.typography.page.fontSize,
lineHeight: Theme.typography.page.lineHeight,
},
'.content': {
outline: 'none',
},
const PageTitleBlock = styled('div')(({ theme }) => {
return {
'.title': {
fontSize: theme.affine.typography.page.fontSize,
lineHeight: theme.affine.typography.page.lineHeight,
fontWeight: theme.affine.typography.page.fontWeight,
},
'.content': {
outline: 'none',
},
};
});

View File

@ -46,6 +46,7 @@ const TextBlock = styled(TextManage)<{ type: string }>(({ theme, type }) => {
return {
fontSize: textStyleMap.text.fontSize,
lineHeight: textStyleMap.text.lineHeight,
fontWeight: textStyleMap.text.fontWeight,
};
}
});

View File

@ -150,6 +150,7 @@ const TodoBlock = styled('div')({
display: 'flex',
'.checkBoxContainer': {
marginRight: '4px',
padding: '0 4px',
height: '22px',
},
'.textContainer': {

View File

@ -27,5 +27,6 @@ export const BlockContainer: FC<BlockContainerProps> = function ({
export const Container = styled('div')<{ selected: boolean }>(
({ selected, theme }) => ({
backgroundColor: selected ? theme.affine.palette.textSelected : '',
marginBottom: '2px',
})
);

View File

@ -39,6 +39,9 @@ export type ExtendedTextUtils = SlateUtils & {
};
const TextBlockContainer = styled(Text)(({ theme }) => ({
lineHeight: theme.affine.typography.body1.lineHeight,
fontFamily: theme.affine.typography.body1.fontFamily,
color: theme.affine.typography.body1.color,
letterSpacing: '0.1px',
}));
const findSlice = (arr: string[], p: string, q: string) => {

View File

@ -102,6 +102,12 @@ export const RenderRoot: FC<PropsWithChildren<RenderRootProps>> = ({
editor.getHooks().onRootNodeMouseLeave(event);
};
const onContextmenu = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
selectionRef.current?.onContextmenu(event);
};
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = event => {
// IMP move into keyboard managers?
editor.getHooks().onRootNodeKeyDown(event);
@ -165,6 +171,7 @@ export const RenderRoot: FC<PropsWithChildren<RenderRootProps>> = ({
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
onMouseOut={onMouseOut}
onContextMenu={onContextmenu}
onKeyDown={onKeyDown}
onKeyDownCapture={onKeyDownCapture}
onKeyUp={onKeyUp}

View File

@ -29,6 +29,9 @@ export type SelectionRef = {
onMouseDown: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseMove: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseUp: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onContextmenu: (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => void;
};
const getFixedPoint = (
@ -207,10 +210,17 @@ export const SelectionRect = forwardRef<SelectionRef, SelectionProps>(
scrollManager.stopAutoScroll();
};
const onContextmenu = () => {
if (mouseType.current === 'down') {
onMouseUp();
}
};
useImperativeHandle(ref, () => ({
onMouseDown,
onMouseMove,
onMouseUp,
onContextmenu,
}));
useEffect(() => {

View File

@ -3,6 +3,8 @@ import { styled } from '@toeverything/components/ui';
import type { AsyncBlock } from '../editor';
import { PendantPopover } from './pendant-popover';
import { PendantRender } from './pendant-render';
import { useRef } from 'react';
import { getRecastItemValue, useRecastBlockMeta } from '../recast-block';
/**
* @deprecated
*/
@ -14,13 +16,27 @@ export const BlockPendantProvider: FC<PropsWithChildren<BlockTagProps>> = ({
block,
children,
}) => {
const triggerRef = useRef<HTMLDivElement>();
const { getProperties } = useRecastBlockMeta();
const properties = getProperties();
const { getValue } = getRecastItemValue(block);
const showTriggerLine =
properties.filter(property => getValue(property.id)).length === 0;
return (
<Container>
{children}
<PendantPopover block={block}>
<StyledTriggerLine />
</PendantPopover>
{showTriggerLine ? (
<StyledPendantContainer ref={triggerRef}>
<PendantPopover
block={block}
container={triggerRef.current}
>
<StyledTriggerLine />
</PendantPopover>
</StyledPendantContainer>
) : null}
<PendantRender block={block} />
</Container>
@ -28,7 +44,7 @@ export const BlockPendantProvider: FC<PropsWithChildren<BlockTagProps>> = ({
};
export const LINE_GAP = 16;
const TAG_GAP = 4;
export const TAG_GAP = 4;
const StyledTriggerLine = styled('div')({
padding: `${TAG_GAP}px 0`,
@ -43,10 +59,12 @@ const StyledTriggerLine = styled('div')({
width: '100%',
height: '2px',
background: '#dadada',
display: 'none',
display: 'flex',
position: 'absolute',
left: '0',
top: '4px',
transition: 'opacity .2s',
opacity: '0',
},
'::after': {
content: "''",
@ -60,18 +78,24 @@ const StyledTriggerLine = styled('div')({
transition: 'width .3s',
},
});
const Container = styled('div')({
position: 'relative',
paddingBottom: `${LINE_GAP - TAG_GAP * 2}px`,
const StyledPendantContainer = styled('div')({
width: '100px',
'&:hover': {
[StyledTriggerLine.toString()]: {
'&::before': {
display: 'flex',
},
[`${StyledTriggerLine}`]: {
'&::after': {
width: '100%',
},
},
},
});
const Container = styled('div')({
position: 'relative',
padding: `${TAG_GAP * 2}px 0 ${LINE_GAP - TAG_GAP * 4}px 0`,
'&:hover': {
[`${StyledTriggerLine}`]: {
'&::before': {
opacity: '1',
},
},
},
});

View File

@ -29,6 +29,7 @@ export const PendantHistoryPanel = ({
const [history, setHistory] = useState<RecastBlockValue[]>([]);
const popoverHandlerRef = useRef<{ [key: string]: PopperHandler }>({});
const historyPanelRef = useRef<HTMLDivElement>();
const { getValueHistory } = getRecastItemValue(block);
useEffect(() => {
@ -84,7 +85,7 @@ export const PendantHistoryPanel = ({
}, [block, getProperties, groupBlock, recastBlock]);
return (
<StyledPendantHistoryPanel>
<StyledPendantHistoryPanel ref={historyPanelRef}>
{history.map(item => {
const property = getProperty(item.id);
return (
@ -116,6 +117,7 @@ export const PendantHistoryPanel = ({
/>
}
trigger="click"
container={historyPanelRef.current}
>
<PendantTag
style={{

View File

@ -1,5 +1,11 @@
import React, { useState, useEffect } from 'react';
import { Input, Option, Select, Tooltip } from '@toeverything/components/ui';
import {
Input,
message,
Option,
Select,
Tooltip,
} from '@toeverything/components/ui';
import { HelpCenterIcon } from '@toeverything/components/icons';
import { AsyncBlock } from '../../editor';
@ -18,6 +24,7 @@ import {
generateRandomFieldName,
generateInitialOptions,
getPendantConfigByType,
checkPendantForm,
} from '../utils';
import { useOnCreateSure } from './hooks';
@ -74,7 +81,7 @@ export const CreatePendantPanel = ({
setFieldName(e.target.value);
}}
endAdornment={
<Tooltip content="Help info here">
<Tooltip content="Help info here" placement="top">
<StyledInputEndAdornment>
<HelpCenterIcon />
</StyledInputEndAdornment>
@ -98,6 +105,17 @@ export const CreatePendantPanel = ({
)}
iconConfig={getPendantConfigByType(selectedOption.type)}
onSure={async (type, newPropertyItem, newValue) => {
const checkResult = checkPendantForm(
type,
fieldName,
newPropertyItem,
newValue
);
if (!checkResult.passed) {
await message.error(checkResult.message);
return;
}
await onCreateSure({
type,
newPropertyItem,

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Input, Tooltip } from '@toeverything/components/ui';
import { Input, message, Tooltip } from '@toeverything/components/ui';
import { HelpCenterIcon } from '@toeverything/components/icons';
import { PendantModifyPanel } from '../pendant-modify-panel';
import type { AsyncBlock } from '../../editor';
@ -8,7 +8,7 @@ import {
type RecastBlockValue,
type RecastMetaProperty,
} from '../../recast-block';
import { getPendantConfigByType } from '../utils';
import { checkPendantForm, getPendantConfigByType } from '../utils';
import {
StyledPopoverWrapper,
StyledOperationLabel,
@ -70,7 +70,7 @@ export const UpdatePendantPanel = ({
setFieldName(e.target.value);
}}
endAdornment={
<Tooltip content="Help info here">
<Tooltip content="Help info here" placement="top">
<StyledInputEndAdornment>
<HelpCenterIcon />
</StyledInputEndAdornment>
@ -98,6 +98,17 @@ export const UpdatePendantPanel = ({
property={property}
type={property.type}
onSure={async (type, newPropertyItem, newValue) => {
const checkResult = checkPendantForm(
type,
fieldName,
newPropertyItem,
newValue
);
if (!checkResult.passed) {
await message.error(checkResult.message);
return;
}
await onUpdateSure({
type,
newPropertyItem,

View File

@ -23,12 +23,7 @@ import {
PendantTypes,
type TempInformationType,
} from '../types';
import {
checkPendantForm,
getOfficialSelected,
getPendantConfigByType,
} from '../utils';
import { message } from '@toeverything/components/ui';
import { getOfficialSelected, getPendantConfigByType } from '../utils';
type SelectPropertyType = MultiSelectProperty | SelectProperty;
type SureParams = {
@ -56,18 +51,6 @@ export const useOnCreateSure = ({ block }: { block: AsyncBlock }) => {
newPropertyItem,
newValue,
}: SureParams) => {
const checkResult = checkPendantForm(
type,
fieldName,
newPropertyItem,
newValue
);
if (!checkResult.passed) {
await message.error(checkResult.message);
return;
}
if (
type === PendantTypes.MultiSelect ||
type === PendantTypes.Select ||
@ -181,18 +164,6 @@ export const useOnUpdateSure = ({
newPropertyItem,
newValue,
}: SureParams) => {
const checkResult = checkPendantForm(
type,
fieldName,
newPropertyItem,
newValue
);
if (!checkResult.passed) {
await message.error(checkResult.message);
return;
}
if (
type === PendantTypes.MultiSelect ||
type === PendantTypes.Select ||

View File

@ -26,6 +26,7 @@ export const PendantPopover: FC<
block={block}
endElement={
<AddPendantPopover
container={popoverProps.container}
block={block}
onSure={() => {
popoverHandlerRef.current?.setVisible(false);

View File

@ -105,6 +105,8 @@ export const PendantRender = ({ block }: { block: AsyncBlock }) => {
<AddPendantPopover
block={block}
iconStyle={{ marginTop: 4 }}
trigger="click"
// trigger={isKanbanView ? 'hover' : 'click'}
container={blockRenderContainerRef.current}
/>
</div>

View File

@ -162,7 +162,7 @@ export class BlockCommands {
public async moveInNewGridItem(
blockId: string,
gridItemId: string,
isBefore = false
type = GridDropType.left
) {
const block = await this._editor.getBlockById(blockId);
if (block) {
@ -175,7 +175,7 @@ export class BlockCommands {
await block.remove();
await gridItemBlock.append(block);
if (targetGridItemBlock && gridItemBlock) {
if (isBefore) {
if (type === GridDropType.left) {
await targetGridItemBlock.before(gridItemBlock);
} else {
await targetGridItemBlock.after(gridItemBlock);

View File

@ -95,6 +95,9 @@ export class DragDropManager {
}
private async _handleDropBlock(event: React.DragEvent<Element>) {
const targetBlock = await this._editor.getBlockById(
this._blockDragTargetId
);
if (this._blockDragDirection !== BlockDropPlacement.none) {
const blockId = event.dataTransfer.getData(this._blockIdKey);
if (!(await this._canBeDrop(event))) return;
@ -109,13 +112,24 @@ export class DragDropManager {
this._blockDragDirection
)
) {
await this._editor.commands.blockCommands.createLayoutBlock(
blockId,
this._blockDragTargetId,
const dropType =
this._blockDragDirection === BlockDropPlacement.left
? GridDropType.left
: GridDropType.right
);
: GridDropType.right;
// if target is a grid item create grid item
if (targetBlock.type !== Protocol.Block.Type.gridItem) {
await this._editor.commands.blockCommands.createLayoutBlock(
blockId,
this._blockDragTargetId,
dropType
);
} else {
await this._editor.commands.blockCommands.moveInNewGridItem(
blockId,
this._blockDragTargetId,
dropType
);
}
}
if (
[
@ -123,9 +137,6 @@ export class DragDropManager {
BlockDropPlacement.outerRight,
].includes(this._blockDragDirection)
) {
const targetBlock = await this._editor.getBlockById(
this._blockDragTargetId
);
if (targetBlock.type !== Protocol.Block.Type.grid) {
await this._editor.commands.blockCommands.createLayoutBlock(
blockId,
@ -154,7 +165,7 @@ export class DragDropManager {
await this._editor.commands.blockCommands.moveInNewGridItem(
blockId,
gridItems[0].id,
true
GridDropType.right
);
}
}
@ -347,10 +358,10 @@ export class DragDropManager {
blockId: string
) {
const { clientX, clientY } = event;
this._setBlockDragTargetId(blockId);
const path = await this._editor.getBlockPath(blockId);
const mousePoint = new Point(clientX, clientY);
const rect = domToRect(blockDom);
let targetBlock: AsyncBlock = path[path.length - 1];
/**
* IMP: compute the level of the target block
* future feature drag drop has level support do not delete
@ -386,13 +397,30 @@ export class DragDropManager {
const gridBlocks = path.filter(
block => block.type === Protocol.Block.Type.grid
);
// limit grid block floor counts, when drag block to init grid
if (gridBlocks.length >= MAX_GRID_BLOCK_FLOOR) {
const parentBlock = path[path.length - 2];
// a new grid should not be grid item`s child
if (
parentBlock &&
parentBlock.type === Protocol.Block.Type.gridItem
) {
targetBlock = parentBlock;
// gridItem`s parent must be grid block
const gridItemCounts = (await path[path.length - 3].children())
.length;
if (
gridItemCounts >=
this._editor.configManager.grid.maxGridItemCount
) {
direction = BlockDropPlacement.none;
}
// limit grid block floor counts, when drag block to init grid
} else if (gridBlocks.length >= MAX_GRID_BLOCK_FLOOR) {
direction = BlockDropPlacement.none;
}
}
this._setBlockDragTargetId(targetBlock.id);
this._setBlockDragDirection(direction);
return direction;
return { direction, block: targetBlock };
}
public handlerEditorDrop(event: React.DragEvent<Element>) {

View File

@ -18,7 +18,6 @@ import {
menuItemsMap,
} from './config';
import { QueryResult } from '../../search';
export type CommandMenuProps = {
editor: Virgo;
hooks: PluginHooks;
@ -82,6 +81,13 @@ export const CommandMenu = ({ editor, hooks, style }: CommandMenuProps) => {
const checkIfShowCommandMenu = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => {
const { type, anchorNode } = editor.selection.currentSelectInfo;
if (!anchorNode?.id) {
return;
}
const activeBlock = await editor.getBlockById(anchorNode.id);
if (activeBlock.type === Protocol.Block.Type.page) {
return;
}
if (event.key === '/' && type === 'Range') {
if (anchorNode) {
const text = editor.blockHelper.getBlockTextBeforeSelection(
@ -119,12 +125,12 @@ export const CommandMenu = ({ editor, hooks, style }: CommandMenuProps) => {
const COMMAND_MENU_HEIGHT =
window.innerHeight * 0.4;
const { top, left } =
const { top, left, bottom } =
editor.container.getBoundingClientRect();
if (clientHeight - rectTop <= COMMAND_MENU_HEIGHT) {
setCommandMenuPosition({
left: rect.left - left,
bottom: rectTop - top + 10,
bottom: bottom - rect.bottom + 24,
top: 'initial',
});
} else {

View File

@ -168,6 +168,12 @@ export const GroupMenu = function ({ editor, hooks }: GroupMenuProps) {
useEffect(() => {
setShowMenu(false);
if (groupBlock) {
const unobserve = groupBlock.onUpdate(() => setGroupBlock(null));
return unobserve;
}
return undefined;
}, [groupBlock]);
return (

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { Protocol } from '@toeverything/datasource/db-service';
import {
MuiClickAwayListener as ClickAwayListener,
MuiGrow as Grow,
@ -22,8 +23,14 @@ export const InlineMenuContainer = ({ editor }: InlineMenuContainerProps) => {
useEffect(() => {
// const unsubscribe = editor.selection.onSelectionChange(info => {
const unsubscribe = editor.selection.onSelectEnd(info => {
const unsubscribe = editor.selection.onSelectEnd(async info => {
const { type, browserSelection, anchorNode } = info;
if (anchorNode) {
const activeBlock = await editor.getBlockById(anchorNode.id);
if (activeBlock.type === Protocol.Block.Type.page) {
return;
}
}
if (
type === 'None' ||
!anchorNode ||

View File

@ -15,6 +15,7 @@ import {
BlockDropPlacement,
LINE_GAP,
AsyncBlock,
TAG_GAP,
} from '@toeverything/framework/virgo';
import { Button } from '@toeverything/components/common';
import { styled } from '@toeverything/components/ui';
@ -78,13 +79,13 @@ function Line(props: { lineInfo: LineInfo; rootRect: DOMRect }) {
};
const bottomLineStyle = {
...horizontalLineStyle,
top: intersectionRect.bottom + 1 - rootRect.y - LINE_GAP,
top: intersectionRect.bottom + 1 - rootRect.y - LINE_GAP + TAG_GAP,
};
const verticalLineStyle = {
...lineStyle,
width: 2,
height: intersectionRect.height - LINE_GAP,
height: intersectionRect.height - LINE_GAP + TAG_GAP,
top: intersectionRect.y - rootRect.y,
};
const leftLineStyle = {
@ -184,6 +185,14 @@ export const LeftMenuDraggable: FC<LeftMenuProps> = props => {
return () => sub.unsubscribe();
}, [blockInfo, editor]);
useEffect(() => {
if (block?.block != null) {
const unobserve = block.block.onUpdate(() => setBlock(undefined));
return unobserve;
}
return undefined;
}, [block?.block]);
useEffect(() => {
const sub = lineInfo.subscribe(data => {
if (data == null) {
@ -220,7 +229,7 @@ export const LeftMenuDraggable: FC<LeftMenuProps> = props => {
MENU_WIDTH -
MENU_BUTTON_OFFSET -
rootRect.left,
top: block.rect.top - rootRect.top,
top: block.rect.top - rootRect.top + TAG_GAP * 2,
opacity: visible ? 1 : 0,
zIndex: 1,
}}

View File

@ -10,7 +10,7 @@ import {
import { PluginRenderRoot } from '../../utils';
import { Subject, throttleTime } from 'rxjs';
import { domToRect, last, Point } from '@toeverything/utils';
const DRAG_THROTTLE_DELAY = 150;
const DRAG_THROTTLE_DELAY = 60;
export class LeftMenuPlugin extends BasePlugin {
private _mousedown?: boolean;
private _root?: PluginRenderRoot;
@ -105,16 +105,17 @@ export class LeftMenuPlugin extends BasePlugin {
new Point(event.clientX, event.clientY)
);
if (block == null || ignoreBlockTypes.includes(block.type)) return;
const direction = await this.editor.dragDropManager.checkBlockDragTypes(
event,
block.dom,
block.id
);
const { direction, block: targetBlock } =
await this.editor.dragDropManager.checkBlockDragTypes(
event,
block.dom,
block.id
);
this._lineInfo.next({
direction,
blockInfo: {
block,
rect: block.dom.getBoundingClientRect(),
block: targetBlock,
rect: targetBlock.dom.getBoundingClientRect(),
},
});
};

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import style9 from 'style9';
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
import {

View File

@ -1,33 +1,34 @@
import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BasePlugin } from '../../base-plugin';
import { PluginRenderRoot } from '../../utils';
import { ReferenceMenu } from './ReferenceMenu';
const PLUGIN_NAME = 'reference-menu';
export class ReferenceMenuPlugin extends BasePlugin {
private root?: Root;
private _root?: PluginRenderRoot;
public static override get pluginName(): string {
return PLUGIN_NAME;
}
protected override _onRender(): void {
const container = document.createElement('div');
// TODO: remove
container.classList.add(`id-${PLUGIN_NAME}`);
// this.editor.attachElement(this.menu_container);
window.document.body.appendChild(container);
this.root = createRoot(container);
this.render_reference_menu();
}
this._root = new PluginRenderRoot({
name: PLUGIN_NAME,
render: this.editor.reactRenderRoot.render,
});
this._root.mount();
private render_reference_menu(): void {
this.root?.render(
this._root?.render(
<StrictMode>
<ReferenceMenu editor={this.editor} hooks={this.hooks} />
</StrictMode>
);
}
public override dispose() {
this._root?.unmount();
super.dispose();
}
}

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import style9 from 'style9';
import { MuiClickAwayListener } from '@toeverything/components/ui';
import { MuiClickAwayListener, styled } from '@toeverything/components/ui';
import { Virgo, HookType, PluginHooks } from '@toeverything/framework/virgo';
import { Point } from '@toeverything/utils';
@ -120,8 +119,7 @@ export const ReferenceMenu = ({ editor, hooks, style }: ReferenceMenuProps) => {
};
return (
<div
className={styles('referenceMenu')}
<ReferenceMenuWrapper
style={{ top: position.y, left: position.x }}
onKeyUp={handle_keyup}
>
@ -140,13 +138,11 @@ export const ReferenceMenu = ({ editor, hooks, style }: ReferenceMenuProps) => {
/>
</div>
</MuiClickAwayListener>
</div>
</ReferenceMenuWrapper>
);
};
const styles = style9.create({
referenceMenu: {
position: 'absolute',
zIndex: 1,
},
const ReferenceMenuWrapper = styled('div')({
position: 'absolute',
zIndex: 1,
});

View File

@ -0,0 +1 @@
export { ReferenceMenuPlugin } from './Plugin';

View File

@ -17,16 +17,20 @@ export const StatusIcon = ({ mode }: StatusIconProps) => {
const IconWrapper = styled('div')<Pick<StatusIconProps, 'mode'>>(
({ theme, mode }) => {
return {
width: '20px',
height: '20px',
width: '24px',
height: '24px',
borderRadius: '5px',
boxShadow: theme.affine.shadows.shadow1,
color: theme.affine.palette.primary,
cursor: 'pointer',
backgroundColor: theme.affine.palette.white,
transform: `translateX(${mode === DocMode.doc ? 0 : 20}px)`,
transform: `translateX(${mode === DocMode.doc ? 0 : 30}px)`,
transition: 'transform 300ms ease',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
'& > svg': {
fontSize: '20px',
},

View File

@ -2,26 +2,37 @@ import { styled } from '@toeverything/components/ui';
type StatusTextProps = {
children: string;
width?: string;
active?: boolean;
onClick?: () => void;
};
export const StatusText = ({ children, active, onClick }: StatusTextProps) => {
export const StatusText = ({
children,
width,
active,
onClick,
}: StatusTextProps) => {
return (
<StyledText active={active} onClick={onClick}>
<StyledText width={width} active={active} onClick={onClick}>
{children}
</StyledText>
);
};
const StyledText = styled('div')<StatusTextProps>(({ theme, active }) => {
return {
display: 'inline-flex',
alignItems: 'center',
color: theme.affine.palette.primary,
fontWeight: active ? '500' : '300',
fontSize: '15px',
cursor: 'pointer',
padding: '0 6px',
};
});
const StyledText = styled('div')<StatusTextProps>(
({ theme, width, active }) => {
return {
display: 'inline-flex',
alignItems: 'center',
color: active
? theme.affine.palette.primary
: 'rgba(62, 111, 219, 0.6)',
fontWeight: active ? '600' : '400',
fontSize: '16px',
lineHeight: '22px',
cursor: 'pointer',
...(!!width && { width }),
};
}
);

View File

@ -18,11 +18,14 @@ export const StatusTrack: FC<StatusTrackProps> = ({ mode, onClick }) => {
const Container = styled('div')(({ theme }) => {
return {
backgroundColor: theme.affine.palette.textHover,
borderRadius: '5px',
height: '30px',
width: '50px',
width: '64px',
height: '32px',
border: '1px solid #ECF1FB',
borderRadius: '8px',
cursor: 'pointer',
padding: '5px',
margin: '0 8px',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
};
});

View File

@ -32,6 +32,7 @@ export const Switcher = () => {
return (
<StyledContainerForSwitcher>
<StatusText
width={'44px'}
active={pageViewMode === DocMode.doc}
onClick={() => switchToPageView(DocMode.doc)}
>
@ -48,6 +49,7 @@ export const Switcher = () => {
}}
/>
<StatusText
width={'56px'}
active={pageViewMode === DocMode.board}
onClick={() => switchToPageView(DocMode.board)}
>
@ -60,4 +62,5 @@ export const Switcher = () => {
const StyledContainerForSwitcher = styled('div')({
display: 'flex',
alignItems: 'center',
pointerEvents: 'all',
});

View File

@ -1,4 +1,4 @@
import { IconButton, styled } from '@toeverything/components/ui';
import { IconButton, styled, MuiButton } from '@toeverything/components/ui';
import {
LogoIcon,
SideBarViewIcon,
@ -6,6 +6,7 @@ import {
SideBarViewCloseIcon,
} from '@toeverything/components/icons';
import { useShowSettingsSidebar } from '@toeverything/datasource/state';
import { CurrentPageTitle } from './Title';
import { EditorBoardSwitcher } from './EditorBoardSwitcher';
@ -24,18 +25,23 @@ export const LayoutHeader = () => {
</FlexContainer>
<FlexContainer>
<StyledHelper>
<StyledShare>Share</StyledShare>
<StyledShare disabled={true}>Share</StyledShare>
<div style={{ margin: '0px 12px' }}>
<IconButton size="large">
<IconButton
size="large"
hoverColor={'transparent'}
disabled={true}
style={{ cursor: 'not-allowed' }}
>
<SearchIcon />
</IconButton>
</div>
<IconButton onClick={toggleInfoSidebar} size="large">
{showSettingsSidebar ? (
<SideBarViewIcon />
) : (
<SideBarViewCloseIcon />
) : (
<SideBarViewIcon />
)}
</IconButton>
</StyledHelper>
@ -44,10 +50,37 @@ export const LayoutHeader = () => {
<EditorBoardSwitcher />
</StyledContainerForEditorBoardSwitcher>
</StyledHeaderRoot>
<StyledUnstableTips>
<StyledUnstableTipsText>
AFFiNE now under active development, the version is
UNSTABLE, please DO NOT store important data in this version
</StyledUnstableTipsText>
</StyledUnstableTips>
</StyledContainerForHeaderRoot>
);
};
const StyledUnstableTips = styled('div')(({ theme }) => {
return {
width: '100%',
height: '2em',
display: 'flex',
zIndex: theme.affine.zIndex.header,
backgroundColor: '#fff8c5',
borderWidth: '1px 0',
borderColor: '#e4e49588',
borderStyle: 'solid',
};
});
const StyledUnstableTipsText = styled('span')(({ theme }) => {
return {
margin: 'auto 36px',
width: '100%',
textAlign: 'center',
};
});
const StyledContainerForHeaderRoot = styled('div')(({ theme }) => {
return {
width: '100%',
@ -92,17 +125,19 @@ const StyledHelper = styled('div')({
alignItems: 'center',
});
const StyledShare = styled('div')({
const StyledShare = styled('div')<{ disabled?: boolean }>({
padding: '10px 12px',
fontWeight: 600,
fontSize: '14px',
color: '#3E6FDB',
cursor: 'pointer',
'&:hover': {
background: '#F5F7F8',
borderRadius: '5px',
},
cursor: 'not-allowed',
color: '#98ACBD',
textTransform: 'none',
/* disabled for current time */
// color: '#3E6FDB',
// '&:hover': {
// background: '#F5F7F8',
// borderRadius: '5px',
// },
});
const StyledLogoIcon = styled(LogoIcon)(({ theme }) => {
@ -112,9 +147,10 @@ const StyledLogoIcon = styled(LogoIcon)(({ theme }) => {
};
});
const StyledContainerForEditorBoardSwitcher = styled('div')(({ theme }) => {
return {
position: 'absolute',
left: '50%',
};
const StyledContainerForEditorBoardSwitcher = styled('div')({
width: '100%',
position: 'absolute',
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
});

View File

@ -3,12 +3,14 @@ import { useParams } from 'react-router-dom';
import { Typography, styled } from '@toeverything/components/ui';
import { services } from '@toeverything/datasource/db-service';
import { useUserAndSpaces } from '@toeverything/datasource/state';
/* card.7: Demand changes, temporarily closed, see https://github.com/toeverything/AFFiNE/issues/522 */
// import { usePageTree} from '@toeverything/components/layout';
// import { pickPath } from './utils';
export const CurrentPageTitle = () => {
const { user } = useUserAndSpaces();
const params = useParams();
const { workspace_id } = params;
const [pageId, setPageId] = useState<string>('');
@ -40,11 +42,11 @@ export const CurrentPageTitle = () => {
}, [pageId, workspace_id]);
useEffect(() => {
fetchPageTitle();
}, [fetchPageTitle]);
if (user) fetchPageTitle();
}, [fetchPageTitle, user]);
useEffect(() => {
if (!workspace_id || !pageId || pageTitle === undefined)
if (!user || !workspace_id || !pageId || pageTitle === undefined)
return () => {};
let unobserve: () => void;
@ -63,7 +65,7 @@ export const CurrentPageTitle = () => {
return () => {
// unobserve?.();
};
}, [fetchPageTitle, pageId, pageTitle, workspace_id]);
}, [fetchPageTitle, pageId, pageTitle, user, workspace_id]);
useEffect(() => {
document.title = pageTitle || '';

View File

@ -100,10 +100,10 @@ export const ContainerTabs = () => {
const StyledTabsTitlesContainer = styled('div')(({ theme }) => {
return {
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
marginBottom: 24,
marginLeft: theme.affine.spacing.smSpacing,
marginRight: theme.affine.spacing.smSpacing,
};

View File

@ -16,7 +16,6 @@ const StyledContainerForSettingsPanel = styled('div')(({ theme }) => {
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
paddingTop: 44,
paddingBottom: 44,
height: '100%',
};

View File

@ -1,4 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { message } from '@toeverything/components/ui';
import { useSettingFlags, type SettingFlags } from './use-setting-flags';
import { copyToClipboard } from '@toeverything/utils';
import {
@ -91,7 +92,10 @@ export const useSettings = (): SettingItem[] => {
{
type: 'button',
name: 'Copy Page Link',
onClick: () => copyToClipboard(window.location.href),
onClick: () => {
copyToClipboard(window.location.href);
message.success('Page link copied successfully');
},
},
{
type: 'separator',

View File

@ -10,9 +10,10 @@ import {
} from '@toeverything/components/ui';
import { useNavigate } from 'react-router';
import { formatDistanceToNow } from 'date-fns';
import { DotIcon } from '../dot-icon';
const StyledWrapper = styled('div')({
paddingLeft: '12px',
width: '100%',
span: {
textOverflow: 'ellipsis',
overflow: 'hidden',
@ -22,8 +23,8 @@ const StyledWrapper = styled('div')({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingRight: '20px',
whiteSpace: 'nowrap',
paddingLeft: '12px',
'&:hover': {
background: '#f5f7f8',
borderRadius: '5px',
@ -106,6 +107,8 @@ export const Activities = () => {
const { id, title, updated } = item;
return (
<ListItem className="item" key={id}>
<DotIcon />
<StyledItemContent
onClick={() => {
navigate(`/${currentSpaceId}/${id}`);

View File

@ -0,0 +1,9 @@
import { PageInPageTreeIcon } from '@toeverything/components/icons';
export const DotIcon = () => {
return (
<PageInPageTreeIcon
style={{ fill: '#98ACBD', width: '20px', height: '20px' }}
/>
);
};

View File

@ -0,0 +1 @@
export { DotIcon } from './DotIcon';

View File

@ -44,7 +44,7 @@ export type DndTreeProps = {
*/
export function DndTree(props: DndTreeProps) {
const {
indentationWidth = 12,
indentationWidth = 20,
collapsible,
removable,
showDragIndicator,

View File

@ -3,10 +3,8 @@ import { DndTree } from './DndTree';
import { useDndTreeAutoUpdate } from './use-page-tree';
const Root = styled('div')({
minWidth: 160,
maxWidth: 260,
marginLeft: 18,
marginRight: 6,
minWidth: '160px',
maxWidth: '276px',
});
export const PageTree = () => {

View File

@ -1,20 +1,18 @@
import styles from './tree-item.module.scss';
import { AddIcon, MoreIcon } from '@toeverything/components/icons';
import {
MuiSnackbar as Snackbar,
Cascader,
CascaderItemProps,
MuiDivider as Divider,
MuiClickAwayListener as ClickAwayListener,
IconButton,
MuiClickAwayListener as ClickAwayListener,
MuiSnackbar as Snackbar,
styled,
} from '@toeverything/components/ui';
import React from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { copyToClipboard } from '@toeverything/utils';
import { services, TemplateFactory } from '@toeverything/datasource/db-service';
import { NewFromTemplatePortal } from './NewFromTemplatePortal';
import { useFlag } from '@toeverything/datasource/feature-flags';
import { MoreIcon, AddIcon } from '@toeverything/components/icons';
import { copyToClipboard } from '@toeverything/utils';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { TreeItemMoreActions } from './styles';
const MESSAGES = {
COPY_LINK_SUCCESS: 'Copyed link to clipboard',
@ -47,6 +45,10 @@ function DndTreeItemMoreActions(props: ActionsProps) {
set_alert_open(false);
};
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (anchorEl) {
setAnchorEl(null);
return;
}
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
@ -246,10 +248,11 @@ function DndTreeItemMoreActions(props: ActionsProps) {
return (
<ClickAwayListener onClickAway={() => handleClose()}>
<div>
<div className={styles['TreeItemMoreActions']}>
<TreeItemMoreActions>
<StyledAction>
<IconButton
size="small"
hoverColor="#E0E6EB"
onClick={handle_new_child_page}
>
<AddIcon />
@ -262,14 +265,15 @@ function DndTreeItemMoreActions(props: ActionsProps) {
<MoreIcon />
</IconButton>
</StyledAction>
</div>
</TreeItemMoreActions>
<Cascader
items={menuList}
anchorEl={anchorEl}
placement="right-start"
open={open}
onClose={handleClose}
></Cascader>
/>
<Snackbar
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
open={alert_open}

View File

@ -1,20 +1,23 @@
import React, {
forwardRef,
type CSSProperties,
type HTMLAttributes,
} from 'react';
import { useParams, Link } from 'react-router-dom';
import cx from 'clsx';
import { CloseIcon } from '@toeverything/components/common';
import {
ArrowDropDownIcon,
ArrowRightIcon,
} from '@toeverything/components/icons';
import { forwardRef, type HTMLAttributes } from 'react';
import { useParams } from 'react-router-dom';
import styles from './tree-item.module.scss';
import { useFlag } from '@toeverything/datasource/feature-flags';
import MoreActions from './MoreActions';
import { DotIcon } from '../../dot-icon';
import {
ActionButton,
Counter,
TextLink,
TreeItemContainer,
TreeItemContent,
Wrapper,
} from './styles';
export type TreeItemProps = {
/** The main text to display on this line */
value: string;
@ -61,54 +64,40 @@ export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
},
ref
) => {
const { workspace_id } = useParams();
const { workspace_id, page_id } = useParams();
const BooleanPageTreeItemMoreActions = useFlag(
'BooleanPageTreeItemMoreActions',
true
);
return (
<li
<Wrapper
ref={wrapperRef}
className={cx(
styles['Wrapper'],
clone && styles['clone'],
ghost && styles['ghost'],
indicator && styles['indicator'],
disableSelection && styles['disableSelection'],
disableInteraction && styles['disableInteraction']
)}
style={
{
'--spacing': `${indentationWidth * depth}px`,
paddingTop: 0,
paddingBottom: 0,
} as CSSProperties
}
clone={clone}
ghost={ghost}
disableSelection={disableSelection}
disableInteraction={disableInteraction}
spacing={`${indentationWidth * depth + 12}px`}
active={pageId === page_id}
{...props}
>
<div
ref={ref}
className={styles['TreeItem']}
style={style}
title={value}
>
<Action onClick={onCollapse}>
{childCount !== 0 &&
(collapsed ? (
<TreeItemContainer ref={ref} style={style} title={value}>
<ActionButton tabIndex={0} onClick={onCollapse}>
{childCount !== 0 ? (
collapsed ? (
<ArrowRightIcon />
) : (
<ArrowDropDownIcon />
))}
</Action>
)
) : (
<DotIcon />
)}
</ActionButton>
<div className={styles['ItemContent']}>
<Link
className={styles['Text']}
{...handleProps}
to={`/${workspace_id}/${pageId}`}
>
<TreeItemContent {...handleProps}>
<TextLink to={`/${workspace_id}/${pageId}`}>
{value}
</Link>
</TextLink>
{BooleanPageTreeItemMoreActions && (
<MoreActions
workspaceId={workspace_id}
@ -119,71 +108,11 @@ export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
{/*{!clone && onRemove && <Remove onClick={onRemove} />}*/}
{clone && childCount && childCount > 1 ? (
<span className={styles['Count']}>
{childCount}
</span>
<Counter>{childCount}</Counter>
) : null}
</div>
</div>
</li>
</TreeItemContent>
</TreeItemContainer>
</Wrapper>
);
}
);
export interface ActionProps extends React.HTMLAttributes<HTMLButtonElement> {
active?: {
fill: string;
background: string;
};
// cursor?: CSSProperties['cursor'];
cursor?: 'pointer' | 'grab';
}
/** Customizable buttons */
export function Action({
active,
className,
cursor,
style,
...props
}: ActionProps) {
return (
<button
{...props}
className={cx(styles['Action'], className)}
tabIndex={0}
style={
{
...style,
'--fill': active?.fill,
'--background': active?.background,
} as CSSProperties
}
/>
);
}
export function Handle(props: ActionProps) {
return (
<Action cursor="grab" data-cypress="draggable-handle" {...props}>
<ArrowDropDownIcon />
</Action>
);
}
export function Remove(props: ActionProps) {
return (
<Action
{...props}
active={{
fill: 'rgba(255, 70, 70, 0.95)',
background: 'rgba(255, 70, 70, 0.1)',
}}
>
<CloseIcon style={{ fontSize: 12 }} />
{/* <svg width="8" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
<path d="M2.99998 -0.000206962C2.7441 -0.000206962 2.48794 0.0972617 2.29294 0.292762L0.292945 2.29276C-0.0980552 2.68376 -0.0980552 3.31682 0.292945 3.70682L7.58591 10.9998L0.292945 18.2928C-0.0980552 18.6838 -0.0980552 19.3168 0.292945 19.7068L2.29294 21.7068C2.68394 22.0978 3.31701 22.0978 3.70701 21.7068L11 14.4139L18.2929 21.7068C18.6829 22.0978 19.317 22.0978 19.707 21.7068L21.707 19.7068C22.098 19.3158 22.098 18.6828 21.707 18.2928L14.414 10.9998L21.707 3.70682C22.098 3.31682 22.098 2.68276 21.707 2.29276L19.707 0.292762C19.316 -0.0982383 18.6829 -0.0982383 18.2929 0.292762L11 7.58573L3.70701 0.292762C3.51151 0.0972617 3.25585 -0.000206962 2.99998 -0.000206962Z" />
</svg> */}
</Action>
);
}

View File

@ -1,9 +1,49 @@
.Wrapper {
import { styled } from '@toeverything/components/ui';
import { Link } from 'react-router-dom';
export const TreeItemContainer = styled('div')`
box-sizing: border-box;
padding-left: var(--spacing);
display: flex;
align-items: center;
color: #4c6275;
`;
export const Wrapper = styled('li')<{
spacing: string;
clone?: boolean;
ghost?: boolean;
indicator?: boolean;
disableSelection?: boolean;
disableInteraction?: boolean;
active?: boolean;
}>`
box-sizing: border-box;
padding-left: ${({ spacing }) => spacing};
list-style: none;
padding: 6px 0;
font-size: 14px;
background-color: ${({ active }) => (active ? '#f5f7f8' : 'transparent')};
border-radius: 5px;
${({ clone, disableSelection }) =>
(clone || disableSelection) &&
`width: 100%;
.Text,
.Count {
user-select: none;
-webkit-user-select: none;
}`}
${({ indicator }) =>
indicator &&
`width: 100%;
.Text,
.Count {
user-select: none;
-webkit-user-select: none;
}`}
${({ disableInteraction }) => disableInteraction && `pointer-events: none;`}
&:hover {
background: #f5f7f8;
border-radius: 5px;
@ -16,7 +56,7 @@
margin-top: 5px;
pointer-events: none;
.TreeItem {
${TreeItemContainer} {
padding-right: 20px;
border-radius: 4px;
box-shadow: 0px 15px 15px 0 rgba(34, 33, 81, 0.1);
@ -30,7 +70,7 @@
z-index: 1;
margin-bottom: -1px;
.TreeItem {
${TreeItemContainer} {
position: relative;
padding: 0;
height: 8px;
@ -61,56 +101,14 @@
opacity: 0.5;
}
.TreeItem > * {
${TreeItemContainer} > * {
box-shadow: none;
background-color: transparent;
}
}
}
`;
.TreeItem {
box-sizing: border-box;
display: flex;
align-items: center;
color: #4c6275;
}
.ItemContent {
box-sizing: border-box;
width: 100%;
height: 32px;
position: relative;
display: flex;
align-items: center;
justify-content: space-around;
color: #4c6275;
padding-right: 0.5rem;
overflow: hidden;
.TreeItemMoreActions {
visibility: hidden;
cursor: pointer;
}
&:hover {
.TreeItemMoreActions {
visibility: visible;
display: block;
}
}
}
.Text {
flex-grow: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
appearance: none;
color: unset;
text-decoration: none;
}
.Count {
export const Counter = styled('span')`
position: absolute;
top: 8px;
right: 0;
@ -124,36 +122,13 @@
font-size: 0.9rem;
font-weight: 500;
color: #fff;
}
`;
.disableInteraction {
pointer-events: none;
}
.disableSelection,
.clone {
width: 100%;
.Text,
.Count {
user-select: none;
-webkit-user-select: none;
}
}
.Collapse {
svg {
transition: transform 250ms ease;
}
&.collapsed svg {
transform: rotate(-90deg);
}
}
.Action {
export const ActionButton = styled('button')<{
background?: string;
fill?: string;
}>`
display: flex;
width: 12px;
padding: 0 15px;
align-items: center;
justify-content: center;
flex: 0 0 auto;
@ -167,18 +142,20 @@
-webkit-tap-highlight-color: transparent;
svg {
width: 20px;
height: 20px;
flex: 0 0 auto;
margin: auto;
height: 100%;
overflow: visible;
fill: #919eab;
}
&:active {
background-color: var(--background, rgba(0, 0, 0, 0.05));
background-color: ${({ background }) =>
background ?? 'rgba(0, 0, 0, 0.05)'};
svg {
fill: var(--fill, #788491);
fill: ${({ fill }) => fill ?? '#788491'};
}
}
@ -186,4 +163,46 @@
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), 0 0px 0px 2px #4c9ffe;
}
}
`;
export const TreeItemMoreActions = styled('div')`
display: block;
visibility: hidden;
`;
export const TextLink = styled(Link, {
shouldForwardProp: (prop: string) => !['active'].includes(prop),
})<{ active?: boolean }>`
display: flex;
align-items: center;
flex-grow: 1;
height: 100%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
appearance: none;
text-decoration: none;
user-select: none;
color: #4c6275;
`;
export const TreeItemContent = styled('div')`
box-sizing: border-box;
width: 100%;
height: 32px;
position: relative;
display: flex;
align-items: center;
justify-content: space-around;
color: #4c6275;
padding-right: 12px;
overflow: hidden;
&:hover {
${TreeItemMoreActions} {
visibility: visible;
cursor: pointer;
}
}
`;

View File

@ -12,6 +12,7 @@ import SelectUnstyled, {
} from '@mui/base/SelectUnstyled';
/* eslint-disable no-restricted-imports */
import PopperUnstyled from '@mui/base/PopperUnstyled';
import { ArrowDropDownIcon } from '@toeverything/components/icons';
import { styled } from '../styled';
type ExtendSelectProps = {
@ -41,20 +42,29 @@ export const Select = forwardRef(function CustomSelect<TValue>(
const { width = '100%', style, listboxStyle, placeholder } = props;
const components: SelectUnstyledProps<TValue>['components'] = {
// Root: generateStyledRoot({ width, ...style }),
Root: forwardRef((rootProps, rootRef) => (
<StyledRoot
ref={rootRef}
{...rootProps}
style={{
width,
...style,
}}
>
{rootProps.children || (
<StyledPlaceholder>{placeholder}</StyledPlaceholder>
)}
</StyledRoot>
)),
Root: forwardRef((rootProps, rootRef) => {
const {
ownerState: { open },
} = rootProps;
return (
<StyledRoot
ref={rootRef}
{...rootProps}
style={{
width,
...style,
}}
>
{rootProps.children || (
<StyledPlaceholder>{placeholder}</StyledPlaceholder>
)}
<StyledSelectedArrowWrapper open={open}>
<ArrowDropDownIcon />
</StyledSelectedArrowWrapper>
</StyledRoot>
);
}),
Listbox: forwardRef((listboxProps, listboxRef) => (
<StyledListbox
ref={listboxRef}
@ -73,6 +83,20 @@ export const Select = forwardRef(function CustomSelect<TValue>(
RefAttributes<HTMLUListElement>
) => JSX.Element;
const StyledSelectedArrowWrapper = styled('div')<{ open: boolean }>(
({ open }) => ({
position: 'absolute',
top: '0',
bottom: '0',
right: '12px',
margin: 'auto',
lineHeight: '32px',
display: 'flex',
alignItems: 'center',
transform: `rotate(${open ? '180deg' : '0'})`,
})
);
const StyledRoot = styled('div')(({ theme }) => ({
height: '32px',
border: `1px solid ${theme.affine.palette.borderColor}`,
@ -95,18 +119,6 @@ const StyledRoot = styled('div')(({ theme }) => ({
[`&.${selectUnstyledClasses.expanded}`]: {
borderColor: `${theme.affine.palette.primary}`,
'&::after': {
content: '"▴"',
},
},
'&::after': {
content: '"▾"',
position: ' absolute',
top: '0',
bottom: '0',
right: '12px',
margin: 'auto',
lineHeight: '32px',
},
}));

View File

@ -173,26 +173,34 @@ export const Theme = {
body1: {
fontSize: '16px',
lineHeight: '22px',
fontWeight: 400,
fontFamily: 'PingFang SC',
color: '#3A4C5C',
},
h1: {
fontSize: '28px',
lineHeight: '40px',
fontWeight: 600,
},
h2: {
fontSize: '24px',
lineHeight: '34px',
fontWeight: 600,
},
h3: {
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
},
h4: {
fontSize: '16px',
lineHeight: '22px',
fontWeight: 600,
},
page: {
fontSize: '36px',
lineHeight: '44px',
fontWeight: 600,
},
callout: {
fontSize: '36px',
@ -221,6 +229,7 @@ export const Theme = {
articleTitle: {
fontSize: '36px',
lineHeight: '54px',
fontWeight: 600,
},
},
shadows: {

View File

@ -154,6 +154,14 @@ export abstract class ServiceBaseClass {
await this.database.unregisterTagExporter(workspace, name);
}
async setupDataExporter(
workspace: string,
initialData: Uint8Array,
cb: (data: Uint8Array) => Promise<void>
) {
await this.database.setupDataExporter(workspace, initialData, cb);
}
protected async _observe(
workspace: string,
blockId: string,

View File

@ -78,7 +78,10 @@ export class Database {
}
async getDatabase(workspace: string, options?: BlockInitOptions) {
const db = await _getBlockDatabase(workspace, options);
const db = await _getBlockDatabase(workspace, {
...this.#options,
...options,
});
return db;
}
@ -87,7 +90,7 @@ export class Database {
name: string,
listener: (connectivity: Connectivity) => void
) {
const db = await _getBlockDatabase(workspace);
const db = await _getBlockDatabase(workspace, this.#options);
return db.addConnectivityListener(name, state => {
const connectivity = state.get(name);
if (connectivity) listener(connectivity);
@ -189,4 +192,13 @@ export class Database {
}
}
}
async setupDataExporter(
workspace: string,
initialData: Uint8Array,
callback: (binary: Uint8Array) => Promise<void>
) {
const db = await this.getDatabase(workspace);
await db.setupDataExporter(initialData, callback);
}
}

View File

@ -5,7 +5,11 @@
"author": "DarkSky <darksky2048@gmail.com>",
"dependencies": {
"lib0": "^0.2.52",
"sql.js": "^1.7.0",
"yjs": "^13.5.41",
"y-protocols": "^1.0.5"
},
"devDependencies": {
"@types/sql.js": "^1.4.3"
}
}

View File

@ -1 +1,3 @@
export { IndexedDBProvider } from './indexeddb';
export { WebsocketProvider } from './provider';
export { SQLiteProvider } from './sqlite';

View File

@ -0,0 +1,185 @@
import * as Y from 'yjs';
import * as idb from 'lib0/indexeddb.js';
import * as mutex from 'lib0/mutex.js';
import { Observable } from 'lib0/observable.js';
const customStoreName = 'custom';
const updatesStoreName = 'updates';
const PREFERRED_TRIM_SIZE = 500;
const fetchUpdates = async (provider: IndexedDBProvider) => {
const [updatesStore] = idb.transact(provider.db as IDBDatabase, [
updatesStoreName,
]); // , 'readonly')
const updates = await idb.getAll(
updatesStore,
idb.createIDBKeyRangeLowerBound(provider._dbref, false)
);
Y.transact(
provider.doc,
() => {
updates.forEach(val => Y.applyUpdate(provider.doc, val));
},
provider,
false
);
const lastKey = await idb.getLastKey(updatesStore);
provider._dbref = lastKey + 1;
const cnt = await idb.count(updatesStore);
provider._dbsize = cnt;
return updatesStore;
};
const storeState = (provider: IndexedDBProvider, forceStore = true) =>
fetchUpdates(provider).then(updatesStore => {
if (forceStore || provider._dbsize >= PREFERRED_TRIM_SIZE) {
idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(provider.doc))
.then(() =>
idb.del(
updatesStore,
idb.createIDBKeyRangeUpperBound(provider._dbref, true)
)
)
.then(() =>
idb.count(updatesStore).then(cnt => {
provider._dbsize = cnt;
})
);
}
});
export class IndexedDBProvider extends Observable<string> {
doc: Y.Doc;
name: string;
private _mux: mutex.mutex;
_dbref: number;
_dbsize: number;
private _destroyed: boolean;
whenSynced: Promise<IndexedDBProvider>;
db: IDBDatabase | null;
private _db: Promise<IDBDatabase>;
private synced: boolean;
private _storeTimeout: number;
private _storeTimeoutId: NodeJS.Timeout | null;
private _storeUpdate: (update: Uint8Array, origin: any) => void;
constructor(name: string, doc: Y.Doc) {
super();
this.doc = doc;
this.name = name;
this._mux = mutex.createMutex();
this._dbref = 0;
this._dbsize = 0;
this._destroyed = false;
this.db = null;
this.synced = false;
this._db = idb.openDB(name, db =>
idb.createStores(db, [
['updates', { autoIncrement: true }],
['custom'],
])
);
this.whenSynced = this._db.then(async db => {
this.db = db;
const currState = Y.encodeStateAsUpdate(doc);
const updatesStore = await fetchUpdates(this);
await idb.addAutoKey(updatesStore, currState);
if (this._destroyed) return this;
this.emit('synced', [this]);
this.synced = true;
return this;
});
// Timeout in ms untill data is merged and persisted in idb.
this._storeTimeout = 1000;
this._storeTimeoutId = null;
this._storeUpdate = (update: Uint8Array, origin: any) => {
if (this.db && origin !== this) {
const [updatesStore] = idb.transact(
/** @type {IDBDatabase} */ this.db,
[updatesStoreName]
);
idb.addAutoKey(updatesStore, update);
if (++this._dbsize >= PREFERRED_TRIM_SIZE) {
// debounce store call
if (this._storeTimeoutId !== null) {
clearTimeout(this._storeTimeoutId);
}
this._storeTimeoutId = setTimeout(() => {
storeState(this, false);
this._storeTimeoutId = null;
}, this._storeTimeout);
}
}
};
doc.on('update', this._storeUpdate);
this.destroy = this.destroy.bind(this);
doc.on('destroy', this.destroy);
}
override destroy() {
if (this._storeTimeoutId) {
clearTimeout(this._storeTimeoutId);
}
this.doc.off('update', this._storeUpdate);
this.doc.off('destroy', this.destroy);
this._destroyed = true;
return this._db.then(db => {
db.close();
});
}
/**
* Destroys this instance and removes all data from indexeddb.
*
* @return {Promise<void>}
*/
async clearData(): Promise<void> {
return this.destroy().then(() => {
idb.deleteDB(this.name);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<String | number | ArrayBuffer | Date | any>}
*/
async get(
key: string | number | ArrayBuffer | Date
): Promise<string | number | ArrayBuffer | Date | any> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName], 'readonly');
return idb.get(custom, key);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @param {String | number | ArrayBuffer | Date} value
* @return {Promise<String | number | ArrayBuffer | Date>}
*/
async set(
key: string | number | ArrayBuffer | Date,
value: string | number | ArrayBuffer | Date
): Promise<string | number | ArrayBuffer | Date> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName]);
return idb.put(custom, value, key);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<undefined>}
*/
async del(key: string | number | ArrayBuffer | Date): Promise<undefined> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName]);
return idb.del(custom, key);
});
}
}

View File

@ -0,0 +1,219 @@
import * as Y from 'yjs';
import sqlite, { Database, SqlJsStatic } from 'sql.js';
import { Observable } from 'lib0/observable.js';
const PREFERRED_TRIM_SIZE = 500;
const _stmts = {
create: 'CREATE TABLE IF NOT EXISTS updates (key INTEGER PRIMARY KEY AUTOINCREMENT, value BLOB);',
selectAll: 'SELECT * FROM updates where key >= $idx',
selectCount: 'SELECT count(*) FROM updates',
insert: 'INSERT INTO updates VALUES (null, $data);',
delete: 'DELETE FROM updates WHERE key < $idx',
drop: 'DROP TABLE updates;',
};
const countUpdates = (db: Database) => {
const [cnt] = db.exec(_stmts.selectCount);
return cnt.values[0]?.[0] as number;
};
const clearUpdates = (db: Database, idx: number) => {
db.exec(_stmts.delete, { $idx: idx });
};
const getAllUpdates = (db: Database, idx: number) => {
return db
.exec(_stmts.selectAll, { $idx: idx })
.flatMap(val => val.values as [number, Uint8Array][])
.sort(([a], [b]) => a - b);
};
let _sqliteInstance: SqlJsStatic | undefined;
let _sqliteProcessing = false;
const sleep = () => new Promise(resolve => setTimeout(resolve, 500));
const initSQLiteInstance = async () => {
while (_sqliteProcessing) {
await sleep();
}
if (_sqliteInstance) return _sqliteInstance;
_sqliteProcessing = true;
_sqliteInstance = await sqlite({
locateFile: () =>
// @ts-ignore
new URL('sql.js/dist/sql-wasm.wasm', import.meta.url).href,
});
_sqliteProcessing = false;
return _sqliteInstance;
};
export class SQLiteProvider extends Observable<string> {
doc: Y.Doc;
name: string;
db: Database | null;
whenSynced: Promise<SQLiteProvider>;
synced: boolean;
private _ref: number;
private _size: number;
private _destroyed: boolean;
private _db: Promise<Database>;
private _saver?: (binary: Uint8Array) => Promise<void> | undefined;
private _destroy: () => void;
constructor(name: string, doc: Y.Doc, origin?: Uint8Array) {
super();
this.doc = doc;
this.name = name;
this._ref = 0;
this._size = 0;
this._destroyed = false;
this.db = null;
this.synced = false;
this._db = initSQLiteInstance().then(db => {
const sqlite = new db.Database(origin);
return sqlite.run(_stmts.create);
});
this.whenSynced = this._db.then(async db => {
this.db = db;
const currState = Y.encodeStateAsUpdate(doc);
await this._fetchUpdates(true);
db.exec(_stmts.insert, { $data: currState });
this._storeState();
if (this._destroyed) return this;
this.emit('synced', [this]);
this.synced = true;
return this;
});
// Timeout in ms until data is merged and persisted in sqlite.
const storeTimeout = 500;
let storeTimeoutId: NodeJS.Timer | undefined = undefined;
let lastSize = 0;
const debouncedStoreState = (force = false) => {
// debounce store call
if (storeTimeoutId) clearTimeout(storeTimeoutId);
if (force) {
if (lastSize !== this._size) {
this._storeState();
storeTimeoutId = undefined;
lastSize = this._size;
}
} else {
storeTimeoutId = setTimeout(() => {
this._storeState();
storeTimeoutId = undefined;
}, storeTimeout);
}
};
const storeStateInterval = setInterval(
() => debouncedStoreState(true),
1000
);
const storeUpdate = (update: Uint8Array, origin: any) => {
if (this._saver && this.db && origin !== this) {
this.db.exec(_stmts.insert, { $data: update });
if (++this._size >= PREFERRED_TRIM_SIZE) {
debouncedStoreState();
}
}
};
doc.on('update', storeUpdate);
this.destroy = this.destroy.bind(this);
doc.on('destroy', this.destroy);
this._destroy = () => {
if (storeTimeoutId) clearTimeout(storeTimeoutId);
if (storeStateInterval) clearInterval(storeStateInterval);
this.doc.off('update', storeUpdate);
this.doc.off('destroy', this.destroy);
};
}
registerExporter(saver: (binary: Uint8Array) => Promise<void> | undefined) {
this._saver = saver;
}
private async _storeState(force?: boolean) {
await this._fetchUpdates();
if (this.db) {
if (force || this._size >= PREFERRED_TRIM_SIZE) {
this.db.exec(_stmts.insert, {
$data: Y.encodeStateAsUpdate(this.doc),
});
clearUpdates(this.db, this._ref);
this._size = countUpdates(this.db);
}
await this._saver?.(this.db?.export());
}
}
private _waitUpdate(updates: any[], sync = false) {
if (updates.length && sync) {
return new Promise<void>((resolve, reject) => {
const final = (_: any, origin: any) => {
if (origin === this) {
this.doc.off('update', final);
resolve();
}
};
this.doc.on('update', final);
});
}
return undefined;
}
private async _fetchUpdates(sync = false) {
if (this.db) {
const updates = getAllUpdates(this.db, this._ref);
const wait = this._waitUpdate(updates, sync);
Y.transact(
this.doc,
() => {
updates.forEach(([, update]) =>
Y.applyUpdate(this.doc, update)
);
},
this,
false
);
const lastKey = Math.max(...updates.map(([idx]) => idx));
this._ref = lastKey + 1;
this._size = countUpdates(this.db);
await wait;
}
}
override destroy(): Promise<void> {
this._destroy();
this._destroyed = true;
return this._db.then(db => {
db.close();
});
}
// Destroys this instance and removes all data from SQLite.
async clearData(): Promise<void> {
return this._db.then(db => {
db.exec(_stmts.drop);
return this.destroy();
});
}
}

View File

@ -13,8 +13,7 @@
"flexsearch": "^0.7.21",
"lib0": "^0.2.52",
"lru-cache": "^7.13.2",
"ts-debounce": "^4.0.0",
"y-indexeddb": "^9.0.9"
"ts-debounce": "^4.0.0"
},
"dependencies": {
"@types/flexsearch": "^0.7.3",

View File

@ -136,6 +136,7 @@ interface BlockInstance<C extends ContentOperation> {
interface AsyncDatabaseAdapter<C extends ContentOperation> {
inspector(): Record<string, any>;
reload(): void;
createBlock(
options: Pick<BlockItem<C>, 'type' | 'flavor'> & {
binary?: ArrayBuffer;
@ -156,6 +157,33 @@ interface AsyncDatabaseAdapter<C extends ContentOperation> {
getUserId(): string;
}
export type DataExporter = (binary: Uint8Array) => Promise<void>;
export const getDataExporter = () => {
let exporter: DataExporter | undefined = undefined;
let importer: (() => Uint8Array | undefined) | undefined = undefined;
const importData = () => importer?.();
const exportData = (binary: Uint8Array) => exporter?.(binary);
const hasExporter = () => !!exporter;
const installExporter = (
initialData: Uint8Array | undefined,
cb: DataExporter
) => {
return new Promise<void>(resolve => {
importer = () => initialData;
exporter = async (data: Uint8Array) => {
exporter = cb;
await cb(data);
resolve();
};
});
};
return { importData, exportData, hasExporter, installExporter };
};
export type {
AsyncDatabaseAdapter,
BlockPosition,

View File

@ -7,7 +7,6 @@ import { fromEvent } from 'file-selector';
import LRUCache from 'lru-cache';
import { debounce } from 'ts-debounce';
import { nanoid } from 'nanoid';
import { IndexeddbPersistence } from 'y-indexeddb';
import { Awareness } from 'y-protocols/awareness.js';
import {
Doc,
@ -19,7 +18,7 @@ import {
snapshot,
} from 'yjs';
import { WebsocketProvider } from '@toeverything/datasource/jwt-rpc';
import { IndexedDBProvider } from '@toeverything/datasource/jwt-rpc';
import {
AsyncDatabaseAdapter,
@ -28,7 +27,7 @@ import {
Connectivity,
HistoryManager,
} from '../../adapter';
import { BucketBackend, BlockItem, BlockTypes } from '../../types';
import { BlockItem, BlockTypes } from '../../types';
import { getLogger, sha3, sleep } from '../../utils';
import { YjsRemoteBinaries } from './binary';
@ -40,50 +39,26 @@ import {
} from './operation';
import { EmitEvents, Suspend } from './listener';
import { YjsHistoryManager } from './history';
import { YjsProvider } from './provider';
declare const JWT_DEV: boolean;
const logger = getLogger('BlockDB:yjs');
type ConnectivityListener = (
workspace: string,
connectivity: Connectivity
) => void;
type YjsProviders = {
awareness: Awareness;
idb: IndexeddbPersistence;
binariesIdb: IndexeddbPersistence;
ws?: WebsocketProvider;
backend: string;
idb: IndexedDBProvider;
binariesIdb: IndexedDBProvider;
gatekeeper: GateKeeper;
connListener: { listeners?: ConnectivityListener };
userId: string;
remoteToken?: string; // remote storage token
};
const _yjsDatabaseInstance = new Map<string, YjsProviders>();
async function _initWebsocketProvider(
url: string,
room: string,
doc: Doc,
token?: string,
params?: YjsInitOptions['params']
): Promise<[Awareness, WebsocketProvider | undefined]> {
const awareness = new Awareness(doc);
if (token) {
const ws = new WebsocketProvider(token, url, room, doc, {
awareness,
params,
}) as any; // TODO: type is erased after cascading references
// Wait for ws synchronization to complete, otherwise the data will be modified in reverse, which can be optimized later
return new Promise((resolve, reject) => {
// TODO: synced will also be triggered on reconnection after losing sync
// There needs to be an event mechanism to emit the synchronization state to the upper layer
ws.once('synced', () => resolve([awareness, ws]));
ws.once('lost-connection', () => resolve([awareness, ws]));
ws.once('connection-error', () => reject());
});
} else {
return [awareness, undefined];
}
}
const _asyncInitLoading = new Set<string>();
const _waitLoading = async (workspace: string) => {
while (_asyncInitLoading.has(workspace)) {
@ -92,12 +67,11 @@ const _waitLoading = async (workspace: string) => {
};
async function _initYjsDatabase(
backend: string,
workspace: string,
options: {
params: YjsInitOptions['params'];
userId: string;
token?: string;
provider?: Record<string, YjsProvider>;
}
): Promise<YjsProviders> {
if (_asyncInitLoading.has(workspace)) {
@ -113,27 +87,19 @@ async function _initYjsDatabase(
}
// if (instance) return instance;
_asyncInitLoading.add(workspace);
const { params, userId, token: remoteToken } = options;
const { userId, token } = options;
const doc = new Doc({ autoLoad: true, shouldLoad: true });
const idbp = new IndexeddbPersistence(workspace, doc).whenSynced;
const wsp = _initWebsocketProvider(
backend,
workspace,
doc,
remoteToken,
params
);
const [idb, [awareness, ws]] = await Promise.all([idbp, wsp]);
const idb = await new IndexedDBProvider(workspace, doc).whenSynced;
const binaries = new Doc({ autoLoad: true, shouldLoad: true });
const binariesIdb = await new IndexeddbPersistence(
const binariesIdb = await new IndexedDBProvider(
`${workspace}_binaries`,
binaries
).whenSynced;
const awareness = new Awareness(doc);
const gateKeeperData = doc.getMap<YMap<string>>('gatekeeper');
const gatekeeper = new GateKeeper(
@ -143,67 +109,74 @@ async function _initYjsDatabase(
gateKeeperData.get('common') || gateKeeperData.set('common', new YMap())
);
_yjsDatabaseInstance.set(workspace, {
const connListener: { listeners?: ConnectivityListener } = {};
if (options.provider) {
const emitState = (c: Connectivity) =>
connListener.listeners?.(workspace, c);
await Promise.all(
Object.entries(options.provider).map(async ([, p]) =>
p({ awareness, doc, token, workspace, emitState })
)
);
}
const newInstance = {
awareness,
idb,
binariesIdb,
ws,
backend,
gatekeeper,
connListener,
userId,
remoteToken,
});
remoteToken: token,
};
_yjsDatabaseInstance.set(workspace, newInstance);
_asyncInitLoading.delete(workspace);
return {
awareness,
idb,
binariesIdb,
ws,
backend,
gatekeeper,
userId,
remoteToken,
};
return newInstance;
}
export type { YjsBlockInstance } from './block';
export type { YjsContentOperation } from './operation';
export type YjsInitOptions = {
backend: typeof BucketBackend[keyof typeof BucketBackend];
params?: Record<string, string>;
userId?: string;
token?: string;
provider?: Record<string, YjsProvider>;
};
export { getYjsProviders } from './provider';
export type { YjsProviderOptions } from './provider';
export class YjsAdapter implements AsyncDatabaseAdapter<YjsContentOperation> {
private readonly _provider: YjsProviders;
private readonly _doc: Doc; // doc instance
private readonly _awareness: Awareness; // lightweight state synchronization
private readonly _gatekeeper: GateKeeper; // Simple access control
private readonly _history: YjsHistoryManager;
private readonly _history!: YjsHistoryManager;
// Block Collection
// key is a randomly generated global id
private readonly _blocks: YMap<YMap<unknown>>;
private readonly _blockUpdated: YMap<number>;
private readonly _blocks!: YMap<YMap<unknown>>;
private readonly _blockUpdated!: YMap<number>;
// Maximum cache Block 1024, ttl 10 minutes
private readonly _blockCaches: LRUCache<string, YjsBlockInstance>;
private readonly _blockCaches!: LRUCache<string, YjsBlockInstance>;
private readonly _binaries: YjsRemoteBinaries;
private readonly _binaries!: YjsRemoteBinaries;
private readonly _listener: Map<string, BlockListener<any>>;
private readonly _reload: () => void;
static async init(
workspace: string,
options: YjsInitOptions
): Promise<YjsAdapter> {
const { backend, params = {}, userId = 'default', token } = options;
const providers = await _initYjsDatabase(backend, workspace, {
params,
const { userId = 'default', token, provider } = options;
const providers = await _initYjsDatabase(workspace, {
userId,
token,
provider,
});
return new YjsAdapter(providers);
}
@ -213,33 +186,39 @@ export class YjsAdapter implements AsyncDatabaseAdapter<YjsContentOperation> {
this._doc = providers.idb.doc;
this._awareness = providers.awareness;
this._gatekeeper = providers.gatekeeper;
const blocks = this._doc.getMap<YMap<any>>('blocks');
this._blocks =
blocks.get('content') || blocks.set('content', new YMap());
this._blockUpdated =
blocks.get('updated') || blocks.set('updated', new YMap());
this._blockCaches = new LRUCache({ max: 1024, ttl: 1000 * 60 * 10 });
this._binaries = new YjsRemoteBinaries(
providers.binariesIdb.doc.getMap(),
providers.remoteToken
);
this._history = new YjsHistoryManager(this._blocks);
this._reload = () => {
const blocks = this._doc.getMap<YMap<any>>('blocks');
// @ts-ignore
this._blocks =
blocks.get('content') || blocks.set('content', new YMap());
// @ts-ignore
this._blockUpdated =
blocks.get('updated') || blocks.set('updated', new YMap());
// @ts-ignore
this._blockCaches = new LRUCache({
max: 1024,
ttl: 1000 * 60 * 10,
});
// @ts-ignore
this._binaries = new YjsRemoteBinaries(
providers.binariesIdb.doc.getMap(),
providers.remoteToken
);
// @ts-ignore
this._history = new YjsHistoryManager(this._blocks);
};
this._reload();
this._listener = new Map();
const ws = providers.ws as any;
if (ws) {
const workspace = providers.idb.name;
const emitState = (connectivity: Connectivity) => {
this._listener.get('connectivity')?.(
new Map([[workspace, connectivity]])
);
};
ws.on('synced', () => emitState('connected'));
ws.on('lost-connection', () => emitState('retry'));
ws.on('connection-error', () => emitState('retry'));
}
providers.connListener.listeners = (
workspace: string,
connectivity: Connectivity
) => {
this._listener.get('connectivity')?.(
new Map([[workspace, connectivity]])
);
};
const debounced_editing_notifier = debounce(
() => {
@ -314,6 +293,10 @@ export class YjsAdapter implements AsyncDatabaseAdapter<YjsContentOperation> {
});
}
reload() {
this._reload();
}
getUserId(): string {
return this._provider.userId;
}
@ -374,7 +357,7 @@ export class YjsAdapter implements AsyncDatabaseAdapter<YjsContentOperation> {
};
check();
});
await new IndexeddbPersistence(this._provider.idb.name, doc)
await new IndexedDBProvider(this._provider.idb.name, doc)
.whenSynced;
applyUpdate(doc, new Uint8Array(binary));
await update_check;

View File

@ -67,8 +67,12 @@ export function ChildrenListenerHandler(
const keys = Array.from(event.keys.entries()).map(
([key, { action }]) => [key, action] as [string, ChangedStateKeys]
);
const deleted = Array.from(event.changes.deleted.values())
.flatMap(val => val.content.getContent() as string[])
.filter(v => v)
.map(k => [k, 'delete'] as [string, ChangedStateKeys]);
for (const listener of listeners.values()) {
EmitEvents(keys, listener);
EmitEvents([...keys, ...deleted], listener);
}
}
}

View File

@ -0,0 +1,83 @@
import { Doc } from 'yjs';
import { Awareness } from 'y-protocols/awareness.js';
import {
SQLiteProvider,
WebsocketProvider,
} from '@toeverything/datasource/jwt-rpc';
import { Connectivity } from '../../adapter';
import { BucketBackend } from '../../types';
type YjsDefaultInstances = {
awareness: Awareness;
doc: Doc;
token?: string;
workspace: string;
emitState: (connectivity: Connectivity) => void;
};
export type YjsProvider = (instances: YjsDefaultInstances) => Promise<void>;
export type YjsProviderOptions = {
backend: typeof BucketBackend[keyof typeof BucketBackend];
params?: Record<string, string>;
importData?: () => Promise<Uint8Array> | Uint8Array | undefined;
exportData?: (binary: Uint8Array) => Promise<void> | undefined;
hasExporter?: () => boolean;
};
export const getYjsProviders = (
options: YjsProviderOptions
): Record<string, YjsProvider> => {
return {
sqlite: async (instances: YjsDefaultInstances) => {
const fsHandle = setInterval(async () => {
if (options.hasExporter?.()) {
clearInterval(fsHandle);
const fs = new SQLiteProvider(
instances.workspace,
instances.doc,
await options.importData?.()
);
if (options.exportData) {
fs.registerExporter(options.exportData);
}
await fs.whenSynced;
}
}, 500);
},
ws: async (instances: YjsDefaultInstances) => {
if (instances.token) {
const ws = new WebsocketProvider(
instances.token,
options.backend,
instances.workspace,
instances.doc,
{
awareness: instances.awareness,
params: options.params,
}
) as any; // TODO: type is erased after cascading references
// Wait for ws synchronization to complete, otherwise the data will be modified in reverse, which can be optimized later
return new Promise<void>((resolve, reject) => {
// TODO: synced will also be triggered on reconnection after losing sync
// There needs to be an event mechanism to emit the synchronization state to the upper layer
ws.once('synced', () => resolve());
ws.once('lost-connection', () => resolve());
ws.once('connection-error', () => reject());
ws.on('synced', () => instances.emitState('connected'));
ws.on('lost-connection', () =>
instances.emitState('retry')
);
ws.on('connection-error', () =>
instances.emitState('retry')
);
});
} else {
return;
}
},
};
};

View File

@ -27,12 +27,13 @@ export class AbstractBlock<
C extends ContentOperation
> {
private readonly _id: string;
readonly #block: BlockInstance<C>;
private readonly _block: BlockInstance<C>;
private readonly _history: HistoryManager;
private readonly _root?: AbstractBlock<B, C>;
private readonly _parentListener: Map<string, BlockListener>;
_parent?: AbstractBlock<B, C>;
private _parent?: AbstractBlock<B, C>;
private _changeParent?: () => void;
constructor(
block: B,
@ -40,20 +41,14 @@ export class AbstractBlock<
parent?: AbstractBlock<B, C>
) {
this._id = block.id;
this.#block = block;
this._history = this.#block.scopedHistory([this._id]);
this._block = block;
this._history = this._block.scopedHistory([this._id]);
this._root = root;
this._parentListener = new Map();
this._parent = parent;
JWT_DEV && logger_debug(`init: exists ${this._id}`);
if (parent) {
parent.addChildrenListener(this._id, states => {
if (states.get(this._id) === 'delete') {
this._emitParent(parent._id, 'delete');
}
});
}
if (parent) this._refreshParent(parent);
}
public get root() {
@ -66,7 +61,7 @@ export class AbstractBlock<
protected _getParentPage(warning = true): string | undefined {
if (this.flavor === 'page') {
return this.#block.id;
return this._block.id;
} else if (!this._parent) {
if (warning && this.flavor !== 'workspace') {
console.warn('parent not found');
@ -89,7 +84,7 @@ export class AbstractBlock<
if (event === 'parent') {
this._parentListener.set(name, callback);
} else {
this.#block.on(event, name, callback);
this._block.on(event, name, callback);
}
}
@ -97,42 +92,40 @@ export class AbstractBlock<
if (event === 'parent') {
this._parentListener.delete(name);
} else {
this.#block.off(event, name);
this._block.off(event, name);
}
}
public addChildrenListener(name: string, listener: BlockListener) {
this.#block.addChildrenListener(name, listener);
this._block.addChildrenListener(name, listener);
}
public removeChildrenListener(name: string) {
this.#block.removeChildrenListener(name);
this._block.removeChildrenListener(name);
}
public addContentListener(name: string, listener: BlockListener) {
this.#block.addContentListener(name, listener);
this._block.addContentListener(name, listener);
}
public removeContentListener(name: string) {
this.#block.removeContentListener(name);
this._block.removeContentListener(name);
}
public getContent<
T extends ContentTypes = ContentOperation
>(): MapOperation<T> {
if (this.#block.type === BlockTypes.block) {
return this.#block.content.asMap() as MapOperation<T>;
if (this._block.type === BlockTypes.block) {
return this._block.content.asMap() as MapOperation<T>;
}
throw new Error(
`this block not a structured block: ${this._id}, ${
this.#block.type
}`
`this block not a structured block: ${this._id}, ${this._block.type}`
);
}
public getBinary(): ArrayBuffer | undefined {
if (this.#block.type === BlockTypes.binary) {
return this.#block.content.asArray<ArrayBuffer>()?.get(0);
if (this._block.type === BlockTypes.binary) {
return this._block.content.asArray<ArrayBuffer>()?.get(0);
}
throw new Error('this block not a binary block');
}
@ -162,7 +155,7 @@ export class AbstractBlock<
// Last update UTC time
public get lastUpdated(): number {
return this.#block.updated || this.#block.created;
return this._block.updated || this._block.created;
}
private get last_updated_date(): string | undefined {
@ -171,7 +164,7 @@ export class AbstractBlock<
// create UTC time
public get created(): number {
return this.#block.created;
return this._block.created;
}
private get created_date(): string | undefined {
@ -180,11 +173,11 @@ export class AbstractBlock<
// creator id
public get creator(): string | undefined {
return this.#block.creator;
return this._block.creator;
}
[_GET_BLOCK]() {
return this.#block;
return this._block;
}
private _emitParent(
@ -199,8 +192,20 @@ export class AbstractBlock<
}
}
[_SET_PARENT](parent: AbstractBlock<B, C>) {
private _refreshParent(parent: AbstractBlock<B, C>) {
this._changeParent?.();
parent.addChildrenListener(this._id, states => {
if (states.get(this._id) === 'delete') {
this._emitParent(parent._id, 'delete');
}
});
this._parent = parent;
this._changeParent = () => parent.removeChildrenListener(this._id);
}
[_SET_PARENT](parent: AbstractBlock<B, C>) {
this._refreshParent(parent);
this._emitParent(parent.id);
}
@ -234,23 +239,23 @@ export class AbstractBlock<
* current block type
*/
public get type(): typeof BlockTypes[BlockTypeKeys] {
return this.#block.type;
return this._block.type;
}
/**
* current block flavor
*/
public get flavor(): typeof BlockFlavors[BlockFlavorKeys] {
return this.#block.flavor;
return this._block.flavor;
}
// TODO: flavor needs optimization
setFlavor(flavor: typeof BlockFlavors[BlockFlavorKeys]) {
this.#block.setFlavor(flavor);
this._block.setFlavor(flavor);
}
public get children(): string[] {
return this.#block.children;
return this._block.children;
}
/**
@ -274,12 +279,12 @@ export class AbstractBlock<
throw new Error('insertChildren: binary not allow insert children');
}
this.#block.insertChildren(block[_GET_BLOCK](), position);
this._block.insertChildren(block[_GET_BLOCK](), position);
block[_SET_PARENT](this);
}
public hasChildren(id: string): boolean {
return this.#block.hasChildren(id);
return this._block.hasChildren(id);
}
/**
@ -289,11 +294,11 @@ export class AbstractBlock<
*/
protected get_children(blockId?: string): BlockInstance<C>[] {
JWT_DEV && logger(`get children: ${blockId}`);
return this.#block.getChildren([blockId]);
return this._block.getChildren([blockId]);
}
public removeChildren(blockId?: string) {
this.#block.removeChildren([blockId]);
this._block.removeChildren([blockId]);
}
public remove() {

View File

@ -14,8 +14,14 @@ import {
HistoryManager,
ContentTypes,
Connectivity,
DataExporter,
getDataExporter,
} from './adapter';
import { YjsBlockInstance } from './adapter/yjs';
import {
getYjsProviders,
YjsBlockInstance,
YjsProviderOptions,
} from './adapter/yjs';
import {
BaseBlock,
BlockIndexer,
@ -27,11 +33,11 @@ import {
BlockTypes,
BlockTypeKeys,
BlockFlavors,
BucketBackend,
UUID,
BlockFlavorKeys,
BlockItem,
ExcludeFunction,
BucketBackend,
} from './types';
import { BlockEventBus, genUUID, getLogger } from './utils';
@ -62,6 +68,10 @@ type BlockClientOptions = {
content?: BlockExporters<string>;
metadata?: BlockExporters<Array<[string, number | string | string[]]>>;
tagger?: BlockExporters<string[]>;
installExporter: (
initialData: Uint8Array,
exporter: DataExporter
) => Promise<void>;
};
export class BlockClient<
@ -91,10 +101,15 @@ export class BlockClient<
private readonly _root: { node?: BaseBlock<B, C> };
private readonly _installExporter: (
initialData: Uint8Array,
exporter: DataExporter
) => Promise<void>;
private constructor(
adapter: A,
workspace: string,
options?: BlockClientOptions
options: BlockClientOptions
) {
this._adapter = adapter;
this._workspace = workspace;
@ -138,6 +153,7 @@ export class BlockClient<
});
this._root = {};
this._installExporter = options.installExporter;
}
public addBlockListener(tag: string, listener: BlockListener) {
@ -586,15 +602,34 @@ export class BlockClient<
return this._adapter.history();
}
public async setupDataExporter(initialData: Uint8Array, cb: DataExporter) {
await this._installExporter(initialData, cb);
this._adapter.reload();
}
public static async init(
workspace: string,
options: Partial<YjsInitOptions & BlockClientOptions> = {}
options: Partial<
YjsInitOptions & YjsProviderOptions & BlockClientOptions
> = {}
): Promise<BlockClientInstance> {
const { importData, exportData, hasExporter, installExporter } =
getDataExporter();
const instance = await YjsAdapter.init(workspace, {
backend: BucketBackend.YjsWebSocketAffine,
provider: getYjsProviders({
backend: BucketBackend.YjsWebSocketAffine,
importData,
exportData,
hasExporter,
...options,
}),
...options,
});
return new BlockClient(instance, workspace, options);
return new BlockClient(instance, workspace, {
...options,
installExporter,
});
}
}

View File

@ -55,28 +55,39 @@ const _useUserAndSpace = () => {
const currentSpaceId: string | undefined = useMemo(() => user?.id, [user]);
return {
user,
currentSpaceId,
loading,
};
return { user, currentSpaceId, loading };
};
const BRAND_ID = 'AFFiNE';
const _localTrigger = atom<boolean>(false);
const _useUserAndSpacesForFreeLogin = () => {
const [user, setUser] = useAtom(_userAtom);
const [loading, setLoading] = useAtom(_loadingAtom);
const [localTrigger] = useAtom(_localTrigger);
useEffect(() => setLoading(false), []);
const BRAND_ID = 'AFFiNE';
return {
user: {
photo: '',
id: BRAND_ID,
nickname: BRAND_ID,
email: '',
} as UserInfo,
currentSpaceId: BRAND_ID,
loading,
};
useEffect(() => {
if (localTrigger) {
setUser({
photo: '',
id: BRAND_ID,
username: BRAND_ID,
nickname: BRAND_ID,
email: '',
});
}
}, [localTrigger, setLoading, setUser]);
const currentSpaceId: string | undefined = useMemo(() => user?.id, [user]);
return { user, currentSpaceId, loading };
};
export const useLocalTrigger = () => {
const [, setTrigger] = useAtom(_localTrigger);
return () => setTrigger(true);
};
export const useUserAndSpaces = process.env['NX_LOCAL']

View File

@ -594,7 +594,6 @@ importers:
sift: ^16.0.0
ts-debounce: ^4.0.0
uuid: ^8.3.2
y-indexeddb: ^9.0.9
y-protocols: ^1.0.5
yjs: ^13.5.41
dependencies:
@ -622,17 +621,21 @@ importers:
lib0: 0.2.52
lru-cache: 7.13.2
ts-debounce: 4.0.0
y-indexeddb: 9.0.9_yjs@13.5.41
libs/datasource/jwt-rpc:
specifiers:
'@types/sql.js': ^1.4.3
lib0: ^0.2.52
sql.js: ^1.7.0
y-protocols: ^1.0.5
yjs: ^13.5.41
dependencies:
lib0: 0.2.52
sql.js: 1.7.0
y-protocols: 1.0.5
yjs: 13.5.41
devDependencies:
'@types/sql.js': 1.4.3
libs/datasource/remote-kv:
specifiers:
@ -6691,6 +6694,10 @@ packages:
resolution: {integrity: sha512-uw8eYMIReOwstQ0QKF0sICefSy8cNO/v7gOTiIy9SbwuHyEecJUm7qlgueOO5S1udZ5I/irVydHVwMchgzbKTg==}
dev: true
/@types/emscripten/1.39.6:
resolution: {integrity: sha512-H90aoynNhhkQP6DRweEjJp5vfUVdIj7tdPLsu7pq89vODD/lcugKfZOsfgwpvM6XUewEp2N5dCg1Uf3Qe55Dcg==}
dev: true
/@types/eslint-scope/3.7.4:
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
dependencies:
@ -6988,6 +6995,13 @@ packages:
'@types/node': 18.0.1
dev: true
/@types/sql.js/1.4.3:
resolution: {integrity: sha512-3bz1LJIiJtKMEL8tYf7c9Nrb1lYcFeWQkE8vhWvobE29ZzizW79DtoTjqx1bR82DS2Ch2K30nOwNhuLclZ1vYg==}
dependencies:
'@types/emscripten': 1.39.6
'@types/node': 18.0.1
dev: true
/@types/stack-utils/2.0.1:
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
dev: true
@ -17879,6 +17893,10 @@ packages:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: true
/sql.js/1.7.0:
resolution: {integrity: sha512-qAfft3xkSgHqmmfNugWTp/59PsqIw8gbeao5TZmpmzQQsAJ49de3iDDKuxVixidYs6dkHNksY8m27v2dZNn2jw==}
dev: false
/sshpk/1.17.0:
resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==}
engines: {node: '>=0.10.0'}
@ -19572,15 +19590,6 @@ packages:
engines: {node: '>=0.4'}
dev: true
/y-indexeddb/9.0.9_yjs@13.5.41:
resolution: {integrity: sha512-GcJbiJa2eD5hankj46Hea9z4hbDnDjvh1fT62E5SpZRsv8GcEemw34l1hwI2eknGcv5Ih9JfusT37JLx9q3LFg==}
peerDependencies:
yjs: ^13.0.0
dependencies:
lib0: 0.2.52
yjs: 13.5.41
dev: true
/y-protocols/1.0.5:
resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
dependencies:
@ -19649,6 +19658,7 @@ packages:
resolution: {integrity: sha512-4eSTrrs8OeI0heXKKioRY4ag7V5Bk85Z4MeniUyown3o3y0G7G4JpAZWrZWfTp7pzw2b53GkAQWKqHsHi9j9JA==}
dependencies:
lib0: 0.2.52
dev: false
/yn/3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}