diff --git a/.all-contributorsrc b/.all-contributorsrc index 3286d1be2..9f85871a6 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -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" + ] } ] } diff --git a/.github/deployment/Dockerfile-affine b/.github/deployment/Dockerfile-affine index 46a46233f..5cf4243ea 100644 --- a/.github/deployment/Dockerfile-affine +++ b/.github/deployment/Dockerfile-affine @@ -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"] \ No newline at end of file +CMD ["caddy", "run"] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..de5d75c15 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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 + +For answers to common questions about this code of conduct, see diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7b7b63a1a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Welcome to ourcontributing guide + +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 don’t 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/) diff --git a/README.md b/README.md index 5018d0ba4..9e2ebc85f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066 --> -[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 @@ -46,7 +46,7 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066 # How to use -If you have experience in front-end development, you may wish to refer to our [documentation](https://affine.gitbook.io/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. +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. @@ -91,7 +91,7 @@ Affine is fully built with web technologies to ensure consistency and accessibil # Documentation -AFFiNE is not yet ready for production use. For installation, you may check how to build or deploy AFFiNE from our [quick-start](https://affine.gitbook.io/affine/basic-documentation/contribute-to-affine/quick-start) guide. Alternatively, you can view our [full documentation](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 @@ -171,19 +171,35 @@ For help, discussion about best practices, or any other conversation that would - - - - - - - + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + +

DarkSky

πŸ’» πŸ“–

Chi Zhang

πŸ’» πŸ“–

wang xinglong

πŸ’» πŸ“–

DiamondThree

πŸ’» πŸ“–

Whitewater

πŸ’» πŸ“–

xiaodong zuo

πŸ’» πŸ“–

MingLIang Wang

πŸ’» πŸ“–

DarkSky

πŸ’» πŸ“–

Chi Zhang

πŸ’» πŸ“–

wang xinglong

πŸ’» πŸ“–

DiamondThree

πŸ’» πŸ“–

Whitewater

πŸ’» πŸ“–

xiaodong zuo

πŸ’» πŸ“–

MingLIang Wang

πŸ’» πŸ“–

Qi

πŸ’» πŸ“–

mitsuhatu

πŸ’» πŸ“–

Austaras

πŸ’» πŸ“–

Jin Yao

πŸ’» πŸ“–

Qi

πŸ’» πŸ“–

mitsuhatu

πŸ’» πŸ“–

Austaras

πŸ’» πŸ“–

Jin Yao

πŸ’» πŸ“–

HeJiachen-PM

πŸ“–

Yipei Wei

πŸ“–

fanjing22

🎨

Svaney

🎨

Guozhu Liu

🎨

fyZheng07

πŸ“‹ πŸ““

CJSS

πŸ“–

Carlos Rafael

πŸ’»

Caleb OLeary

πŸ’»

JimmFly

πŸ’»

Weston Graham

πŸ“–

pointmax

πŸ“–
diff --git a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx index 76c48efeb..886d8f1c1 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/Page.tsx @@ -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'; diff --git a/apps/ligo-virgo/src/pages/workspace/docs/collapsible-page-tree.tsx b/apps/ligo-virgo/src/pages/workspace/docs/collapsible-page-tree.tsx index 913f4024c..d37910583 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/collapsible-page-tree.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/collapsible-page-tree.tsx @@ -8,6 +8,7 @@ import { usePageTree, } from '@toeverything/components/layout'; import { + IconButton, MuiBox as Box, MuiCollapse as Collapse, styled, @@ -27,6 +28,7 @@ const StyledBtn = styled('div')({ cursor: 'pointer', userSelect: 'none', flex: 1, + marginLeft: '12px', }); export type CollapsiblePageTreeProps = { @@ -70,7 +72,7 @@ export function CollapsiblePageTree(props: CollapsiblePageTreeProps) { display: 'flex', justifyContent: 'space-between', alignItems: 'center', - paddingRight: 1, + paddingRight: '12px', '&:hover': { background: '#f5f7f8', borderRadius: '5px', @@ -80,24 +82,17 @@ export function CollapsiblePageTree(props: CollapsiblePageTreeProps) { onMouseLeave={() => setNewPageBtnVisible(false)} > setOpen(prev => !prev)}> - {open ? ( - - ) : ( - - )} {title} {newPageBtnVisible && ( - + > + + )} {children ? ( diff --git a/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx b/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx index b4a2eaf02..7f442caca 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/components/tabs/Tabs.tsx @@ -10,13 +10,13 @@ const StyledTabs = styled('div')(({ theme }) => { display: 'flex', fontSize: '12px', fontWeight: '600', - color: theme.affine.palette.primary, }; }); -const StyledTabTitle = styled('div', { - shouldForwardProp: (prop: string) => !['isActive'].includes(prop), -})<{ isActive?: boolean; isDisabled?: boolean }>` +const StyledTabTitle = styled('div')<{ + isActive?: boolean; + isDisabled?: boolean; +}>` flex: 1; display: flex; align-items: center; @@ -26,13 +26,18 @@ const StyledTabTitle = styled('div', { 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 : ''}; + isActive + ? theme.affine.palette.primary + : 'rgba(62, 111, 219, 0.6)'}; position: absolute; left: 100%; top: -2px; diff --git a/apps/ligo-virgo/src/pages/workspace/docs/workspace-name.tsx b/apps/ligo-virgo/src/pages/workspace/docs/workspace-name.tsx index 631149716..67a36e1ce 100644 --- a/apps/ligo-virgo/src/pages/workspace/docs/workspace-name.tsx +++ b/apps/ligo-virgo/src/pages/workspace/docs/workspace-name.tsx @@ -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) => { - if (e.key === 'Enter') { - e.stopPropagation(); - e.preventDefault(); - setInRename(false); - } - }, - [] - ); + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + setInRename(false); + } + }, []); const handleChange = useCallback( - (e: React.ChangeEvent) => { - services.api.userConfig.setWorkspaceName( + async (e: ChangeEvent) => { + 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 = () => { {inRename ? ( - { + indexedDB.deleteDatabase(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 = () => { + const onSelected = useLocalTrigger(); + const [error, setError] = useState(false); + + useEffect(() => { + if (process.env['NX_E2E']) { + onSelected(); + } + }, []); + + return ( + + + + + + + + { + try { + await requestPermission('AFFiNE'); + onSelected(); + } catch (e) { + setError(true); + onSelected(); + setTimeout(() => setError(false), 3000); + } + }} + style={{ + textAlign: 'center', + width: '300px', + margin: '300px auto 20px auto', + }} + sx={{ mt: 1 }} + > + + + + Sync to Disk + + + + + ); +}; diff --git a/libs/components/account/src/login/index.tsx b/libs/components/account/src/login/index.tsx index 3601d97b9..ba7a589c8 100644 --- a/libs/components/account/src/login/index.tsx +++ b/libs/components/account/src/login/index.tsx @@ -1,11 +1,13 @@ +/* eslint-disable filename-rules/match */ // import { Authing } from './authing'; import { Firebase } from './firebase'; +import { FileSystem } from './fs'; export function Login() { return ( <> {/* */} - + {process.env['NX_LOCAL'] ? : } ); } diff --git a/libs/components/board-draw/src/TlDraw.tsx b/libs/components/board-draw/src/TlDraw.tsx index 21044ad51..1e95506ab 100644 --- a/libs/components/board-draw/src/TlDraw.tsx +++ b/libs/components/board-draw/src/TlDraw.tsx @@ -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(null); + const rWrapper = useRef(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 ( @@ -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; + focusableRef: RefObject; }) { 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', diff --git a/libs/components/board-draw/src/components/tools-panel/ToolsPanel.tsx b/libs/components/board-draw/src/components/tools-panel/ToolsPanel.tsx index 48550054e..e8d10e1f7 100644 --- a/libs/components/board-draw/src/components/tools-panel/ToolsPanel.tsx +++ b/libs/components/board-draw/src/components/tools-panel/ToolsPanel.tsx @@ -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 ( = ({ app }) => { style={{ color: activeTool === type - ? 'blue' + ? theme.affine.palette + .primary : '', }} onClick={() => { diff --git a/libs/components/board-state/src/tldraw-app.ts b/libs/components/board-state/src/tldraw-app.ts index ab9492aab..b062cc8b8 100644 --- a/libs/components/board-state/src/tldraw-app.ts +++ b/libs/components/board-state/src/tldraw-app.ts @@ -219,8 +219,6 @@ export class TldrawApp extends StateManager { isPointing = false; - isForcePanning = false; - editingStartTime = -1; fileSystemHandle: FileSystemHandle | null = null; @@ -262,7 +260,7 @@ export class TldrawApp extends StateManager { 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 { ); 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 { 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 { break; } case ' ': { - this.isForcePanning = true; + this.patchState({ + settings: { + forcePanning: true, + }, + }); this.spaceKey = true; break; } @@ -3976,7 +3978,12 @@ export class TldrawApp extends StateManager { 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 { 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 { 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 { 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 { assets: {}, }; - static default_state: TDSnapshot = { + static defaultState: TDSnapshot = { settings: { isCadSelectMode: false, isPenMode: false, @@ -4527,6 +4542,7 @@ export class TldrawApp extends StateManager { isSnapping: false, isDebugMode: false, isReadonlyMode: false, + forcePanning: false, keepStyleMenuOpen: false, nudgeDistanceLarge: 16, nudgeDistanceSmall: 1, diff --git a/libs/components/board-tools/src/hand-draw/hand-draw-tool.ts b/libs/components/board-tools/src/hand-draw/hand-draw-tool.ts index c579c8e7e..180b93644 100644 --- a/libs/components/board-tools/src/hand-draw/hand-draw-tool.ts +++ b/libs/components/board-tools/src/hand-draw/hand-draw-tool.ts @@ -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, + }, + }); }; } diff --git a/libs/components/board-types/src/types.ts b/libs/components/board-types/src/types.ts index fbf3e5c30..6f80a965b 100644 --- a/libs/components/board-types/src/types.ts +++ b/libs/components/board-types/src/types.ts @@ -84,6 +84,7 @@ export interface TDSnapshot { isPenMode: boolean; isReadonlyMode: boolean; isZoomSnap: boolean; + forcePanning: boolean; keepStyleMenuOpen: boolean; nudgeDistanceSmall: number; nudgeDistanceLarge: number; diff --git a/libs/components/common/src/lib/collapsible-title/index.tsx b/libs/components/common/src/lib/collapsible-title/index.tsx index 7dd59e613..f241bed32 100644 --- a/libs/components/common/src/lib/collapsible-title/index.tsx +++ b/libs/components/common/src/lib/collapsible-title/index.tsx @@ -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 ( <> setOpen(prev => !prev)}> - {open ? ( - - ) : ( - - )}
({ 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 ? { diff --git a/libs/components/editor-blocks/src/blocks/group/scene-kanban/CardContext.tsx b/libs/components/editor-blocks/src/blocks/group/scene-kanban/CardContext.tsx index f7f05b37d..13fcaa45e 100644 --- a/libs/components/editor-blocks/src/blocks/group/scene-kanban/CardContext.tsx +++ b/libs/components/editor-blocks/src/blocks/group/scene-kanban/CardContext.tsx @@ -60,6 +60,9 @@ export const CardContext = (props: Props) => { const StyledCardContainer = styled('div')` cursor: pointer; + &:hover { + z-index: 1; + } &:focus-within { z-index: 1; } diff --git a/libs/components/editor-blocks/src/blocks/page/PageView.tsx b/libs/components/editor-blocks/src/blocks/page/PageView.tsx index 5aca7e646..17ad6cae9 100644 --- a/libs/components/editor-blocks/src/blocks/page/PageView.tsx +++ b/libs/components/editor-blocks/src/blocks/page/PageView.tsx @@ -109,12 +109,15 @@ export const PageView: FC = ({ 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', + }, + }; }); diff --git a/libs/components/editor-blocks/src/blocks/text/TextView.tsx b/libs/components/editor-blocks/src/blocks/text/TextView.tsx index 8d1726a9a..98b6736b2 100644 --- a/libs/components/editor-blocks/src/blocks/text/TextView.tsx +++ b/libs/components/editor-blocks/src/blocks/text/TextView.tsx @@ -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, }; } }); diff --git a/libs/components/editor-blocks/src/blocks/todo/TodoView.tsx b/libs/components/editor-blocks/src/blocks/todo/TodoView.tsx index b45c07a79..e5588d46f 100644 --- a/libs/components/editor-blocks/src/blocks/todo/TodoView.tsx +++ b/libs/components/editor-blocks/src/blocks/todo/TodoView.tsx @@ -150,6 +150,7 @@ const TodoBlock = styled('div')({ display: 'flex', '.checkBoxContainer': { marginRight: '4px', + padding: '0 4px', height: '22px', }, '.textContainer': { diff --git a/libs/components/editor-blocks/src/components/BlockContainer/BlockContainer.tsx b/libs/components/editor-blocks/src/components/BlockContainer/BlockContainer.tsx index f03cd9ffe..dbe12277b 100644 --- a/libs/components/editor-blocks/src/components/BlockContainer/BlockContainer.tsx +++ b/libs/components/editor-blocks/src/components/BlockContainer/BlockContainer.tsx @@ -27,5 +27,6 @@ export const BlockContainer: FC = function ({ export const Container = styled('div')<{ selected: boolean }>( ({ selected, theme }) => ({ backgroundColor: selected ? theme.affine.palette.textSelected : '', + marginBottom: '2px', }) ); diff --git a/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx b/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx index 2e146c770..7928375a3 100644 --- a/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx +++ b/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx @@ -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) => { diff --git a/libs/components/editor-core/src/RenderRoot.tsx b/libs/components/editor-core/src/RenderRoot.tsx index e4534b813..caa840cb3 100644 --- a/libs/components/editor-core/src/RenderRoot.tsx +++ b/libs/components/editor-core/src/RenderRoot.tsx @@ -102,6 +102,12 @@ export const RenderRoot: FC> = ({ editor.getHooks().onRootNodeMouseLeave(event); }; + const onContextmenu = ( + event: React.MouseEvent + ) => { + selectionRef.current?.onContextmenu(event); + }; + const onKeyDown: React.KeyboardEventHandler = event => { // IMP move into keyboard managers? editor.getHooks().onRootNodeKeyDown(event); @@ -165,6 +171,7 @@ export const RenderRoot: FC> = ({ onMouseUp={onMouseUp} onMouseLeave={onMouseLeave} onMouseOut={onMouseOut} + onContextMenu={onContextmenu} onKeyDown={onKeyDown} onKeyDownCapture={onKeyDownCapture} onKeyUp={onKeyUp} diff --git a/libs/components/editor-core/src/Selection.tsx b/libs/components/editor-core/src/Selection.tsx index e2dc34ed3..98ad9ccb1 100644 --- a/libs/components/editor-core/src/Selection.tsx +++ b/libs/components/editor-core/src/Selection.tsx @@ -29,6 +29,9 @@ export type SelectionRef = { onMouseDown: (event: React.MouseEvent) => void; onMouseMove: (event: React.MouseEvent) => void; onMouseUp: (event: React.MouseEvent) => void; + onContextmenu: ( + event: React.MouseEvent + ) => void; }; const getFixedPoint = ( @@ -207,10 +210,17 @@ export const SelectionRect = forwardRef( scrollManager.stopAutoScroll(); }; + const onContextmenu = () => { + if (mouseType.current === 'down') { + onMouseUp(); + } + }; + useImperativeHandle(ref, () => ({ onMouseDown, onMouseMove, onMouseUp, + onContextmenu, })); useEffect(() => { diff --git a/libs/components/editor-core/src/block-pendant/BlockPendantProvider.tsx b/libs/components/editor-core/src/block-pendant/BlockPendantProvider.tsx index 66cf4001a..70c56af04 100644 --- a/libs/components/editor-core/src/block-pendant/BlockPendantProvider.tsx +++ b/libs/components/editor-core/src/block-pendant/BlockPendantProvider.tsx @@ -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> = ({ block, children, }) => { + const triggerRef = useRef(); + const { getProperties } = useRecastBlockMeta(); + const properties = getProperties(); + const { getValue } = getRecastItemValue(block); + const showTriggerLine = + properties.filter(property => getValue(property.id)).length === 0; + return ( {children} - - - + {showTriggerLine ? ( + + + + + + ) : null} @@ -28,7 +44,7 @@ export const BlockPendantProvider: FC> = ({ }; 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', + }, + }, + }, +}); diff --git a/libs/components/editor-core/src/block-pendant/pendant-history-panel/PendantHistoryPanel.tsx b/libs/components/editor-core/src/block-pendant/pendant-history-panel/PendantHistoryPanel.tsx index 0f610301d..ade7ced37 100644 --- a/libs/components/editor-core/src/block-pendant/pendant-history-panel/PendantHistoryPanel.tsx +++ b/libs/components/editor-core/src/block-pendant/pendant-history-panel/PendantHistoryPanel.tsx @@ -29,6 +29,7 @@ export const PendantHistoryPanel = ({ const [history, setHistory] = useState([]); const popoverHandlerRef = useRef<{ [key: string]: PopperHandler }>({}); + const historyPanelRef = useRef(); const { getValueHistory } = getRecastItemValue(block); useEffect(() => { @@ -84,7 +85,7 @@ export const PendantHistoryPanel = ({ }, [block, getProperties, groupBlock, recastBlock]); return ( - + {history.map(item => { const property = getProperty(item.id); return ( @@ -116,6 +117,7 @@ export const PendantHistoryPanel = ({ /> } trigger="click" + container={historyPanelRef.current} > + @@ -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, diff --git a/libs/components/editor-core/src/block-pendant/pendant-operation-panel/UpdatePendantPanel.tsx b/libs/components/editor-core/src/block-pendant/pendant-operation-panel/UpdatePendantPanel.tsx index 40ef97631..2ff5515ba 100644 --- a/libs/components/editor-core/src/block-pendant/pendant-operation-panel/UpdatePendantPanel.tsx +++ b/libs/components/editor-core/src/block-pendant/pendant-operation-panel/UpdatePendantPanel.tsx @@ -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={ - + @@ -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, diff --git a/libs/components/editor-core/src/block-pendant/pendant-operation-panel/hooks.ts b/libs/components/editor-core/src/block-pendant/pendant-operation-panel/hooks.ts index 079cb2627..55016cc21 100644 --- a/libs/components/editor-core/src/block-pendant/pendant-operation-panel/hooks.ts +++ b/libs/components/editor-core/src/block-pendant/pendant-operation-panel/hooks.ts @@ -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 || diff --git a/libs/components/editor-core/src/block-pendant/pendant-popover/PendantPopover.tsx b/libs/components/editor-core/src/block-pendant/pendant-popover/PendantPopover.tsx index 70cb5115c..9ec79d03e 100644 --- a/libs/components/editor-core/src/block-pendant/pendant-popover/PendantPopover.tsx +++ b/libs/components/editor-core/src/block-pendant/pendant-popover/PendantPopover.tsx @@ -26,6 +26,7 @@ export const PendantPopover: FC< block={block} endElement={ { popoverHandlerRef.current?.setVisible(false); diff --git a/libs/components/editor-core/src/block-pendant/pendant-render/PandentRender.tsx b/libs/components/editor-core/src/block-pendant/pendant-render/PandentRender.tsx index 76ed0d4e3..8de0e9a20 100644 --- a/libs/components/editor-core/src/block-pendant/pendant-render/PandentRender.tsx +++ b/libs/components/editor-core/src/block-pendant/pendant-render/PandentRender.tsx @@ -105,6 +105,8 @@ export const PendantRender = ({ block }: { block: AsyncBlock }) => {
diff --git a/libs/components/editor-core/src/editor/commands/block-commands.ts b/libs/components/editor-core/src/editor/commands/block-commands.ts index 54f9e5d2f..5972e9d41 100644 --- a/libs/components/editor-core/src/editor/commands/block-commands.ts +++ b/libs/components/editor-core/src/editor/commands/block-commands.ts @@ -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); diff --git a/libs/components/editor-core/src/editor/drag-drop/drag-drop.ts b/libs/components/editor-core/src/editor/drag-drop/drag-drop.ts index eccdfb081..be64c07b9 100644 --- a/libs/components/editor-core/src/editor/drag-drop/drag-drop.ts +++ b/libs/components/editor-core/src/editor/drag-drop/drag-drop.ts @@ -95,6 +95,9 @@ export class DragDropManager { } private async _handleDropBlock(event: React.DragEvent) { + 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) { diff --git a/libs/components/editor-plugins/src/menu/command-menu/Menu.tsx b/libs/components/editor-plugins/src/menu/command-menu/Menu.tsx index fc5583a2e..7b74cbfd5 100644 --- a/libs/components/editor-plugins/src/menu/command-menu/Menu.tsx +++ b/libs/components/editor-plugins/src/menu/command-menu/Menu.tsx @@ -18,7 +18,6 @@ import { menuItemsMap, } from './config'; import { QueryResult } from '../../search'; - export type CommandMenuProps = { editor: Virgo; hooks: PluginHooks; @@ -82,7 +81,6 @@ export const CommandMenu = ({ editor, hooks, style }: CommandMenuProps) => { const checkIfShowCommandMenu = useCallback( async (event: React.KeyboardEvent) => { const { type, anchorNode } = editor.selection.currentSelectInfo; - // console.log(await editor.getBlockById(anchorNode.id)); if (!anchorNode?.id) { return; } @@ -127,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 { diff --git a/libs/components/editor-plugins/src/menu/group-menu/GropuMenu.tsx b/libs/components/editor-plugins/src/menu/group-menu/GropuMenu.tsx index 790ee9863..781c2c3a2 100644 --- a/libs/components/editor-plugins/src/menu/group-menu/GropuMenu.tsx +++ b/libs/components/editor-plugins/src/menu/group-menu/GropuMenu.tsx @@ -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 ( diff --git a/libs/components/editor-plugins/src/menu/left-menu/LeftMenuDraggable.tsx b/libs/components/editor-plugins/src/menu/left-menu/LeftMenuDraggable.tsx index 996857c2a..750490650 100644 --- a/libs/components/editor-plugins/src/menu/left-menu/LeftMenuDraggable.tsx +++ b/libs/components/editor-plugins/src/menu/left-menu/LeftMenuDraggable.tsx @@ -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 = 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 = 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, }} diff --git a/libs/components/editor-plugins/src/menu/left-menu/LeftMenuPlugin.tsx b/libs/components/editor-plugins/src/menu/left-menu/LeftMenuPlugin.tsx index 91402bbd9..4eda43b55 100644 --- a/libs/components/editor-plugins/src/menu/left-menu/LeftMenuPlugin.tsx +++ b/libs/components/editor-plugins/src/menu/left-menu/LeftMenuPlugin.tsx @@ -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(), }, }); }; diff --git a/libs/components/layout/src/header/EditorBoardSwitcher/StatusIcon.tsx b/libs/components/layout/src/header/EditorBoardSwitcher/StatusIcon.tsx index a30e17f91..0c38b3f4e 100644 --- a/libs/components/layout/src/header/EditorBoardSwitcher/StatusIcon.tsx +++ b/libs/components/layout/src/header/EditorBoardSwitcher/StatusIcon.tsx @@ -17,16 +17,20 @@ export const StatusIcon = ({ mode }: StatusIconProps) => { const IconWrapper = styled('div')>( ({ 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', }, diff --git a/libs/components/layout/src/header/EditorBoardSwitcher/StatusText.tsx b/libs/components/layout/src/header/EditorBoardSwitcher/StatusText.tsx index 73976d3da..b50e48091 100644 --- a/libs/components/layout/src/header/EditorBoardSwitcher/StatusText.tsx +++ b/libs/components/layout/src/header/EditorBoardSwitcher/StatusText.tsx @@ -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 ( - + {children} ); }; -const StyledText = styled('div')(({ 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')( + ({ 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 }), + }; + } +); diff --git a/libs/components/layout/src/header/EditorBoardSwitcher/StatusTrack.tsx b/libs/components/layout/src/header/EditorBoardSwitcher/StatusTrack.tsx index bcc3e80fa..839c429da 100644 --- a/libs/components/layout/src/header/EditorBoardSwitcher/StatusTrack.tsx +++ b/libs/components/layout/src/header/EditorBoardSwitcher/StatusTrack.tsx @@ -18,11 +18,14 @@ export const StatusTrack: FC = ({ 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', }; }); diff --git a/libs/components/layout/src/header/EditorBoardSwitcher/Switcher.tsx b/libs/components/layout/src/header/EditorBoardSwitcher/Switcher.tsx index 0bfe20971..3c350c237 100644 --- a/libs/components/layout/src/header/EditorBoardSwitcher/Switcher.tsx +++ b/libs/components/layout/src/header/EditorBoardSwitcher/Switcher.tsx @@ -32,6 +32,7 @@ export const Switcher = () => { return ( switchToPageView(DocMode.doc)} > @@ -48,6 +49,7 @@ export const Switcher = () => { }} /> switchToPageView(DocMode.board)} > diff --git a/libs/components/layout/src/header/LayoutHeader.tsx b/libs/components/layout/src/header/LayoutHeader.tsx index ccf649764..85e715733 100644 --- a/libs/components/layout/src/header/LayoutHeader.tsx +++ b/libs/components/layout/src/header/LayoutHeader.tsx @@ -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,9 +25,14 @@ export const LayoutHeader = () => { - Share + Share
- +
@@ -119,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 }) => { @@ -141,9 +149,7 @@ const StyledLogoIcon = styled(LogoIcon)(({ theme }) => { const StyledContainerForEditorBoardSwitcher = styled('div')(({ theme }) => { return { - width: '100%', position: 'absolute', - display: 'flex', - justifyContent: 'center', + left: '50%', }; }); diff --git a/libs/components/layout/src/settings-sidebar/Settings/use-settings.ts b/libs/components/layout/src/settings-sidebar/Settings/use-settings.ts index 1ce5bcb55..639afef92 100644 --- a/libs/components/layout/src/settings-sidebar/Settings/use-settings.ts +++ b/libs/components/layout/src/settings-sidebar/Settings/use-settings.ts @@ -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', diff --git a/libs/components/layout/src/workspace-sidebar/activities/activities.tsx b/libs/components/layout/src/workspace-sidebar/activities/activities.tsx index a532d9bd0..50c552c0a 100644 --- a/libs/components/layout/src/workspace-sidebar/activities/activities.tsx +++ b/libs/components/layout/src/workspace-sidebar/activities/activities.tsx @@ -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 ( + + { navigate(`/${currentSpaceId}/${id}`); diff --git a/libs/components/layout/src/workspace-sidebar/dot-icon/DotIcon.tsx b/libs/components/layout/src/workspace-sidebar/dot-icon/DotIcon.tsx new file mode 100644 index 000000000..4a86326d4 --- /dev/null +++ b/libs/components/layout/src/workspace-sidebar/dot-icon/DotIcon.tsx @@ -0,0 +1,9 @@ +import { PageInPageTreeIcon } from '@toeverything/components/icons'; + +export const DotIcon = () => { + return ( + + ); +}; diff --git a/libs/components/layout/src/workspace-sidebar/dot-icon/index.ts b/libs/components/layout/src/workspace-sidebar/dot-icon/index.ts new file mode 100644 index 000000000..5ee35a697 --- /dev/null +++ b/libs/components/layout/src/workspace-sidebar/dot-icon/index.ts @@ -0,0 +1 @@ +export { DotIcon } from './DotIcon'; diff --git a/libs/components/layout/src/workspace-sidebar/page-tree/DndTree.tsx b/libs/components/layout/src/workspace-sidebar/page-tree/DndTree.tsx index 57d7643bb..40991b930 100755 --- a/libs/components/layout/src/workspace-sidebar/page-tree/DndTree.tsx +++ b/libs/components/layout/src/workspace-sidebar/page-tree/DndTree.tsx @@ -44,7 +44,7 @@ export type DndTreeProps = { */ export function DndTree(props: DndTreeProps) { const { - indentationWidth = 12, + indentationWidth = 20, collapsible, removable, showDragIndicator, diff --git a/libs/components/layout/src/workspace-sidebar/page-tree/PageTree.tsx b/libs/components/layout/src/workspace-sidebar/page-tree/PageTree.tsx index d40442ce0..b2ea531e5 100755 --- a/libs/components/layout/src/workspace-sidebar/page-tree/PageTree.tsx +++ b/libs/components/layout/src/workspace-sidebar/page-tree/PageTree.tsx @@ -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 = () => { diff --git a/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/TreeItem.tsx b/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/TreeItem.tsx index 1a084d7b3..e2c0c7def 100755 --- a/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/TreeItem.tsx +++ b/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/TreeItem.tsx @@ -8,6 +8,7 @@ import { useParams } from 'react-router-dom'; import { useFlag } from '@toeverything/datasource/feature-flags'; import MoreActions from './MoreActions'; +import { DotIcon } from '../../dot-icon'; import { ActionButton, Counter, @@ -76,24 +77,25 @@ export const TreeItem = forwardRef( ghost={ghost} disableSelection={disableSelection} disableInteraction={disableInteraction} - spacing={`${indentationWidth * depth}px`} + spacing={`${indentationWidth * depth + 12}px`} + active={pageId === page_id} {...props} > - {childCount !== 0 && - (collapsed ? ( + {childCount !== 0 ? ( + collapsed ? ( ) : ( - ))} + ) + ) : ( + + )} - + {value} {BooleanPageTreeItemMoreActions && ( diff --git a/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/styles.ts b/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/styles.ts index a615a497e..0040e5bef 100644 --- a/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/styles.ts +++ b/libs/components/layout/src/workspace-sidebar/page-tree/tree-item/styles.ts @@ -15,11 +15,14 @@ export const Wrapper = styled('li')<{ indicator?: boolean; disableSelection?: boolean; disableInteraction?: boolean; + active?: boolean; }>` box-sizing: border-box; padding-left: ${({ spacing }) => spacing}; list-style: none; font-size: 14px; + background-color: ${({ active }) => (active ? '#f5f7f8' : 'transparent')}; + border-radius: 5px; ${({ clone, disableSelection }) => (clone || disableSelection) && @@ -126,8 +129,6 @@ export const ActionButton = styled('button')<{ fill?: string; }>` display: flex; - width: 12px; - padding: 0 15px; align-items: center; justify-content: center; flex: 0 0 auto; @@ -141,9 +142,10 @@ export const ActionButton = styled('button')<{ -webkit-tap-highlight-color: transparent; svg { + width: 20px; + height: 20px; flex: 0 0 auto; margin: auto; - height: 100%; overflow: visible; fill: #919eab; } @@ -168,7 +170,9 @@ export const TreeItemMoreActions = styled('div')` visibility: hidden; `; -export const TextLink = styled(Link)<{ active?: boolean }>` +export const TextLink = styled(Link, { + shouldForwardProp: (prop: string) => !['active'].includes(prop), +})<{ active?: boolean }>` display: flex; align-items: center; flex-grow: 1; @@ -180,8 +184,7 @@ export const TextLink = styled(Link)<{ active?: boolean }>` appearance: none; text-decoration: none; user-select: none; - color: ${({ theme, active }) => - active ? theme.affine.palette.primary : 'unset'}; + color: #4c6275; `; export const TreeItemContent = styled('div')` @@ -193,7 +196,7 @@ export const TreeItemContent = styled('div')` align-items: center; justify-content: space-around; color: #4c6275; - padding-right: 0.5rem; + padding-right: 12px; overflow: hidden; &:hover { diff --git a/libs/components/ui/src/select/Select.tsx b/libs/components/ui/src/select/Select.tsx index d1f3141e6..607c3bfa6 100644 --- a/libs/components/ui/src/select/Select.tsx +++ b/libs/components/ui/src/select/Select.tsx @@ -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( const { width = '100%', style, listboxStyle, placeholder } = props; const components: SelectUnstyledProps['components'] = { // Root: generateStyledRoot({ width, ...style }), - Root: forwardRef((rootProps, rootRef) => ( - - {rootProps.children || ( - {placeholder} - )} - - )), + Root: forwardRef((rootProps, rootRef) => { + const { + ownerState: { open }, + } = rootProps; + + return ( + + {rootProps.children || ( + {placeholder} + )} + + + + + ); + }), Listbox: forwardRef((listboxProps, listboxRef) => ( ( RefAttributes ) => 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', }, })); diff --git a/libs/components/ui/src/theme/theme.ts b/libs/components/ui/src/theme/theme.ts index 70aace812..b8055da05 100644 --- a/libs/components/ui/src/theme/theme.ts +++ b/libs/components/ui/src/theme/theme.ts @@ -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: { diff --git a/libs/datasource/db-service/src/services/base.ts b/libs/datasource/db-service/src/services/base.ts index dcf53dcb4..9e10af917 100644 --- a/libs/datasource/db-service/src/services/base.ts +++ b/libs/datasource/db-service/src/services/base.ts @@ -154,6 +154,14 @@ export abstract class ServiceBaseClass { await this.database.unregisterTagExporter(workspace, name); } + async setupDataExporter( + workspace: string, + initialData: Uint8Array, + cb: (data: Uint8Array) => Promise + ) { + await this.database.setupDataExporter(workspace, initialData, cb); + } + protected async _observe( workspace: string, blockId: string, diff --git a/libs/datasource/db-service/src/services/database/index.ts b/libs/datasource/db-service/src/services/database/index.ts index 439e6ca90..9f97b9a35 100644 --- a/libs/datasource/db-service/src/services/database/index.ts +++ b/libs/datasource/db-service/src/services/database/index.ts @@ -192,4 +192,13 @@ export class Database { } } } + + async setupDataExporter( + workspace: string, + initialData: Uint8Array, + callback: (binary: Uint8Array) => Promise + ) { + const db = await this.getDatabase(workspace); + await db.setupDataExporter(initialData, callback); + } } diff --git a/libs/datasource/jwt-rpc/src/indexeddb.ts b/libs/datasource/jwt-rpc/src/indexeddb.ts index bb38fb8a8..512daea6d 100644 --- a/libs/datasource/jwt-rpc/src/indexeddb.ts +++ b/libs/datasource/jwt-rpc/src/indexeddb.ts @@ -134,7 +134,7 @@ export class IndexedDBProvider extends Observable { } /** - * Destroys this instance and removes all data from SQLite. + * Destroys this instance and removes all data from indexeddb. * * @return {Promise} */ diff --git a/libs/datasource/jwt-rpc/src/sqlite.ts b/libs/datasource/jwt-rpc/src/sqlite.ts index 36e374efe..1753edda8 100644 --- a/libs/datasource/jwt-rpc/src/sqlite.ts +++ b/libs/datasource/jwt-rpc/src/sqlite.ts @@ -5,7 +5,7 @@ import { Observable } from 'lib0/observable.js'; const PREFERRED_TRIM_SIZE = 500; const _stmts = { - create: 'CREATE TABLE updates (key INTEGER PRIMARY KEY AUTOINCREMENT, value BLOB);', + 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);', @@ -41,6 +41,7 @@ const initSQLiteInstance = async () => { _sqliteProcessing = true; _sqliteInstance = await sqlite({ locateFile: () => + // @ts-ignore new URL('sql.js/dist/sql-wasm.wasm', import.meta.url).href, }); _sqliteProcessing = false; @@ -58,7 +59,7 @@ export class SQLiteProvider extends Observable { private _size: number; private _destroyed: boolean; private _db: Promise; - private _saver?: (binary: Uint8Array) => void; + private _saver?: (binary: Uint8Array) => Promise | undefined; private _destroy: () => void; constructor(name: string, doc: Y.Doc, origin?: Uint8Array) { @@ -81,8 +82,9 @@ export class SQLiteProvider extends Observable { this.whenSynced = this._db.then(async db => { this.db = db; const currState = Y.encodeStateAsUpdate(doc); - await this._fetchUpdates(); + await this._fetchUpdates(true); db.exec(_stmts.insert, { $data: currState }); + this._storeState(); if (this._destroyed) return this; this.emit('synced', [this]); this.synced = true; @@ -90,21 +92,38 @@ export class SQLiteProvider extends Observable { }); // Timeout in ms until data is merged and persisted in sqlite. - const storeTimeout = 1000; + 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) { - // debounce store call - if (storeTimeoutId) clearTimeout(storeTimeoutId); - - storeTimeoutId = setTimeout(() => { - this._storeState(); - storeTimeoutId = undefined; - }, storeTimeout); + debouncedStoreState(); } } }; @@ -115,35 +134,54 @@ export class SQLiteProvider extends Observable { 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) => void) { + registerExporter(saver: (binary: Uint8Array) => Promise | undefined) { this._saver = saver; } - private async _storeState() { + private async _storeState(force?: boolean) { await this._fetchUpdates(); - if (this.db && this._size >= PREFERRED_TRIM_SIZE) { - this.db.exec(_stmts.insert, { - $data: Y.encodeStateAsUpdate(this.doc), - }); + 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); + clearUpdates(this.db, this._ref); - this._size = countUpdates(this.db); + this._size = countUpdates(this.db); + } - this._saver?.(this.db?.export()); + await this._saver?.(this.db?.export()); } } - private async _fetchUpdates() { + private _waitUpdate(updates: any[], sync = false) { + if (updates.length && sync) { + return new Promise((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, @@ -159,6 +197,7 @@ export class SQLiteProvider extends Observable { const lastKey = Math.max(...updates.map(([idx]) => idx)); this._ref = lastKey + 1; this._size = countUpdates(this.db); + await wait; } } diff --git a/libs/datasource/jwt/src/adapter/index.ts b/libs/datasource/jwt/src/adapter/index.ts index ddf4f29ca..2fa2ec551 100644 --- a/libs/datasource/jwt/src/adapter/index.ts +++ b/libs/datasource/jwt/src/adapter/index.ts @@ -136,6 +136,7 @@ interface BlockInstance { interface AsyncDatabaseAdapter { inspector(): Record; + reload(): void; createBlock( options: Pick, 'type' | 'flavor'> & { binary?: ArrayBuffer; @@ -156,6 +157,33 @@ interface AsyncDatabaseAdapter { getUserId(): string; } +export type DataExporter = (binary: Uint8Array) => Promise; + +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(resolve => { + importer = () => initialData; + exporter = async (data: Uint8Array) => { + exporter = cb; + await cb(data); + resolve(); + }; + }); + }; + + return { importData, exportData, hasExporter, installExporter }; +}; + export type { AsyncDatabaseAdapter, BlockPosition, diff --git a/libs/datasource/jwt/src/adapter/yjs/index.ts b/libs/datasource/jwt/src/adapter/yjs/index.ts index d815fe473..a1497de60 100644 --- a/libs/datasource/jwt/src/adapter/yjs/index.ts +++ b/libs/datasource/jwt/src/adapter/yjs/index.ts @@ -18,11 +18,7 @@ import { snapshot, } from 'yjs'; -import { - IndexedDBProvider, - SQLiteProvider, - WebsocketProvider, -} from '@toeverything/datasource/jwt-rpc'; +import { IndexedDBProvider } from '@toeverything/datasource/jwt-rpc'; import { AsyncDatabaseAdapter, @@ -31,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'; @@ -43,51 +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: IndexedDBProvider; binariesIdb: IndexedDBProvider; - fstore?: SQLiteProvider; - ws?: WebsocketProvider; - backend: string; gatekeeper: GateKeeper; + connListener: { listeners?: ConnectivityListener }; userId: string; remoteToken?: string; // remote storage token }; const _yjsDatabaseInstance = new Map(); -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(); const _waitLoading = async (workspace: string) => { while (_asyncInitLoading.has(workspace)) { @@ -96,14 +67,11 @@ const _waitLoading = async (workspace: string) => { }; async function _initYjsDatabase( - backend: string, workspace: string, options: { - params: YjsInitOptions['params']; userId: string; token?: string; - importData?: Uint8Array; - exportData?: (binary: Uint8Array) => void; + provider?: Record; } ): Promise { if (_asyncInitLoading.has(workspace)) { @@ -119,28 +87,10 @@ 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 IndexedDBProvider(workspace, doc).whenSynced; - - const fs = new SQLiteProvider(workspace, doc, options.importData); - if (options.exportData) fs.registerExporter(options.exportData); - - const wsp = _initWebsocketProvider( - backend, - workspace, - doc, - remoteToken, - params - ); - - const [idb, [awareness, ws], fstore] = await Promise.all([ - idbp, - wsp, - fs.whenSynced, - ]); + const idb = await new IndexedDBProvider(workspace, doc).whenSynced; const binaries = new Doc({ autoLoad: true, shouldLoad: true }); const binariesIdb = await new IndexedDBProvider( @@ -148,6 +98,8 @@ async function _initYjsDatabase( binaries ).whenSynced; + const awareness = new Awareness(doc); + const gateKeeperData = doc.getMap>('gatekeeper'); const gatekeeper = new GateKeeper( @@ -157,80 +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, - fstore, - ws, - backend, gatekeeper, + connListener, userId, - remoteToken, - }); + remoteToken: token, + }; + + _yjsDatabaseInstance.set(workspace, newInstance); + _asyncInitLoading.delete(workspace); - return { - awareness, - idb, - binariesIdb, - fstore, - 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; userId?: string; token?: string; - importData?: Uint8Array; - exportData?: (binary: Uint8Array) => void; + provider?: Record; }; +export { getYjsProviders } from './provider'; +export type { YjsProviderOptions } from './provider'; + export class YjsAdapter implements AsyncDatabaseAdapter { 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>; - private readonly _blockUpdated: YMap; + private readonly _blocks!: YMap>; + private readonly _blockUpdated!: YMap; // Maximum cache Block 1024, ttl 10 minutes - private readonly _blockCaches: LRUCache; + private readonly _blockCaches!: LRUCache; - private readonly _binaries: YjsRemoteBinaries; + private readonly _binaries!: YjsRemoteBinaries; private readonly _listener: Map>; + private readonly _reload: () => void; + static async init( workspace: string, options: YjsInitOptions ): Promise { - const { - backend, - params = {}, - userId = 'default', - token, - importData, - exportData, - } = options; - const providers = await _initYjsDatabase(backend, workspace, { - params, + const { userId = 'default', token, provider } = options; + const providers = await _initYjsDatabase(workspace, { userId, token, - importData, - exportData, + provider, }); return new YjsAdapter(providers); } @@ -240,33 +186,39 @@ export class YjsAdapter implements AsyncDatabaseAdapter { this._doc = providers.idb.doc; this._awareness = providers.awareness; this._gatekeeper = providers.gatekeeper; - - const blocks = this._doc.getMap>('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>('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( () => { @@ -341,6 +293,10 @@ export class YjsAdapter implements AsyncDatabaseAdapter { }); } + reload() { + this._reload(); + } + getUserId(): string { return this._provider.userId; } diff --git a/libs/datasource/jwt/src/adapter/yjs/listener.ts b/libs/datasource/jwt/src/adapter/yjs/listener.ts index d9b4de123..36c203bbc 100644 --- a/libs/datasource/jwt/src/adapter/yjs/listener.ts +++ b/libs/datasource/jwt/src/adapter/yjs/listener.ts @@ -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); } } } diff --git a/libs/datasource/jwt/src/adapter/yjs/provider.ts b/libs/datasource/jwt/src/adapter/yjs/provider.ts new file mode 100644 index 000000000..f7068288f --- /dev/null +++ b/libs/datasource/jwt/src/adapter/yjs/provider.ts @@ -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; + +export type YjsProviderOptions = { + backend: typeof BucketBackend[keyof typeof BucketBackend]; + params?: Record; + importData?: () => Promise | Uint8Array | undefined; + exportData?: (binary: Uint8Array) => Promise | undefined; + hasExporter?: () => boolean; +}; + +export const getYjsProviders = ( + options: YjsProviderOptions +): Record => { + 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((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; + } + }, + }; +}; diff --git a/libs/datasource/jwt/src/block/abstract.ts b/libs/datasource/jwt/src/block/abstract.ts index 0225aa089..468fd5332 100644 --- a/libs/datasource/jwt/src/block/abstract.ts +++ b/libs/datasource/jwt/src/block/abstract.ts @@ -27,12 +27,13 @@ export class AbstractBlock< C extends ContentOperation > { private readonly _id: string; - readonly #block: BlockInstance; + private readonly _block: BlockInstance; private readonly _history: HistoryManager; private readonly _root?: AbstractBlock; private readonly _parentListener: Map; - _parent?: AbstractBlock; + private _parent?: AbstractBlock; + private _changeParent?: () => void; constructor( block: B, @@ -40,20 +41,14 @@ export class AbstractBlock< parent?: AbstractBlock ) { 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 { - if (this.#block.type === BlockTypes.block) { - return this.#block.content.asMap() as MapOperation; + if (this._block.type === BlockTypes.block) { + return this._block.content.asMap() as MapOperation; } 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()?.get(0); + if (this._block.type === BlockTypes.binary) { + return this._block.content.asArray()?.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) { + private _refreshParent(parent: AbstractBlock) { + 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) { + 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[] { 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() { diff --git a/libs/datasource/jwt/src/index.ts b/libs/datasource/jwt/src/index.ts index 6ced321c2..ecad7aebf 100644 --- a/libs/datasource/jwt/src/index.ts +++ b/libs/datasource/jwt/src/index.ts @@ -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; metadata?: BlockExporters>; tagger?: BlockExporters; + installExporter: ( + initialData: Uint8Array, + exporter: DataExporter + ) => Promise; }; export class BlockClient< @@ -91,10 +101,15 @@ export class BlockClient< private readonly _root: { node?: BaseBlock }; + private readonly _installExporter: ( + initialData: Uint8Array, + exporter: DataExporter + ) => Promise; + 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 = {} + options: Partial< + YjsInitOptions & YjsProviderOptions & BlockClientOptions + > = {} ): Promise { + 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, + }); } } diff --git a/libs/datasource/state/src/user.ts b/libs/datasource/state/src/user.ts index bce11b832..fefb689f0 100644 --- a/libs/datasource/state/src/user.ts +++ b/libs/datasource/state/src/user.ts @@ -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(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']