├── .gitignore ├── LICENSE ├── README.md ├── branding └── cover.png ├── editor ├── .gitignore ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── api │ │ └── hello.ts │ └── index.tsx ├── public │ ├── favicon.ico │ └── vercel.svg ├── styles │ ├── Home.module.css │ └── globals.css └── tsconfig.json ├── package.json ├── packages ├── boring-core-action │ ├── actions.ts │ ├── index.ts │ └── package.json ├── boring-core-config │ ├── index.ts │ └── package.json ├── boring-core-state │ ├── index.ts │ └── package.json ├── boring-core-store │ ├── index.ts │ ├── package.json │ └── store.ts ├── boring-document-model │ ├── __test__ │ │ ├── page-data-flat.mock.json │ │ ├── page-data-nested.mock.json │ │ └── page-data.test.ts │ ├── content.model.ts │ ├── document.model.ts │ ├── index.ts │ ├── package.json │ └── title.model.ts ├── boring-loader │ ├── index.ts │ ├── initial-loader.ts │ └── package.json ├── boring-state-app-react │ ├── index.ts │ └── package.json ├── boring-template-provider │ ├── README.md │ ├── index.ts │ └── package.json ├── boring-ui-core │ └── README.md └── core-react │ ├── blocks │ ├── iframe-block.ts │ ├── index.ts │ ├── media-block.tsx │ ├── toc.tsx │ ├── trailing-node.ts │ ├── video-block.ts │ └── wrapper-block.tsx │ ├── content-editor │ ├── index.ts │ └── main-content-editor.tsx │ ├── drag-handle │ ├── drag-handle.ts │ └── index.ts │ ├── embeding-utils │ ├── index.ts │ ├── vimeo.ts │ └── youtube.ts │ ├── extension-configs │ ├── block-quote-config.ts │ ├── codeblock-config.ts │ ├── floating-menu-conig.ts │ ├── index.ts │ ├── placeholder-config.ts │ ├── slash-command-config.ts │ └── underline-config.ts │ ├── floating-menu │ ├── index.ts │ └── side-floating-menu.tsx │ ├── index.ts │ ├── inline-toolbar │ ├── README.md │ ├── index.ts │ └── inline-toolbar.tsx │ ├── key-maps │ └── index.ts │ ├── menu │ ├── add-block-menu │ │ ├── block-item.tsx │ │ ├── block-list.tsx │ │ ├── icons.tsx │ │ ├── index.tsx │ │ └── items.ts │ ├── add-image-block-menu │ │ ├── README.md │ │ ├── add-image-block-menu.tsx │ │ └── index.ts │ ├── index.ts │ └── link-input-menu.tsx │ ├── node-view-wrapper │ ├── index.ts │ └── node-view-wrapper.tsx │ ├── package.json │ ├── scaffold │ ├── index.tsx │ └── scaffold-extensions.ts │ ├── slash-commands │ ├── README.md │ ├── commands.ts │ ├── index.ts │ └── render-items.tsx │ ├── table-of-contents │ ├── README.md │ └── index.ts │ ├── theme │ ├── font-family.ts │ └── index.ts │ ├── title │ ├── index.ts │ └── title.tsx │ └── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | package-lock.json 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | .DS_Store 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 bridged.xyz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./branding/cover.png) 2 | 3 | # Boring Engine - the boring text editor engine 4 | 5 | 6 | > write your document like you code. write in dedicated environment. manage your text contents. 7 | 8 | 9 | 10 | ## Features 11 | - Table 12 | - md style editing 13 | - text node & api (highlighting) 14 | - variables 15 | - placeholders 16 | - [Bridged's G11n](https://github.com/bridgedxyz/G11n) Support 17 | - text node theme api 18 | - live collaborate compatitable api interface (collaboration is not built-in but provides the availability to just plug-in the live feature) 19 | 20 | 21 | ## Other engine projects 22 | 23 | - [nothing engine](https://github.com/bridgedxyz/nothing) - nothing but drawing engine. 24 | 25 | ## Other Projects 26 | other boring related projects are being managed under [github/boringso](https://github.com/boringso) 27 | 28 | Visit [boring.so](https://boring.so) for more details 29 | 30 | 31 | ## Who uses boring? 32 | - [Bridged Docs](https://github.com/bridgedxyz/docs) - Bridged docs site is built with boring 33 | - [code.surf Docs](https://github.com/surfcodes/website) - github.surf, surf.codes docs site is built with boring 34 | -------------------------------------------------------------------------------- /branding/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridaco/boring/e2bf72cc01ddf1a2b8d311fbf3ba2a9e30236892/branding/cover.png -------------------------------------------------------------------------------- /editor/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /editor/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /editor/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /editor/next.config.js: -------------------------------------------------------------------------------- 1 | const withTM = require("next-transpile-modules")([ 2 | // 3 | "@boringso/react-core", 4 | ]); 5 | 6 | module.exports = withTM({ 7 | webpack: (config) => { 8 | return config; 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@boringso/react-core": "0.0.0", 12 | "@boring-ui/embed": "3.3.4", 13 | "@emotion/react": "^11.4.0", 14 | "@emotion/styled": "^11.3.0", 15 | "next": "11.0.0", 16 | "react": "17.0.2", 17 | "react-dom": "17.0.2" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "17.0.11", 21 | "next-transpile-modules": "^8.0.0", 22 | "typescript": "4.3.3" 23 | } 24 | } -------------------------------------------------------------------------------- /editor/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import Head from "next/head"; 4 | import { Global, css } from "@emotion/react"; 5 | 6 | /** 7 | * Css normalize - reset all default values. 8 | */ 9 | function CssNormalized() { 10 | return ( 11 | 23 | ); 24 | } 25 | 26 | function HeadInjection() { 27 | return ( 28 | 29 | 30 | 31 | 36 | 37 | ); 38 | } 39 | 40 | function SeoMeta() { 41 | return ( 42 | <> 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | function MyApp({ Component, pageProps }: AppProps) { 50 | return ( 51 | <> 52 | 53 | 54 | 55 | ); 56 | } 57 | export default MyApp; 58 | -------------------------------------------------------------------------------- /editor/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default (req: NextApiRequest, res: NextApiResponse) => { 9 | res.status(200).json({ name: 'John Doe' }) 10 | } 11 | -------------------------------------------------------------------------------- /editor/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { Scaffold } from "@boringso/react-core"; 3 | 4 | export default function Home() { 5 | return ( 6 | <> 7 | 8 | Boring 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /editor/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridaco/boring/e2bf72cc01ddf1a2b8d311fbf3ba2a9e30236892/editor/public/favicon.ico -------------------------------------------------------------------------------- /editor/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /editor/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | height: 100vh; 9 | } 10 | 11 | .main { 12 | padding: 5rem 0; 13 | flex: 1; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | .footer { 21 | width: 100%; 22 | height: 100px; 23 | border-top: 1px solid #eaeaea; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | 29 | .footer a { 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | flex-grow: 1; 34 | } 35 | 36 | .title a { 37 | color: #0070f3; 38 | text-decoration: none; 39 | } 40 | 41 | .title a:hover, 42 | .title a:focus, 43 | .title a:active { 44 | text-decoration: underline; 45 | } 46 | 47 | .title { 48 | margin: 0; 49 | line-height: 1.15; 50 | font-size: 4rem; 51 | } 52 | 53 | .title, 54 | .description { 55 | text-align: center; 56 | } 57 | 58 | .description { 59 | line-height: 1.5; 60 | font-size: 1.5rem; 61 | } 62 | 63 | .code { 64 | background: #fafafa; 65 | border-radius: 5px; 66 | padding: 0.75rem; 67 | font-size: 1.1rem; 68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 69 | Bitstream Vera Sans Mono, Courier New, monospace; 70 | } 71 | 72 | .grid { 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | flex-wrap: wrap; 77 | max-width: 800px; 78 | margin-top: 3rem; 79 | } 80 | 81 | .card { 82 | margin: 1rem; 83 | padding: 1.5rem; 84 | text-align: left; 85 | color: inherit; 86 | text-decoration: none; 87 | border: 1px solid #eaeaea; 88 | border-radius: 10px; 89 | transition: color 0.15s ease, border-color 0.15s ease; 90 | width: 45%; 91 | } 92 | 93 | .card:hover, 94 | .card:focus, 95 | .card:active { 96 | color: #0070f3; 97 | border-color: #0070f3; 98 | } 99 | 100 | .card h2 { 101 | margin: 0 0 1rem 0; 102 | font-size: 1.5rem; 103 | } 104 | 105 | .card p { 106 | margin: 0; 107 | font-size: 1.25rem; 108 | line-height: 1.5; 109 | } 110 | 111 | .logo { 112 | height: 1em; 113 | margin-left: 0.5rem; 114 | } 115 | 116 | @media (max-width: 600px) { 117 | .grid { 118 | width: 100%; 119 | flex-direction: column; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /editor/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boring-workspace", 3 | "private": true, 4 | "scripts": { 5 | "editor": "yarn workspace editor dev" 6 | }, 7 | "workspaces": [ 8 | "packages/*", 9 | "editor" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/boring-core-action/actions.ts: -------------------------------------------------------------------------------- 1 | type TextContent = string; 2 | type RawContent = string; 3 | type CodeContent = RawContent; 4 | 5 | type BlockCreationActionType = 6 | | typeof add_any_block 7 | // 8 | | typeof add_code_block 9 | | HeadingBlockCreationActionType 10 | | typeof add_paragraph_block; 11 | 12 | export interface IAddBlockAction { 13 | type: BlockCreationActionType; 14 | content: T; 15 | } 16 | 17 | const add_any_block = "add-any-block"; 18 | export interface AddAnyBlockAction extends IAddBlockAction { 19 | type: typeof add_any_block; 20 | } 21 | 22 | const add_code_block = "add-code-block"; 23 | export interface AddCodeBlock extends IAddBlockAction { 24 | type: typeof add_code_block; 25 | content: CodeContent; 26 | } 27 | 28 | export type HeadingBlockCreationActionType = 29 | | typeof add_heading_block 30 | | typeof add_heading1_block 31 | | typeof add_heading2_block 32 | | typeof add_heading3_block 33 | | typeof add_heading4_block 34 | | typeof add_heading5_block 35 | | typeof add_heading6_block; 36 | 37 | const add_heading_block = "add-heading-block"; 38 | const add_heading1_block = "add-heading1-block"; 39 | const add_heading2_block = "add-heading2-block"; 40 | const add_heading3_block = "add-heading3-block"; 41 | const add_heading4_block = "add-heading4-block"; 42 | const add_heading5_block = "add-heading5-block"; 43 | const add_heading6_block = "add-heading6-block"; 44 | 45 | export interface IAddHeadingBlockAction extends IAddBlockAction { 46 | type: HeadingBlockCreationActionType; 47 | } 48 | export interface AddHeadingBlock extends IAddHeadingBlockAction { 49 | type: typeof add_heading_block; 50 | level: 1 | 2 | 3 | 4 | 5 | 6; 51 | } 52 | export interface AddHeading1Block extends IAddHeadingBlockAction { 53 | type: typeof add_heading1_block; 54 | } 55 | export interface AddHeading2Block extends IAddHeadingBlockAction { 56 | type: typeof add_heading2_block; 57 | } 58 | export interface AddHeading3Block extends IAddHeadingBlockAction { 59 | type: typeof add_heading3_block; 60 | } 61 | export interface AddHeading4Block extends IAddHeadingBlockAction { 62 | type: typeof add_heading4_block; 63 | } 64 | export interface AddHeading5Block extends IAddHeadingBlockAction { 65 | type: typeof add_heading5_block; 66 | } 67 | export interface AddHeading6Block extends IAddHeadingBlockAction { 68 | type: typeof add_heading6_block; 69 | } 70 | 71 | // 72 | 73 | const add_paragraph_block = "add-paragraph-block"; 74 | export interface AddParagraphBlock extends IAddBlockAction { 75 | type: typeof add_paragraph_block; 76 | } 77 | -------------------------------------------------------------------------------- /packages/boring-core-action/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./actions"; 2 | -------------------------------------------------------------------------------- /packages/boring-core-action/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/action", 3 | "version": "0.0.0" 4 | } -------------------------------------------------------------------------------- /packages/boring-core-config/index.ts: -------------------------------------------------------------------------------- 1 | export interface AutosaveOption { 2 | /** 3 | * autosave trigger interval in seconds (not ms) 4 | */ 5 | interval: number | (() => number); 6 | dosave: boolean | (() => boolean) | ((prev, curr) => boolean); 7 | } 8 | 9 | export const defaults = { 10 | contentAutosave: { 11 | interval: 3.5, 12 | dosave: true, 13 | }, 14 | }; 15 | 16 | export interface EditorConfig { 17 | contentAutosave: AutosaveOption; 18 | } 19 | -------------------------------------------------------------------------------- /packages/boring-core-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/config", 3 | "version": "0.0.0" 4 | } -------------------------------------------------------------------------------- /packages/boring-core-state/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridaco/boring/e2bf72cc01ddf1a2b8d311fbf3ba2a9e30236892/packages/boring-core-state/index.ts -------------------------------------------------------------------------------- /packages/boring-core-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/state", 3 | "version": "0.0.0" 4 | } -------------------------------------------------------------------------------- /packages/boring-core-store/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./store"; 2 | -------------------------------------------------------------------------------- /packages/boring-core-store/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/store", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "idb": "^6.1.2" 6 | }, 7 | "devDependencies": { 8 | "@boring.so/document-model": "workspace:^" 9 | }, 10 | "peerDependencies": { 11 | "@boring.so/document-model": "workspace:^" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/boring-core-store/store.ts: -------------------------------------------------------------------------------- 1 | import { openDB, deleteDB, wrap, unwrap, IDBPDatabase } from "idb"; 2 | import { 3 | BoringContent, 4 | BoringDocument, 5 | BoringDocumentId, 6 | } from "@boring.so/document-model"; 7 | 8 | /*no-export*/ const _document_store_db_v = 1; 9 | /*no-export*/ const _document_store_db_n = "documents"; 10 | /*no-export*/ const _boring_documents_store_name = "boring.so/documents"; 11 | /*no-export*/ const boring_document_store_name = (id: string) => 12 | `boring.so/documents/${id}`; 13 | export class BoringDocumentsStore { 14 | private _db: IDBPDatabase; 15 | private readonly tmpstore = new Map(); 16 | async db(): Promise { 17 | if (this._db) { 18 | return this._db; 19 | } 20 | return await this.prewarm(); 21 | } 22 | 23 | constructor() { 24 | this.prewarm(); 25 | } 26 | 27 | async prewarm(): Promise { 28 | if (typeof indexedDB === "undefined") { 29 | return; 30 | } 31 | 32 | this._db = await openDB(_document_store_db_n, _document_store_db_v, { 33 | upgrade(db) { 34 | const store = db.createObjectStore(_boring_documents_store_name, { 35 | keyPath: "id", 36 | }); 37 | }, 38 | }); 39 | return this._db; 40 | } 41 | 42 | async get(id: string): Promise { 43 | try { 44 | return this.tmpstore.has(id) 45 | ? this.tmpstore.get(id) 46 | : await (await this.db()).get(_boring_documents_store_name, id); 47 | } catch (e) {} 48 | } 49 | 50 | async set(doc: BoringDocument) { 51 | try { 52 | this.tmpstore.set(doc.id, doc); 53 | await (await this.db()).add(_boring_documents_store_name, doc); 54 | this.tmpstore.delete(doc.id); 55 | } catch (e) {} 56 | } 57 | 58 | async put(doc: BoringDocument) { 59 | try { 60 | await (await this.db()).put(_boring_documents_store_name, doc); 61 | } catch (e) {} 62 | } 63 | } 64 | 65 | export class BoringDocumentStore { 66 | private readonly unique_storename; 67 | readonly service: BoringDocumentsStore; 68 | constructor(readonly id: string) { 69 | this.unique_storename = boring_document_store_name(id); 70 | this.service = new BoringDocumentsStore(); 71 | } 72 | 73 | async get() { 74 | const doc = await this.service.get(this.id); 75 | return doc; 76 | } 77 | 78 | async put(doc: BoringDocument, id?: BoringDocumentId) { 79 | if (!doc.id && id) { 80 | doc.id = id; 81 | } 82 | return this.service.put(doc); 83 | } 84 | 85 | async updateContent(content: string) { 86 | try { 87 | const beforeupdate = await this.get(); 88 | beforeupdate.content = new BoringContent(content); 89 | /*no-await (no need to await)*/ this.put(beforeupdate); 90 | return beforeupdate; 91 | } catch (e) {} 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/boring-document-model/__test__/page-data-flat.mock.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "title": "Page name", 5 | "description": "Page description", 6 | "icon": "heart" 7 | } 8 | ] -------------------------------------------------------------------------------- /packages/boring-document-model/__test__/page-data-nested.mock.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridaco/boring/e2bf72cc01ddf1a2b8d311fbf3ba2a9e30236892/packages/boring-document-model/__test__/page-data-nested.mock.json -------------------------------------------------------------------------------- /packages/boring-document-model/__test__/page-data.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridaco/boring/e2bf72cc01ddf1a2b8d311fbf3ba2a9e30236892/packages/boring-document-model/__test__/page-data.test.ts -------------------------------------------------------------------------------- /packages/boring-document-model/content.model.ts: -------------------------------------------------------------------------------- 1 | export class BoringContent { 2 | constructor(raw: string) { 3 | this.raw = raw; 4 | } 5 | raw: string; 6 | } 7 | 8 | export type BoringContentLike = BoringContent | string; 9 | 10 | export function boringContentLikeAsBoringContent( 11 | p: BoringContentLike 12 | ): BoringContent { 13 | if (p instanceof BoringContent) { 14 | return p; 15 | } else if (typeof p == "string") { 16 | return new BoringContent(p); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/boring-document-model/document.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BoringTitle, 3 | BoringTitleLike, 4 | boringTitleLikeAsBoringTitle, 5 | } from "./title.model"; 6 | import { 7 | BoringContent, 8 | BoringContentLike, 9 | boringContentLikeAsBoringContent, 10 | } from "./content.model"; 11 | import { nanoid } from "nanoid"; 12 | export interface BoringDocumentTemplateConstraint { 13 | /** 14 | * id of the template 15 | * **/ 16 | id: string; 17 | constraint: "unconstrained" | "constrained"; 18 | } 19 | 20 | export type BoringDocumentId = string; 21 | 22 | export function autoid(): string { 23 | return nanoid(); 24 | } 25 | export class BoringDocument { 26 | id: BoringDocumentId; 27 | title: BoringTitle; 28 | content: BoringContent; 29 | template?: BoringDocumentTemplateConstraint; 30 | 31 | constructor({ 32 | title, 33 | content, 34 | id = autoid(), 35 | }: { 36 | id?: BoringDocumentId; 37 | title?: BoringTitleLike; 38 | content: BoringContentLike; 39 | }) { 40 | (this.title = boringTitleLikeAsBoringTitle(title)), 41 | (this.content = boringContentLikeAsBoringContent(content)), 42 | (this.id = id); 43 | } 44 | } 45 | 46 | export class EmptyDocument extends BoringDocument { 47 | constructor() { 48 | super({ 49 | title: new BoringTitle(""), 50 | content: new BoringContent(""), 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/boring-document-model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./content.model"; 2 | export * from "./document.model"; 3 | export * from "./title.model"; 4 | -------------------------------------------------------------------------------- /packages/boring-document-model/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/document-model", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "nanoid": "^3.1.23" 6 | } 7 | } -------------------------------------------------------------------------------- /packages/boring-document-model/title.model.ts: -------------------------------------------------------------------------------- 1 | type BoringTitleIcon = string; 2 | 3 | // === BoringTitleLike === 4 | export type BoringTitleLike = string | PageIconAndName | BoringTitle; 5 | 6 | export function boringTitleLikeAsBoringTitle(tl: BoringTitleLike): BoringTitle { 7 | if (!tl) { 8 | return; 9 | } 10 | if (tl instanceof BoringTitle) { 11 | return tl; 12 | } 13 | 14 | return new BoringTitle(tl); // since accepts both `string | PageIconAndName` as initializer 15 | } 16 | // === BoringTitleLike === 17 | 18 | // === PageIconAndName === 19 | export interface PageIconAndName { 20 | icon?: BoringTitleIcon; 21 | name: string; 22 | } 23 | 24 | export function isPageIconAndName(p?: PageIconAndName | any): boolean { 25 | if (p) { 26 | const _maybe = p as PageIconAndName; 27 | return _maybe?.name !== undefined; 28 | } 29 | return false; 30 | } 31 | // === PageIconAndName === 32 | 33 | export interface BoringTitle extends PageIconAndName { 34 | raw: string; 35 | icon?: BoringTitleIcon; 36 | name: string; 37 | } 38 | 39 | export class BoringTitle implements BoringTitle { 40 | constructor(p: PageIconAndName | string) { 41 | switch (typeof p) { 42 | case "string": { 43 | const _p = parseraw(p); 44 | this.icon = _p.icon; 45 | this.name = _p.name; 46 | this.raw = p; 47 | break; 48 | } 49 | case "undefined": { 50 | this.name = ""; 51 | this.raw = ""; 52 | break; 53 | } 54 | case "object": { 55 | this.icon = p.icon; 56 | this.name = p.name; 57 | this.raw = buildraw(p); 58 | break; 59 | } 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * icon | name single string separator 66 | * 67 | * DO NOT CHANGE THE VALUE 68 | * 69 | * e.g. - `{ icon: ":smile", name: "Title" }` -> `":smile|Title"` 70 | */ 71 | const _separator = "|"; 72 | /** 73 | * 1. `{ name: `"|Title"` }` -> `"|Title"` 74 | * 2. `{ icon: ":smile", name: "Title" }` -> `":smile|Title"` 75 | * 3. `{ name: "Title" }` -> `"Title"` 76 | */ 77 | function buildraw(p: PageIconAndName): string { 78 | return (p.icon ? p.icon + _separator : "") + p.name; 79 | } 80 | 81 | /** 82 | * 1. `"|Title"` -> `{ name: `"|Title"` }` 83 | * 2. `":smile|Title"` -> `{ icon: ":smile", name: "Title" }` 84 | * 3. `"Title"` -> `{ name: "Title" }` 85 | * @param raw 86 | * @returns 87 | */ 88 | function parseraw(raw: string): PageIconAndName { 89 | let _icon: BoringTitleIcon; 90 | let _name: string; 91 | if (raw.includes(_separator)) { 92 | if (raw.startsWith(_separator)) { 93 | _name = raw; 94 | } else { 95 | const _splts = raw.split(_separator); 96 | _icon = _splts[0]; 97 | _name = _splts[1]; 98 | } 99 | } else { 100 | _name = raw; 101 | } 102 | return { 103 | icon: _icon, 104 | name: _name, 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /packages/boring-loader/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./initial-loader"; 2 | -------------------------------------------------------------------------------- /packages/boring-loader/initial-loader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BoringContent, 3 | BoringContentLike, 4 | boringContentLikeAsBoringContent, 5 | BoringDocument, 6 | BoringTitleLike, 7 | EmptyDocument, 8 | } from "@boring.so/document-model"; 9 | export class DocumentInitial { 10 | title: BoringTitleLike; 11 | content: BoringContent; 12 | 13 | constructor({ 14 | title, 15 | content, 16 | }: { 17 | title: BoringTitleLike; 18 | content: BoringContentLike; 19 | }) { 20 | this.title = title; 21 | this.content = boringContentLikeAsBoringContent(content); 22 | } 23 | } 24 | 25 | export class StaticDocumentInitial extends DocumentInitial { 26 | constructor({ 27 | title, 28 | content, 29 | }: { 30 | title: BoringTitleLike; 31 | content: BoringContentLike; 32 | }) { 33 | super({ 34 | title: title, 35 | content: content, 36 | }); 37 | } 38 | } 39 | 40 | export class TemplateInitial extends DocumentInitial { 41 | template: string | (() => string); 42 | props: Map; 43 | } 44 | 45 | export function initialize(initial?: DocumentInitial): BoringDocument { 46 | if (initial) { 47 | if (initial instanceof StaticDocumentInitial) { 48 | } 49 | if (initial instanceof TemplateInitial) { 50 | } 51 | /** 52 | * EXECUTION ORDER MATTERS - DocumentInitial is a parent class, needs to be placed at last. 53 | */ 54 | if (initial instanceof DocumentInitial) { 55 | } 56 | } 57 | return new EmptyDocument(); 58 | } 59 | -------------------------------------------------------------------------------- /packages/boring-loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/loader", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@boring.so/document-model": "workspace:*" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/boring-state-app-react/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gridaco/boring/e2bf72cc01ddf1a2b8d311fbf3ba2a9e30236892/packages/boring-state-app-react/index.ts -------------------------------------------------------------------------------- /packages/boring-state-app-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/react-state", 3 | "version": "0.0.0" 4 | } -------------------------------------------------------------------------------- /packages/boring-template-provider/README.md: -------------------------------------------------------------------------------- 1 | # Boring template provider 2 | 3 | uses handlebar for simple template rendering -------------------------------------------------------------------------------- /packages/boring-template-provider/index.ts: -------------------------------------------------------------------------------- 1 | import { BoringContent, BoringTitleLike } from "@boring.so/document-model"; 2 | import { DocumentInitial, StaticDocumentInitial } from "@boring.so/loader"; 3 | import Handlebars from "handlebars"; 4 | 5 | export class TemplateProvider { 6 | register() {} // todo 7 | } 8 | 9 | export interface ITemplateSource { 10 | default?: string; 11 | template: string; 12 | } 13 | 14 | export interface ITemplatePropSource { 15 | default?: T; 16 | props: T; 17 | } 18 | 19 | export class UnconstrainedTemplate extends DocumentInitial { 20 | title: BoringTitleLike; 21 | content: BoringContent; 22 | templateTitleSource: ITemplateSource; 23 | templateContentSource: ITemplateSource; 24 | templateProps: ITemplatePropSource; 25 | 26 | constructor({ 27 | templateTitleSource, 28 | templateContentSource, 29 | templateProps, 30 | }: { 31 | templateTitleSource: ITemplateSource; 32 | templateContentSource: ITemplateSource; 33 | templateProps: ITemplatePropSource; 34 | }) { 35 | super({ 36 | title: templateTitleSource.default, 37 | content: templateContentSource.default, 38 | }); 39 | 40 | this.templateTitleSource = templateTitleSource; 41 | this.templateContentSource = templateContentSource; 42 | this.templateProps = templateProps; 43 | } 44 | 45 | /** 46 | * render template with props 47 | */ 48 | render(): StaticDocumentInitial { 49 | const _title_c = Handlebars.compile(this.templateTitleSource.template); 50 | const _content_c = Handlebars.compile(this.templateContentSource.template); 51 | const _title = _title_c(this.templateProps.props); 52 | const _content = _content_c(this.templateProps.props); 53 | return new StaticDocumentInitial({ 54 | title: _title, 55 | content: _content, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/boring-template-provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boring.so/template-provider", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@boring.so/document-model": "workspace:*", 6 | "handlebars": "^4.7.7" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/boring-ui-core/README.md: -------------------------------------------------------------------------------- 1 | # boring endgine core ui 2 | -------------------------------------------------------------------------------- /packages/core-react/blocks/iframe-block.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "@tiptap/core"; 2 | 3 | export interface IframeOptions { 4 | allowFullscreen: boolean; 5 | HTMLAttributes: { 6 | [key: string]: any; 7 | }; 8 | } 9 | 10 | declare module "@tiptap/core" { 11 | interface Commands { 12 | iframe: { 13 | /** 14 | * Add an iframe 15 | */ 16 | setIframe: (options: { src: string }) => ReturnType; 17 | }; 18 | } 19 | } 20 | 21 | const Iframe = Node.create({ 22 | name: "iframe", 23 | 24 | group: "block", 25 | 26 | atom: true, 27 | 28 | addOptions() { 29 | return { 30 | allowFullscreen: true, 31 | HTMLAttributes: { 32 | class: "iframe-wrapper", 33 | }, 34 | }; 35 | }, 36 | 37 | addAttributes() { 38 | return { 39 | src: { 40 | default: null, 41 | }, 42 | frameborder: { 43 | default: 0, 44 | }, 45 | allowfullscreen: { 46 | default: this.options.allowFullscreen, 47 | parseHTML: () => this.options.allowFullscreen, 48 | }, 49 | }; 50 | }, 51 | 52 | parseHTML() { 53 | return [ 54 | { 55 | tag: "iframe", 56 | }, 57 | ]; 58 | }, 59 | 60 | renderHTML({ HTMLAttributes }) { 61 | return ["div", this.options.HTMLAttributes, ["iframe", HTMLAttributes]]; 62 | }, 63 | 64 | addCommands() { 65 | return { 66 | setIframe: 67 | (options: { src: string }) => 68 | ({ tr, dispatch }) => { 69 | const { selection } = tr; 70 | const node = this.type.create(options); 71 | 72 | if (dispatch) { 73 | tr.replaceRangeWith( 74 | selection.from, 75 | selection.to, 76 | // @ts-ignore 77 | node 78 | ); 79 | } 80 | 81 | return true; 82 | }, 83 | }; 84 | }, 85 | }); 86 | 87 | export default Iframe; 88 | -------------------------------------------------------------------------------- /packages/core-react/blocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./wrapper-block"; 2 | export * from "./media-block"; 3 | export * from "./trailing-node"; 4 | -------------------------------------------------------------------------------- /packages/core-react/blocks/media-block.tsx: -------------------------------------------------------------------------------- 1 | import Embed from "@boring-ui/embed"; 2 | export function MediaComponent() { 3 | return ; 4 | } 5 | -------------------------------------------------------------------------------- /packages/core-react/blocks/toc.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react"; 2 | import css from "@emotion/css"; 3 | import { NodeViewWrapper } from "@tiptap/react"; 4 | import { Node, mergeAttributes, Editor } from "@tiptap/core"; 5 | import { ReactNodeViewRenderer } from "@tiptap/react"; 6 | import { getHeadingsFrom } from "../table-of-contents"; 7 | 8 | export const TableOfContents = Node.create({ 9 | name: "table-of-contents", 10 | 11 | group: "block", 12 | 13 | atom: true, 14 | 15 | parseHTML() { 16 | return [ 17 | { 18 | tag: "toc", 19 | }, 20 | ]; 21 | }, 22 | 23 | renderHTML({ HTMLAttributes }) { 24 | return ["toc", mergeAttributes(HTMLAttributes)]; 25 | }, 26 | 27 | addNodeView() { 28 | return ReactNodeViewRenderer(TocNode); 29 | }, 30 | 31 | addGlobalAttributes() { 32 | return [ 33 | { 34 | types: ["heading"], 35 | attributes: { 36 | id: { 37 | default: null, 38 | }, 39 | }, 40 | }, 41 | ]; 42 | }, 43 | }); 44 | 45 | const TocNode = ({ editor }: { editor: Editor }) => { 46 | const [items, setItems] = useState([]); 47 | 48 | const handleUpdate = useCallback(() => { 49 | setItems(getHeadingsFrom(editor)); 50 | }, [editor]); 51 | 52 | useEffect(handleUpdate, []); 53 | 54 | useEffect(() => { 55 | if (!editor) { 56 | return null; 57 | } 58 | 59 | editor.on("update", handleUpdate); 60 | 61 | return () => { 62 | editor.off("update", handleUpdate); 63 | }; 64 | }, [editor]); 65 | 66 | return ( 67 | 68 |
    69 | {items.map((item, index) => ( 70 |
  • 71 | {item.text} 72 |
  • 73 | ))} 74 |
75 |
76 | ); 77 | }; 78 | 79 | const style = css` 80 | background: rgba(black, 0.1); 81 | border-radius: 0.5rem; 82 | opacity: 0.75; 83 | padding: 0.75rem; 84 | 85 | list { 86 | list-style: none; 87 | padding: 0; 88 | 89 | &::before { 90 | content: "Table of Contents"; 91 | display: block; 92 | font-size: 0.75rem; 93 | font-weight: 700; 94 | letter-spacing: 0.025rem; 95 | opacity: 0.5; 96 | text-transform: uppercase; 97 | } 98 | } 99 | 100 | &item { 101 | a:hover { 102 | opacity: 0.5; 103 | } 104 | 105 | &--3 { 106 | padding-left: 1rem; 107 | } 108 | 109 | &--4 { 110 | padding-left: 2rem; 111 | } 112 | 113 | &--5 { 114 | padding-left: 3rem; 115 | } 116 | 117 | &--6 { 118 | padding-left: 4rem; 119 | } 120 | } 121 | `; 122 | -------------------------------------------------------------------------------- /packages/core-react/blocks/trailing-node.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import { PluginKey, Plugin } from "prosemirror-state"; 3 | 4 | // @ts-ignore 5 | function nodeEqualsType({ types, node }) { 6 | return ( 7 | (Array.isArray(types) && types.includes(node.type)) || node.type === types 8 | ); 9 | } 10 | 11 | /** 12 | * Extension based on: 13 | * - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js 14 | * - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts 15 | */ 16 | 17 | export interface TrailingNodeOptions { 18 | node: string; 19 | notAfter: string[]; 20 | } 21 | 22 | const TrailingNode = Extension.create({ 23 | name: "trailingNode", 24 | 25 | addOptions() { 26 | return { 27 | node: "paragraph", 28 | notAfter: ["paragraph"], 29 | }; 30 | }, 31 | 32 | addProseMirrorPlugins() { 33 | const plugin = new PluginKey(this.name); 34 | const disabledNodes = Object.entries(this.editor.schema.nodes) 35 | .map(([, value]) => value) 36 | .filter((node) => this.options.notAfter.includes((node as any).name)); 37 | 38 | return [ 39 | new Plugin({ 40 | key: plugin, 41 | appendTransaction: (_, __, state) => { 42 | const { doc, tr, schema } = state; 43 | const shouldInsertNodeAtEnd = plugin.getState(state); 44 | const endPosition = doc.content.size; 45 | const type = schema.nodes[this.options.node]; 46 | 47 | if (!shouldInsertNodeAtEnd) { 48 | return; 49 | } 50 | 51 | return tr.insert(endPosition, type.create()); 52 | }, 53 | state: { 54 | init: (_, state) => { 55 | const lastNode = state.tr.doc.lastChild; 56 | 57 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }); 58 | }, 59 | apply: (tr, value) => { 60 | if (!tr.docChanged) { 61 | return value; 62 | } 63 | 64 | const lastNode = tr.doc.lastChild; 65 | 66 | return !nodeEqualsType({ node: lastNode, types: disabledNodes }); 67 | }, 68 | }, 69 | }), 70 | ]; 71 | }, 72 | }); 73 | 74 | export default TrailingNode; 75 | -------------------------------------------------------------------------------- /packages/core-react/blocks/video-block.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from "@tiptap/core"; 2 | 3 | const Video = Node.create({ 4 | name: "video", // unique name for the Node 5 | group: "block", // belongs to the 'block' group of extensions 6 | selectable: true, // so we can select the video 7 | draggable: true, // so we can drag the video 8 | atom: true, // is a single unit 9 | 10 | addAttributes() { 11 | return { 12 | src: { 13 | default: null, 14 | }, 15 | controls: { 16 | default: true, 17 | }, 18 | }; 19 | }, 20 | 21 | parseHTML() { 22 | return [ 23 | { 24 | tag: "video", 25 | }, 26 | ]; 27 | }, 28 | 29 | renderHTML({ HTMLAttributes }) { 30 | return ["video", mergeAttributes(HTMLAttributes)]; 31 | }, 32 | }); 33 | 34 | export default Video; 35 | -------------------------------------------------------------------------------- /packages/core-react/blocks/wrapper-block.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | export function WrapperBlock(props: { children?: JSX.Element }) { 5 | const [hover, setHover] = useState(false); 6 | const starthover = () => { 7 | setHover(true); 8 | }; 9 | const endhover = () => { 10 | setHover(false); 11 | }; 12 | return ( 13 |
14 | 15 | 16 | 17 | 18 | {props.children} 19 |
20 | ); 21 | } 22 | 23 | const HandleContainer = styled.div` 24 | width: 80px; 25 | height: 20px; 26 | padding-right: 12px; 27 | position: absolute; 28 | left: 280px; 29 | `; 30 | -------------------------------------------------------------------------------- /packages/core-react/content-editor/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./main-content-editor"; 2 | -------------------------------------------------------------------------------- /packages/core-react/content-editor/main-content-editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Node } from "@tiptap/core"; 4 | import { Editor, useEditor, EditorContent } from "@tiptap/react"; 5 | 6 | // region block components 7 | 8 | // endregion block components 9 | import { DEFAULT_THEME_FONT_FAMILY } from "../theme"; 10 | import { InlineToolbar } from "../inline-toolbar"; 11 | import { SideFloatingMenu } from "../floating-menu"; 12 | 13 | interface MainBodyContentEditorProps { 14 | editor: Editor | null; 15 | 16 | /** 17 | * initial height of interactive area. defaults to 200px. 18 | */ 19 | initialHeight?: string; 20 | 21 | readonly: boolean; 22 | 23 | onUploadFile: (file: File) => Promise; 24 | } 25 | 26 | export function MainBodyContentEditor({ 27 | initialHeight, 28 | editor, 29 | readonly, 30 | onUploadFile, 31 | }: MainBodyContentEditorProps) { 32 | const focus = () => { 33 | editor?.chain().focus().run(); 34 | }; 35 | 36 | /** 37 | * this is for focusing to content editor when padding safe area is clicked. (usually bottom of the editor) 38 | */ 39 | const onTouchAreaClick = () => { 40 | focus(); 41 | }; 42 | 43 | return ( 44 | 45 | {/* */} 46 | 47 | {/* */} 48 | {editor && ( 49 | 50 | )} 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | const TouchArea = styled.div<{ 59 | initialHeight?: string; 60 | }>` 61 | cursor: text; 62 | width: 100%; 63 | min-height: ${(p) => p.initialHeight ?? "200px"}; 64 | padding-bottom: 120px; 65 | `; 66 | 67 | const MainContentEditorRootWrapper = styled.div` 68 | /* disable outline for contenteditable */ 69 | [contenteditable] { 70 | outline: 0px solid transparent; 71 | } 72 | 73 | min-height: 400px; 74 | `; 75 | 76 | const EditorContentInstance = styled(EditorContent)` 77 | /* font */ 78 | .ProseMirror { 79 | h1, 80 | h2, 81 | h3, 82 | h4, 83 | h5, 84 | h6, 85 | p { 86 | color: rgba(0, 0, 0, 0.87); 87 | font-family: ${DEFAULT_THEME_FONT_FAMILY}; 88 | line-height: 110%; 89 | } 90 | 91 | h1, 92 | h2, 93 | h3 { 94 | line-height: 180%; 95 | } 96 | 97 | h1 { 98 | font-size: 30px; 99 | } 100 | 101 | h2 { 102 | font-size: 24px; 103 | } 104 | 105 | h3 { 106 | font-size: 20px; 107 | } 108 | 109 | /* p only */ 110 | p { 111 | font-size: 16px; 112 | line-height: 150%; 113 | } 114 | 115 | img { 116 | border-radius: 2px; 117 | width: 100%; 118 | height: auto; 119 | display: block; 120 | margin-left: auto; 121 | margin-right: auto; 122 | } 123 | 124 | video { 125 | width: 100%; 126 | height: auto; 127 | display: block; 128 | margin-left: auto; 129 | margin-right: auto; 130 | } 131 | 132 | img, 133 | video { 134 | margin-bottom: 4px; 135 | &.ProseMirror-selectednode { 136 | outline: 2px solid rgba(0, 0, 0, 0.2); 137 | cursor: grab; 138 | } 139 | background-color: rgba(0, 0, 0, 0.2); 140 | } 141 | 142 | iframe { 143 | width: 100%; 144 | height: 400px; 145 | } 146 | 147 | hr { 148 | display: flex; 149 | cursor: default; 150 | height: 13px; 151 | outline: none; 152 | margin: 0; 153 | padding: 0; 154 | border: none; 155 | align-items: center; 156 | :after { 157 | display: inline-block; 158 | content: ""; 159 | width: 100%; 160 | height: 1px; 161 | border-bottom: solid 1px rgba(0, 0, 0, 0.12); 162 | } 163 | &.ProseMirror-selectednode { 164 | :after { 165 | border-bottom: solid 1px rgba(0, 0, 0, 0.3); 166 | } 167 | } 168 | } 169 | } 170 | 171 | /* placeholder's style - https://www.tiptap.dev/api/extensions/placeholder/#placeholder*/ 172 | .ProseMirror p.is-editor-empty:first-of-type::before { 173 | content: attr(data-placeholder); 174 | float: left; 175 | color: rgba(0, 0, 0, 0.3); 176 | pointer-events: none; 177 | height: 0; 178 | } 179 | 180 | /* ================================================================ */ 181 | /* region placeholder */ 182 | /* Placeholder (only at the top) */ 183 | .ProseMirror .is-editor-empty:first-of-type::before { 184 | content: attr(data-placeholder); 185 | float: left; 186 | color: rgba(0, 0, 0, 0.3); 187 | pointer-events: none; 188 | height: 0; 189 | } 190 | 191 | /* Placeholder (on every new line) */ 192 | .ProseMirror .is-empty::before { 193 | content: attr(data-placeholder); 194 | float: left; 195 | color: rgba(0, 0, 0, 0.3); 196 | pointer-events: none; 197 | height: 0; 198 | } 199 | /* endregion placeholder */ 200 | /* ================================================================ */ 201 | 202 | .ProseMirror { 203 | /* ================================================================ */ 204 | /* codeblock - https://www.tiptap.dev/api/nodes/code-block-lowlight */ 205 | /* */ 206 | pre { 207 | background: #5c5c5c; 208 | color: #fff; 209 | font-family: "JetBrainsMono", monospace; 210 | padding: 0.75rem 1rem; 211 | border-radius: 0.5rem; 212 | 213 | code { 214 | color: inherit; 215 | padding: 0; 216 | background: none; 217 | font-size: 0.8rem; 218 | } 219 | 220 | .hljs-comment, 221 | .hljs-quote { 222 | color: #616161; 223 | } 224 | 225 | .hljs-variable, 226 | .hljs-template-variable, 227 | .hljs-attribute, 228 | .hljs-tag, 229 | .hljs-name, 230 | .hljs-regexp, 231 | .hljs-link, 232 | .hljs-name, 233 | .hljs-selector-id, 234 | .hljs-selector-class { 235 | color: #f98181; 236 | } 237 | 238 | .hljs-number, 239 | .hljs-meta, 240 | .hljs-built_in, 241 | .hljs-builtin-name, 242 | .hljs-literal, 243 | .hljs-type, 244 | .hljs-params { 245 | color: #fbbc88; 246 | } 247 | 248 | .hljs-string, 249 | .hljs-symbol, 250 | .hljs-bullet { 251 | color: #b9f18d; 252 | } 253 | 254 | .hljs-title, 255 | .hljs-section { 256 | color: #faf594; 257 | } 258 | 259 | .hljs-keyword, 260 | .hljs-selector-tag { 261 | color: #70cff8; 262 | } 263 | 264 | .hljs-emphasis { 265 | font-style: italic; 266 | } 267 | 268 | .hljs-strong { 269 | font-weight: 700; 270 | } 271 | } 272 | /* ================================================================ */ 273 | /* ================================================================ */ 274 | 275 | /* ================================================================ */ 276 | /* blockquote - https://www.tiptap.dev/api/nodes/blockquote */ 277 | /* FIXME - not border-left working */ 278 | blockquote { 279 | padding-left: 1rem; 280 | border-left: 2px solid #b0b0b0; 281 | } 282 | /* ================================================================ */ 283 | /* ================================================================ */ 284 | } 285 | 286 | /* Give a remote user a caret */ 287 | .collaboration-cursor__caret { 288 | border-left: 1px solid #0d0d0d; 289 | border-right: 1px solid #0d0d0d; 290 | margin-left: -1px; 291 | margin-right: -1px; 292 | pointer-events: none; 293 | position: relative; 294 | word-break: normal; 295 | } 296 | 297 | /* Render the username above the caret */ 298 | .collaboration-cursor__label { 299 | border-radius: 3px 3px 3px 0; 300 | color: #0d0d0d; 301 | font-size: 12px; 302 | font-style: normal; 303 | font-weight: 600; 304 | left: -1px; 305 | line-height: normal; 306 | padding: 0.1rem 0.3rem; 307 | position: absolute; 308 | top: -1.4em; 309 | user-select: none; 310 | white-space: nowrap; 311 | } 312 | `; 313 | -------------------------------------------------------------------------------- /packages/core-react/drag-handle/drag-handle.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// forked from https://www.tiptap.dev/experiments/global-drag-handle/#globaldraghandle 3 | /// 4 | 5 | import { Extension } from "@tiptap/core"; 6 | import { NodeSelection, Plugin } from "prosemirror-state"; 7 | import { serializeForClipboard } from "prosemirror-view/src/clipboard"; 8 | 9 | function removeNode(node) { 10 | node.parentNode.removeChild(node); 11 | } 12 | 13 | function absoluteRect(node) { 14 | const data = node.getBoundingClientRect(); 15 | 16 | return { 17 | top: data.top, 18 | left: data.left, 19 | width: data.width, 20 | }; 21 | } 22 | 23 | export default Extension.create({ 24 | addProseMirrorPlugins() { 25 | function blockPosAtCoords(coords, view) { 26 | const pos = view.posAtCoords(coords); 27 | let node = view.domAtPos(pos.pos); 28 | 29 | node = node.node; 30 | 31 | while (node && node.parentNode) { 32 | if (node.parentNode?.classList?.contains("ProseMirror")) { 33 | // todo 34 | break; 35 | } 36 | 37 | node = node.parentNode; 38 | } 39 | 40 | if (node && node.nodeType === 1) { 41 | const desc = view.docView.nearestDesc(node, true); 42 | 43 | if (!(!desc || desc === view.docView)) { 44 | return desc.posBefore; 45 | } 46 | } 47 | return null; 48 | } 49 | 50 | function dragStart(e, view) { 51 | view.composing = true; 52 | 53 | if (!e.dataTransfer) { 54 | return; 55 | } 56 | 57 | const coords = { left: e.clientX + 50, top: e.clientY }; 58 | const pos = blockPosAtCoords(coords, view); 59 | 60 | if (pos != null) { 61 | view.dispatch( 62 | view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)) 63 | ); 64 | 65 | const slice = view.state.selection.content(); 66 | 67 | // console.log({ 68 | // from: view.nodeDOM(view.state.selection.from), 69 | // to: view.nodeDOM(view.state.selection.to), 70 | // }) 71 | const { dom, text } = serializeForClipboard(view, slice); 72 | 73 | e.dataTransfer.clearData(); 74 | e.dataTransfer.setData("text/html", dom.innerHTML); 75 | e.dataTransfer.setData("text/plain", text); 76 | 77 | const el = document.querySelector(".ProseMirror-selectednode"); 78 | e.dataTransfer?.setDragImage(el, 0, 0); 79 | 80 | view.dragging = { slice, move: true }; 81 | } 82 | } 83 | 84 | let dropElement; 85 | const WIDTH = 28; 86 | 87 | return [ 88 | new Plugin({ 89 | view(editorView) { 90 | const element = document.createElement("div"); 91 | element.draggable = true; 92 | element.classList.add("global-drag-handle"); 93 | element.addEventListener("dragstart", (e) => 94 | dragStart(e, editorView) 95 | ); 96 | dropElement = element; 97 | document.body.appendChild(dropElement); 98 | 99 | return { 100 | // update(view, prevState) { 101 | // }, 102 | destroy() { 103 | removeNode(dropElement); 104 | dropElement = null; 105 | }, 106 | }; 107 | }, 108 | props: { 109 | handleDrop(view, event, slice, moved): boolean { 110 | if (moved) { 111 | // setTimeout(() => { 112 | // console.log('remove selection') 113 | // view.dispatch(view.state.tr.deleteSelection()) 114 | // }, 50) 115 | return true; 116 | } 117 | return false; 118 | }, 119 | // handlePaste() { 120 | // alert(2) 121 | // }, 122 | handleDOMEvents: { 123 | // drop(view, event) { 124 | // setTimeout(() => { 125 | // const node = document.querySelector('.ProseMirror-hideselection') 126 | // if (node) { 127 | // node.classList.remove('ProseMirror-hideselection') 128 | // } 129 | // }, 50) 130 | // }, 131 | mousemove(view, event): boolean { 132 | const coords = { 133 | left: event.clientX + WIDTH + 50, 134 | top: event.clientY, 135 | }; 136 | const pos = view.posAtCoords(coords); 137 | 138 | if (pos) { 139 | let node = view.domAtPos(pos?.pos); 140 | 141 | if (node) { 142 | let nnode: HTMLElement = node.node as HTMLElement; 143 | while (nnode && nnode.parentNode) { 144 | if ( 145 | (nnode.parentNode as HTMLElement)?.classList?.contains( 146 | "ProseMirror" 147 | ) 148 | ) { 149 | // todo 150 | break; 151 | } 152 | nnode = nnode.parentNode as HTMLElement; 153 | } 154 | 155 | if (node instanceof Element) { 156 | const cstyle = window.getComputedStyle(node); 157 | const lineHeight = parseInt(cstyle.lineHeight, 10); 158 | // const top = parseInt(cstyle.marginTop, 10) + parseInt(cstyle.paddingTop, 10) 159 | const top = 0; 160 | const rect = absoluteRect(node); 161 | const win = node.ownerDocument.defaultView; 162 | 163 | rect.top += win.pageYOffset + (lineHeight - 24) / 2 + top; 164 | rect.left += win.pageXOffset; 165 | rect.width = `${WIDTH}px`; 166 | 167 | dropElement.style.left = `${-WIDTH + rect.left}px`; 168 | dropElement.style.top = `${rect.top}px`; 169 | return true; 170 | } 171 | } 172 | } 173 | return false; 174 | }, 175 | }, 176 | }, 177 | }), 178 | ]; 179 | }, 180 | }); 181 | -------------------------------------------------------------------------------- /packages/core-react/drag-handle/index.ts: -------------------------------------------------------------------------------- 1 | export { default as default } from "./drag-handle"; 2 | -------------------------------------------------------------------------------- /packages/core-react/embeding-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./youtube"; 2 | export * from "./vimeo"; 3 | -------------------------------------------------------------------------------- /packages/core-react/embeding-utils/vimeo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * parse id from video url 3 | * 4 | * e.g. - from "https://vimeo.com/697387375" 5 | * @param url 6 | * @returns 7 | */ 8 | export function get_vimeo_video_id(url: string) { 9 | const regExp = 10 | /^.*(vimeo\.com\/)((channels\/[A-z]+\/)|(groups\/[A-z]+\/videos\/))?([0-9]+)/; 11 | const match = url.match(regExp); 12 | if (match) { 13 | return match[5]; 14 | } 15 | } 16 | 17 | export function make_vimeo_video_embed_url(id: string) { 18 | return `https://player.vimeo.com/video/${id}`; 19 | } 20 | -------------------------------------------------------------------------------- /packages/core-react/embeding-utils/youtube.ts: -------------------------------------------------------------------------------- 1 | export function get_youtube_video_id(url: string) { 2 | const regExp = 3 | /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/; 4 | const match = url.match(regExp); 5 | return match && match[7].length === 11 ? match[7] : false; 6 | } 7 | 8 | export function make_youtube_video_embed_url(id: string) { 9 | return `https://www.youtube.com/embed/${id}`; 10 | } 11 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/block-quote-config.ts: -------------------------------------------------------------------------------- 1 | import Blockquote from "@tiptap/extension-blockquote"; 2 | export const BlockQuoteConfig = Blockquote.configure({}); 3 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/codeblock-config.ts: -------------------------------------------------------------------------------- 1 | import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; 2 | /* load all highlight.js languages */ import lowlight from "lowlight"; 3 | 4 | export const CodeblockConfig = CodeBlockLowlight.configure({ 5 | lowlight, 6 | }); 7 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/floating-menu-conig.ts: -------------------------------------------------------------------------------- 1 | import FloatingMenu, { 2 | FloatingMenuOptions, 3 | } from "@tiptap/extension-floating-menu"; 4 | 5 | export const FloatingMenuConfig = FloatingMenu.configure({ 6 | // 7 | }); 8 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./placeholder-config"; 2 | export * from "./codeblock-config"; 3 | export * from "./block-quote-config"; 4 | export * from "./underline-config"; 5 | export * from "./slash-command-config"; 6 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/placeholder-config.ts: -------------------------------------------------------------------------------- 1 | import Placeholder from "@tiptap/extension-placeholder"; 2 | 3 | export const PlaceholderConfig = Placeholder.configure({ 4 | showOnlyCurrent: true, 5 | placeholder: ({ node, editor, hasAnchor }) => { 6 | const headingPlaceholders = { 7 | 1: "Heading 1", 8 | 2: "Heading 2", 9 | 3: "Heading 3", 10 | }; 11 | 12 | if (node.type.name === "heading") { 13 | return headingPlaceholders[node.attrs.level]; 14 | } 15 | 16 | if (node.type.name === "paragraph") { 17 | return "Type '/' for commands"; 18 | } 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/slash-command-config.ts: -------------------------------------------------------------------------------- 1 | import { Commands, getSuggestionItems, renderItems } from "../slash-commands"; 2 | 3 | export const SlashCommandConfig = ({ 4 | onUploadFile, 5 | }: { 6 | onUploadFile: (file: File) => Promise; 7 | }) => 8 | Commands.configure({ 9 | suggestion: { 10 | items: (args) => getSuggestionItems({ ...args, onUploadFile }), 11 | render: renderItems, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/core-react/extension-configs/underline-config.ts: -------------------------------------------------------------------------------- 1 | import Underline from "@tiptap/extension-underline"; 2 | 3 | export const UnderlineConfig = Underline.configure({}); 4 | -------------------------------------------------------------------------------- /packages/core-react/floating-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./side-floating-menu"; 2 | -------------------------------------------------------------------------------- /packages/core-react/floating-menu/side-floating-menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { FloatingMenu as TiptapFloatingMenu, Editor } from "@tiptap/react"; 4 | import { 5 | useFloating, 6 | offset, 7 | useInteractions, 8 | useDismiss, 9 | autoUpdate, 10 | } from "@floating-ui/react-dom-interactions"; 11 | import { AddBlockMenu } from "../menu"; 12 | 13 | export function SideFloatingMenu({ 14 | editor, 15 | onUploadFile, 16 | }: { 17 | editor: Editor; 18 | onUploadFile; 19 | }) { 20 | const rectref = useRef(); 21 | const [addMenuShown, setAddMenuShown] = useState(false); 22 | const { x, y, reference, floating, context } = useFloating({ 23 | middleware: [offset({ mainAxis: 4, crossAxis: 40 })], 24 | strategy: "absolute", 25 | open: addMenuShown, 26 | }); 27 | const { getReferenceProps, getFloatingProps } = useInteractions([ 28 | useDismiss(context, { 29 | enabled: true, 30 | escapeKey: true, 31 | referencePointerDown: true, 32 | outsidePointerDown: true, 33 | }), 34 | ]); 35 | 36 | const showAddMenu = () => setAddMenuShown(true); 37 | const hideAddMenu = () => setAddMenuShown(false); 38 | 39 | useEffect(() => { 40 | setAddMenuShown(false); 41 | }, [editor.state.selection.anchor]); 42 | 43 | // dismiss manager 44 | useEffect(() => { 45 | const hideonclickoutside = (event: MouseEvent) => { 46 | if (rectref.current && !rectref.current.contains(event.target as Node)) { 47 | hideAddMenu(); 48 | } 49 | }; 50 | 51 | const hideonkeydown = (event: KeyboardEvent) => { 52 | if (event.key === "Escape") { 53 | hideAddMenu(); 54 | } 55 | 56 | // TODO: pass event to add block menu 57 | }; 58 | if (addMenuShown) { 59 | document.addEventListener("mousedown", hideonclickoutside); 60 | document.addEventListener("keydown", hideonkeydown); 61 | } 62 | return () => { 63 | document.removeEventListener("mousedown", hideonclickoutside); 64 | document.removeEventListener("keydown", hideonkeydown); 65 | }; 66 | }, [addMenuShown]); 67 | 68 | return ( 69 | <> 70 | true} 77 | > 78 |
79 |
90 | {addMenuShown && ( 91 | 96 | )} 97 |
98 |
99 | 100 | 105 | 112 | 117 | 118 | 119 | 120 |
121 | 122 | ); 123 | } 124 | 125 | const Container = styled.div` 126 | display: flex; 127 | `; 128 | 129 | const AddButton = styled.button` 130 | outline: none; 131 | border: none; 132 | text-align: center; 133 | border-radius: 4px; 134 | background-color: transparent; 135 | 136 | :hover { 137 | background-color: rgba(0, 0, 0, 0.1); 138 | } 139 | :active { 140 | background-color: rgba(0, 0, 0, 0.2); 141 | } 142 | `; 143 | -------------------------------------------------------------------------------- /packages/core-react/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./scaffold"; 2 | export * from "./node-view-wrapper"; 3 | -------------------------------------------------------------------------------- /packages/core-react/inline-toolbar/README.md: -------------------------------------------------------------------------------- 1 | # Inline toolbar 2 | -------------------------------------------------------------------------------- /packages/core-react/inline-toolbar/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./inline-toolbar"; 2 | -------------------------------------------------------------------------------- /packages/core-react/inline-toolbar/inline-toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Editor, BubbleMenu } from "@tiptap/react"; 4 | import { 5 | useFloating, 6 | offset, 7 | useInteractions, 8 | useDismiss, 9 | } from "@floating-ui/react-dom-interactions"; 10 | import { BoringBubbleLinkInput } from "../menu"; 11 | 12 | /* TODO: import icon somewhere else */ 13 | export function InlineToolbar(props: { editor: Editor | null }) { 14 | const { editor } = props; 15 | if (!editor) { 16 | return <>; 17 | } 18 | 19 | const { view, state } = editor; 20 | const { from, to } = view.state.selection; 21 | const text = state.doc.textBetween(from, to, ""); 22 | 23 | // - on text 24 | // - on image 25 | // - on embeddings 26 | // - on divider 27 | 28 | const isText = text && text !== ""; 29 | const isImage = editor.isActive("image"); 30 | 31 | return ( 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | function LinkItem({ editor }: { editor: Editor }) { 39 | const { x, y, reference, floating, context } = useFloating({ 40 | middleware: [offset({ mainAxis: 50 })], 41 | strategy: "absolute", 42 | }); 43 | const { getReferenceProps, getFloatingProps } = useInteractions([ 44 | useDismiss(context, { 45 | enabled: true, 46 | escapeKey: true, 47 | referencePointerDown: true, 48 | outsidePointerDown: true, 49 | }), 50 | ]); 51 | const [isOpen, setIsOpen] = React.useState(false); 52 | 53 | const link = editor.getAttributes("link")["href"]; 54 | 55 | return ( 56 | <> 57 |
68 | {isOpen && ( 69 | { 72 | if (v) { 73 | editor 74 | .chain() 75 | .focus() 76 | .setLink({ href: v, target: "_blank" }) 77 | .run(); 78 | } else { 79 | // if input is empty, remove link 80 | editor.chain().focus().unsetLink().run(); 81 | } 82 | }} 83 | /> 84 | )} 85 |
86 | { 90 | setIsOpen(true); 91 | }} 92 | className={editor.isActive("link") ? "is-active" : ""} 93 | > 94 | 101 | 107 | 108 | Link 109 | 110 | 111 | ); 112 | } 113 | 114 | function Items({ 115 | editor, 116 | type, 117 | }: { 118 | editor: Editor; 119 | type: "text" | "image" | "other"; 120 | }) { 121 | const isLink = editor.isActive("link"); 122 | const isBlockquote = editor.isActive("blockquote"); 123 | const isBold = editor.isActive("bold"); 124 | const isItalic = editor.isActive("italic"); 125 | const isUnderline = editor.isActive("underline"); 126 | const isStrike = editor.isActive("strike"); 127 | const isH1 = editor.isActive("heading", { level: 1 }); 128 | const isH2 = editor.isActive("heading", { level: 2 }); 129 | 130 | switch (type) { 131 | case "text": { 132 | return ( 133 | <> 134 | 135 | 136 | editor.chain().focus().toggleBlockquote().run()} 138 | className={isBlockquote ? "is-active" : ""} 139 | > 140 | 141 | 145 | 146 | 147 | 149 | editor 150 | .chain() 151 | .focus() 152 | // @ts-ignore 153 | .toggleBold() 154 | .run() 155 | } 156 | className={isBold ? "is-active" : ""} 157 | > 158 | 159 | 163 | 164 | 165 | 167 | editor 168 | .chain() 169 | .focus() 170 | // @ts-ignore 171 | .toggleItalic() 172 | .run() 173 | } 174 | className={isItalic ? "is-active" : ""} 175 | > 176 | 182 | 186 | 187 | 188 | editor.chain().focus().toggleUnderline().run()} 190 | className={isUnderline ? "is-active" : ""} 191 | > 192 | 198 | 202 | 206 | 207 | 208 | 210 | editor 211 | .chain() 212 | .focus() 213 | // @ts-ignore 214 | .toggleStrike() 215 | .run() 216 | } 217 | className={isStrike ? "is-active" : ""} 218 | > 219 | 225 | 229 | 230 | 231 | 232 | 234 | editor 235 | .chain() 236 | .focus() 237 | // @ts-ignore 238 | .toggleHeading({ level: 1 }) 239 | .run() 240 | } 241 | className={isH1 ? "is-active" : ""} 242 | > 243 | 250 | 254 | 258 | 259 | 260 | 262 | editor 263 | .chain() 264 | .focus() 265 | // @ts-ignore 266 | .toggleHeading({ level: 2 }) 267 | .run() 268 | } 269 | className={isH2 ? "is-active" : ""} 270 | > 271 | 278 | 282 | 286 | 287 | 288 | 289 | ); 290 | } 291 | case "image": { 292 | return <>; 293 | } 294 | case "other": { 295 | return <>; 296 | } 297 | } 298 | } 299 | 300 | // https://www.tiptap.dev/examples/menus 301 | // @ts-ignore 302 | const MenuWrapper = styled(BubbleMenu)` 303 | display: flex; 304 | justify-content: flex-start; 305 | flex-direction: row; 306 | align-items: center; 307 | flex: none; 308 | border: 1px solid rgba(0, 0, 0, 0.02); 309 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); 310 | border-radius: 4px; 311 | background-color: white; 312 | box-sizing: border-box; 313 | overflow: hidden; 314 | `; 315 | 316 | const Item = styled.button` 317 | display: flex; 318 | border-radius: 0px; 319 | justify-content: center; 320 | flex-direction: row; 321 | align-items: center; 322 | border: none; 323 | background: none; 324 | gap: 4px; 325 | align-self: stretch; 326 | box-sizing: border-box; 327 | padding: 10px 6px; 328 | flex-shrink: 0; 329 | background-color: transparent; 330 | 331 | :hover { 332 | background-color: rgba(0, 0, 0, 0.1); 333 | } 334 | :active { 335 | background-color: rgba(0, 0, 0, 0.12); 336 | } 337 | &.is-active { 338 | background-color: rgba(0, 0, 0, 0.1); 339 | } 340 | 341 | transition: background-color 0.1s ease-in-out; 342 | `; 343 | 344 | const Icon = styled.svg` 345 | width: 18px; 346 | height: 18px; 347 | `; 348 | 349 | const Link = styled.span` 350 | color: rgb(83, 84, 85); 351 | text-overflow: ellipsis; 352 | font-size: 16px; 353 | font-family: Inter, sans-serif; 354 | font-weight: 500; 355 | text-align: left; 356 | `; 357 | 358 | const Divider = styled.div` 359 | width: 1px; 360 | margin-left: 1px; 361 | border-left: solid 1px rgb(237, 237, 236); 362 | align-self: stretch; 363 | flex-shrink: 0; 364 | `; 365 | -------------------------------------------------------------------------------- /packages/core-react/key-maps/index.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function keyBindingFn(e: React.KeyboardEvent) { 4 | if (e.key === "/") { 5 | return "show-boring-shortcut-menu"; 6 | } 7 | if (e.key === "Enter") { 8 | return "split-block"; 9 | } 10 | } 11 | 12 | export function handleKeyCommand(command: string) { 13 | switch (command) { 14 | case "show-boring-shortcut-menu": 15 | return "handled"; 16 | case "split-block": 17 | // const currentContent = editorState.getCurrentContent(); 18 | // const selection = editorState.getSelection(); 19 | // const textWithEntity = Modifier.splitBlock(currentContent, selection); 20 | // setEditorState( 21 | // EditorState.push(editorState, textWithEntity, "split-block") 22 | // ); 23 | return "handled"; 24 | } 25 | 26 | // We do this by telling Draft we haven't handled it. 27 | return "not-handled"; 28 | } 29 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-block-menu/block-item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | export function BoringBlockSuggestionItemOnBubbleMenu({ 5 | label, 6 | helptext, 7 | preview, 8 | onClick, 9 | selected, 10 | }: { 11 | selected?: boolean; 12 | label: string; 13 | helptext?: string; 14 | preview?: JSX.Element; 15 | onClick: () => void; 16 | }) { 17 | return ( 18 | 19 | {preview && {preview}} 20 | 21 | 22 | {helptext && {helptext}} 23 | 24 | 25 | ); 26 | } 27 | 28 | const Container = styled.div` 29 | cursor: pointer; 30 | display: flex; 31 | justify-content: flex-start; 32 | flex-direction: row; 33 | align-items: center; 34 | flex: none; 35 | gap: 12px; 36 | border-radius: 4px; 37 | box-sizing: border-box; 38 | padding: 8px 16px; 39 | &[data-selected="true"], 40 | :hover { 41 | background-color: rgba(0, 0, 0, 0.05); 42 | /* #efefef; */ 43 | } 44 | `; 45 | 46 | const PreviewContainer = styled.div` 47 | display: flex; 48 | justify-content: center; 49 | flex-direction: row; 50 | align-items: center; 51 | flex: none; 52 | border: solid 1px rgba(0, 0, 0, 0.08); 53 | border-radius: 4px; 54 | width: 60px; 55 | height: 60px; 56 | box-sizing: border-box; 57 | `; 58 | 59 | const TextContainer = styled.div` 60 | display: flex; 61 | justify-content: flex-start; 62 | flex-direction: column; 63 | align-items: flex-start; 64 | flex: 1; 65 | gap: 10px; 66 | box-sizing: border-box; 67 | `; 68 | 69 | const Label = styled.span` 70 | color: black; 71 | text-overflow: ellipsis; 72 | font-size: 18px; 73 | font-family: Inter, sans-serif; 74 | font-weight: 500; 75 | text-align: left; 76 | `; 77 | 78 | const Helptext = styled.span` 79 | color: rgba(0, 0, 0, 0.5); 80 | text-overflow: ellipsis; 81 | font-size: 16px; 82 | font-family: Inter, sans-serif; 83 | font-weight: 500; 84 | text-align: left; 85 | `; 86 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-block-menu/block-list.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import type { Editor } from "@tiptap/react"; 3 | import { BoringBlockSuggestionItemOnBubbleMenu } from "./block-item"; 4 | import styled from "@emotion/styled"; 5 | import { CommandItem } from "./items"; 6 | import { BoringBlockIcon } from "./icons"; 7 | 8 | export class BlockList extends Component<{ 9 | items: CommandItem[]; 10 | command?: (c: CommandItem) => void; 11 | }> { 12 | state = { 13 | selectedIndex: 0, 14 | }; 15 | 16 | items: []; 17 | off: boolean; 18 | 19 | constructor(props) { 20 | super(props); 21 | this.items = props.items; 22 | this.off = this.items.length === 0; 23 | } 24 | 25 | componentDidUpdate(oldProps) { 26 | if (this.props.items !== oldProps.items) { 27 | this.setState({ 28 | selectedIndex: 0, 29 | }); 30 | } 31 | } 32 | 33 | onKeyDown({ event }) { 34 | if (this.off) { 35 | return false; 36 | } 37 | 38 | if (event.key === "ArrowUp") { 39 | this.upHandler(); 40 | return true; 41 | } 42 | 43 | if (event.key === "ArrowDown") { 44 | this.downHandler(); 45 | return true; 46 | } 47 | 48 | if (event.key === "Enter") { 49 | this.enterHandler(); 50 | return true; 51 | } 52 | 53 | return false; 54 | } 55 | 56 | upHandler() { 57 | this.setState({ 58 | selectedIndex: 59 | (this.state.selectedIndex + this.props.items.length - 1) % 60 | this.props.items.length, 61 | }); 62 | } 63 | 64 | downHandler() { 65 | this.setState({ 66 | selectedIndex: (this.state.selectedIndex + 1) % this.props.items.length, 67 | }); 68 | } 69 | 70 | enterHandler() { 71 | this.selectItem(this.state.selectedIndex); 72 | } 73 | 74 | selectItem(index) { 75 | const item = this.props.items[index]; 76 | 77 | if (item) { 78 | this.props.command?.(item); 79 | } 80 | } 81 | 82 | render() { 83 | const { items } = this.props; 84 | return ( 85 | <> 86 | {items.length ? ( 87 | 88 |
89 | {items.map((item, index) => { 90 | return ( 91 | } 95 | label={item.title} 96 | helptext={item.subtitle} 97 | onClick={() => this.selectItem(index)} 98 | /> 99 | ); 100 | })} 101 |
102 | 103 | ) : ( 104 | <> 105 | )} 106 | 107 | ); 108 | } 109 | } 110 | 111 | const ItemsContainer = styled.div` 112 | z-index: 9; 113 | display: flex; 114 | justify-content: flex-start; 115 | flex-direction: column; 116 | align-items: stretch; 117 | flex: none; 118 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); 119 | border: solid 1px rgb(237, 237, 236); 120 | border-radius: 4px; 121 | background-color: white; 122 | box-sizing: border-box; 123 | padding: 0px 8px; 124 | max-height: 400px; 125 | overflow-y: scroll; 126 | `; 127 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-block-menu/icons.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type BoringBlockIconType = 4 | | "text" 5 | // 6 | | "h1" 7 | | "h2" 8 | | "h3" 9 | | "hr" 10 | | "image" 11 | | "embed" 12 | | "video"; 13 | 14 | export function BoringBlockIcon({ name }: { name: BoringBlockIconType }) { 15 | switch (name) { 16 | case "text": 17 | return <>; 18 | case "h1": 19 | return ; 20 | case "h2": 21 | return ; 22 | case "h3": 23 | return ; 24 | case "hr": 25 | return ; 26 | case "image": 27 | return ; 28 | case "embed": 29 | return <>; 30 | case "video": 31 | return ; 32 | default: 33 | return <>; 34 | } 35 | } 36 | 37 | function Heading1BlockIcon() { 38 | return ( 39 | 46 | 50 | 54 | 55 | ); 56 | } 57 | 58 | function Heading2BlockIcon() { 59 | return ( 60 | 67 | 71 | 75 | 76 | ); 77 | } 78 | 79 | function Heading3BlockIcon() { 80 | return ( 81 | 88 | 92 | 96 | 97 | ); 98 | } 99 | 100 | function HorizontalRuleBlockIcon() { 101 | return ( 102 | 109 | 110 | 111 | ); 112 | } 113 | 114 | function ImageBlockIcon() { 115 | return ( 116 | 123 | 130 | 137 | 143 | 144 | ); 145 | } 146 | 147 | function VideoBlockIcon() { 148 | return ( 149 | 156 | 161 | 165 | 166 | ); 167 | } 168 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-block-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import { BlockList as AddBlockMenuBody } from "./block-list"; 3 | import { Editor } from "@tiptap/react"; 4 | import getSuggestionItems from "./items"; 5 | 6 | export function AddBlockMenu({ 7 | editor, 8 | onUploadFile, 9 | onHide, 10 | }: { 11 | editor: Editor; 12 | onUploadFile: (file: File) => Promise; 13 | onHide: () => void; 14 | }) { 15 | const ref = useRef(); 16 | useEffect(() => { 17 | // focus to this element on mount 18 | ref.current?.focus(); 19 | }, [ref]); 20 | 21 | useEffect(() => { 22 | const block = (e) => { 23 | e.stopPropagation(); 24 | e.preventDefault(); 25 | }; 26 | document.addEventListener("keydown", block); 27 | return () => { 28 | document.removeEventListener("keydown", block); 29 | }; 30 | }, []); 31 | 32 | return ( 33 |
40 | {/* @ts-ignore */} 41 | { 43 | c.command({ 44 | editor, 45 | range: [ 46 | editor.view.state.selection.from, 47 | editor.view.state.selection.to, 48 | ], 49 | }); 50 | onHide(); 51 | }} 52 | items={getSuggestionItems({ editor, onUploadFile })} 53 | /> 54 |
55 | ); 56 | } 57 | 58 | export { AddBlockMenuBody }; 59 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-block-menu/items.ts: -------------------------------------------------------------------------------- 1 | import { Editor, ReactRenderer } from "@tiptap/react"; 2 | import type { BoringBlockIconType } from "./icons"; 3 | import { AddImageBlockMenu } from "../add-image-block-menu"; 4 | import tippy from "tippy.js"; 5 | import { 6 | get_youtube_video_id, 7 | make_youtube_video_embed_url, 8 | get_vimeo_video_id, 9 | make_vimeo_video_embed_url, 10 | } from "../../embeding-utils"; 11 | 12 | export interface CommandItem { 13 | title: string; 14 | subtitle?: string; 15 | icon?: BoringBlockIconType; 16 | command: ({ editor, range }: { editor: Editor; range }) => void; 17 | } 18 | 19 | const getSuggestionItems = ( 20 | { 21 | editor, 22 | query = "", 23 | onUploadFile, 24 | }: { 25 | editor: Editor; 26 | query?: string; 27 | onUploadFile: (file: File) => Promise; 28 | }, 29 | ...args 30 | ): CommandItem[] => { 31 | // [(enable this to disable slash commands on revisited slashes.)] 32 | // const d = JSON.stringify(editor.getJSON()); 33 | // const lastd = sessionStorage.getItem("last-content"); 34 | // sessionStorage.setItem("last-content", d); 35 | // if (d.length < (lastd?.length ?? 0) || lastd === d) { 36 | // // if removed or same - it means "/" is not typed. 37 | // // somehow the d is not beign updated with '/' value 38 | // // this may not work when removing and re inserting the same value - '/' 39 | // // fix: save the data on every change, compare that with this. (somewhre else) 40 | // return []; 41 | // } 42 | 43 | return [ 44 | { 45 | title: "H1", 46 | icon: "h1", 47 | subtitle: "Big section heading", 48 | command: ({ editor, range }) => { 49 | editor 50 | .chain() 51 | .focus() 52 | .deleteRange(range) 53 | .setNode("heading", { level: 1 }) 54 | .run(); 55 | }, 56 | }, 57 | { 58 | title: "H2", 59 | icon: "h2", 60 | subtitle: "Medium section heading", 61 | command: ({ editor, range }) => { 62 | editor 63 | .chain() 64 | .focus() 65 | .deleteRange(range) 66 | .setNode("heading", { level: 2 }) 67 | .run(); 68 | }, 69 | }, 70 | { 71 | title: "H3", 72 | icon: "h3", 73 | subtitle: "Smaller section heading", 74 | command: ({ editor, range }) => { 75 | editor 76 | .chain() 77 | .focus() 78 | .deleteRange(range) 79 | .setNode("heading", { level: 3 }) 80 | .run(); 81 | }, 82 | }, 83 | { 84 | title: "Divider", 85 | icon: "hr", 86 | subtitle: "Divide blocks with new line", 87 | command: ({ editor, range }) => { 88 | editor 89 | .chain() 90 | .focus() 91 | .deleteRange(range) 92 | // @ts-ignore 93 | .setHorizontalRule() 94 | .run(); 95 | // create new empty block below (only for divider) 96 | editor.chain().focus().setNode("paragraph").run(); 97 | }, 98 | }, 99 | // { 100 | // title: "Text", 101 | // icon: "text", 102 | // subtitle: "Paragraph text", 103 | // command: ({ editor, range }) => { 104 | // editor.chain().focus().deleteRange(range).setNode("paragraph").run(); 105 | // }, 106 | // }, 107 | { 108 | title: "Image", 109 | icon: "image", 110 | subtitle: "Put an image from url or via upload", 111 | command: ({ editor, range }) => { 112 | // to dismiss the menu 113 | editor.chain().focus().deleteRange(range).run(); 114 | // open file input 115 | const input = document.createElement("input"); 116 | input.type = "file"; 117 | input.accept = "image/*"; 118 | input.style.display = "none"; 119 | // @ts-ignore 120 | input.onchange = async () => { 121 | // @ts-ignore 122 | const file = input.files[0]; 123 | if (!file || !file.type.includes("image/")) { 124 | return; 125 | } 126 | 127 | onUploadFile(file) 128 | ?.then((url) => { 129 | if (url) { 130 | editor 131 | .chain() 132 | .focus() 133 | .deleteRange(range) 134 | .setImage({ src: url }) 135 | .run(); 136 | } else { 137 | alert("could not upload file, please try again."); 138 | } 139 | }) 140 | .finally(() => { 141 | input.remove(); 142 | }); 143 | }; 144 | document.body.appendChild(input); 145 | 146 | input.click(); 147 | 148 | return true; 149 | }, 150 | }, 151 | { 152 | title: "Video", 153 | icon: "video", 154 | subtitle: "Embed a Youtube or Vimeo video", 155 | command: ({ editor, range }) => { 156 | // dismiss the menu 157 | editor.chain().focus().deleteRange(range).run(); 158 | setTimeout(() => { 159 | try { 160 | const _url = window.prompt("Enter a Youtube or Vimeo video url"); 161 | 162 | if (_url) { 163 | const url = new URL(_url).toString(); 164 | // parse the url, make the embed url 165 | if (_url.includes("youtube.com")) { 166 | const id = get_youtube_video_id(url); 167 | if (id) { 168 | const src = make_youtube_video_embed_url(id); 169 | editor 170 | .chain() 171 | .focus() 172 | .deleteRange(range) 173 | .setIframe({ src: src }) 174 | .run(); 175 | } else { 176 | alert("Not a valid Youtube URL"); 177 | } 178 | return; 179 | } 180 | 181 | if (_url.includes("vimeo.com")) { 182 | const id = get_vimeo_video_id(url); 183 | if (id) { 184 | const src = make_vimeo_video_embed_url(id); 185 | editor 186 | .chain() 187 | .focus() 188 | .deleteRange(range) 189 | .setIframe({ src: src }) 190 | .run(); 191 | } else { 192 | alert("Not a valid Vimeo URL"); 193 | } 194 | return; 195 | } 196 | } 197 | } catch (e) { 198 | // dismiss 199 | } 200 | }, 100); 201 | }, 202 | }, 203 | ] 204 | .filter((item) => item.title.toLowerCase().startsWith(query?.toLowerCase())) 205 | .slice(0, 10); 206 | }; 207 | 208 | export default getSuggestionItems; 209 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-image-block-menu/README.md: -------------------------------------------------------------------------------- 1 | # Add iamge block menu 2 | 3 | this appreas when add image action is triggered (to show a ui). 4 | 5 | once shown, users can do one of below. 6 | 7 | - drop image on menu 8 | - paste link to an image on menu 9 | - dismiss 10 | - search image on image resources provier. 11 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-image-block-menu/add-image-block-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | // 3 | 4 | export function AddImageBlockMenu() { 5 | return <>Add an image; 6 | } 7 | -------------------------------------------------------------------------------- /packages/core-react/menu/add-image-block-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./add-image-block-menu"; 2 | -------------------------------------------------------------------------------- /packages/core-react/menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./link-input-menu"; 2 | export { AddBlockMenu, AddBlockMenuBody } from "./add-block-menu"; 3 | -------------------------------------------------------------------------------- /packages/core-react/menu/link-input-menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useLayoutEffect } from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | export function BoringBubbleLinkInput({ 5 | defaultValue, 6 | onChange, 7 | onSubmit, 8 | }: { 9 | defaultValue?: string; 10 | onChange?: (value: string) => void; 11 | onSubmit?: (url: string) => void; 12 | }) { 13 | // TODO: add url validation 14 | const ref = useRef(); 15 | const [value, setValue] = useState(defaultValue); 16 | 17 | useLayoutEffect(() => { 18 | console.log(ref); 19 | if (ref.current) { 20 | ref.current.focus(); 21 | } 22 | }, [ref]); 23 | 24 | return ( 25 | 26 | { 29 | setValue(e.target.value); 30 | onChange?.(e.target.value); 31 | }} 32 | onKeyDown={(e) => { 33 | if (e.key === "Enter") { 34 | onSubmit?.(value); 35 | } 36 | }} 37 | value={value} 38 | defaultValue={defaultValue} 39 | type="text" 40 | placeholder="Pase link" 41 | autoFocus 42 | /> 43 | 44 | ); 45 | } 46 | 47 | const Container = styled.div` 48 | display: flex; 49 | justify-content: flex-start; 50 | flex-direction: column; 51 | align-items: flex-start; 52 | flex: none; 53 | box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); 54 | border: solid 1px rgb(237, 237, 236); 55 | border-radius: 4px; 56 | background-color: white; 57 | box-sizing: border-box; 58 | padding: 18px 20px; 59 | `; 60 | 61 | const InputAsInput = styled.input` 62 | color: rgba(0, 0, 0, 0.87); 63 | background-color: rgba(0, 0, 0, 0.02); 64 | border: solid 3px rgba(35, 77, 255, 0.3); 65 | border-radius: 2px; 66 | padding: 4px 8px; 67 | box-sizing: border-box; 68 | font-size: 16px; 69 | font-family: Inter, sans-serif; 70 | font-weight: 500; 71 | text-align: start; 72 | align-self: stretch; 73 | flex-shrink: 0; 74 | 75 | ::placeholder { 76 | color: rgba(0, 0, 0, 0.3); 77 | font-family: Inter, sans-serif; 78 | font-weight: 500; 79 | } 80 | `; 81 | -------------------------------------------------------------------------------- /packages/core-react/node-view-wrapper/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./node-view-wrapper" -------------------------------------------------------------------------------- /packages/core-react/node-view-wrapper/node-view-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { NodeViewWrapper as _NodeViewWrapper } from "@tiptap/react"; 2 | 3 | export function NodeViewWrapper(props: { 4 | children: JSX.Element | JSX.Element[]; 5 | 6 | /** 7 | * noninteractive is set to false by default, which means interactive by default. this will prevent parent's onclick event. 8 | */ 9 | noninteractive?: boolean; 10 | }) { 11 | const eventcallback = !props.noninteractive 12 | ? (event) => { 13 | event.stopPropagation(); 14 | } 15 | : undefined; 16 | return ( 17 | <_NodeViewWrapper> 18 |
{props.children}
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/core-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boringso/react-core", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@boring.so/config": "workspace:^", 6 | "@boring.so/document-model": "workspace:^", 7 | "@boring.so/store": "workspace:^", 8 | "@floating-ui/react-dom-interactions": "^0.6.1", 9 | "@tippyjs/react": "^4.2.6", 10 | "@tiptap/core": "^2.1.13", 11 | "@tiptap/extension-blockquote": "^2.1.13", 12 | "@tiptap/extension-bold": "^2.11.2", 13 | "@tiptap/extension-bubble-menu": "^2.1.13", 14 | "@tiptap/extension-code-block-lowlight": "^2.1.13", 15 | "@tiptap/extension-collaboration": "^2.1.13", 16 | "@tiptap/extension-collaboration-cursor": "^2.1.13", 17 | "@tiptap/extension-image": "^2.1.13", 18 | "@tiptap/extension-link": "^2.1.13", 19 | "@tiptap/extension-mention": "^2.1.13", 20 | "@tiptap/extension-placeholder": "^2.1.13", 21 | "@tiptap/extension-underline": "^2.1.13", 22 | "@tiptap/pm": "^2.1.13", 23 | "@tiptap/react": "^2.1.13", 24 | "@tiptap/starter-kit": "^2.1.13", 25 | "@tiptap/suggestion": "^2.1.13", 26 | "lowlight": "^3.1.0", 27 | "prosemirror-state": "^1.4.3", 28 | "prosemirror-view": "^1.33.1", 29 | "react-textarea-autosize": "^8.3.3", 30 | "tippy.js": "^6.3.7", 31 | "y-indexeddb": "^9.0.7", 32 | "y-prosemirror": "^1.2.1", 33 | "y-webrtc": "^10.2.3", 34 | "yjs": "^13.5.38" 35 | }, 36 | "devDependencies": { 37 | "@emotion/styled": "^11.11.0", 38 | "@types/react": "18.2.58", 39 | "react": "^18.2.0" 40 | }, 41 | "resolutions": { 42 | "prosemirror-model": "1.14.2" 43 | }, 44 | "peerDependencies": { 45 | "@emotion/styled": "^11.11.0", 46 | "react": "^18.2.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/core-react/scaffold/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Title, TitleProps } from "../title"; 4 | import { Node } from "@tiptap/core"; 5 | import { MainBodyContentEditor } from "../content-editor"; 6 | import type { EditorView } from "prosemirror-view"; 7 | import { 8 | BoringContent, 9 | BoringDocument, 10 | BoringDocumentId, 11 | autoid, 12 | } from "@boring.so/document-model"; 13 | import { EditorConfig, defaults as DefaultConfig } from "@boring.so/config"; 14 | import { Editor, useEditor, EditorContent } from "@tiptap/react"; 15 | import { default_extensions } from "./scaffold-extensions"; 16 | import { BoringDocumentStore } from "@boring.so/store"; 17 | import { 18 | get_youtube_video_id, 19 | make_youtube_video_embed_url, 20 | } from "../embeding-utils"; 21 | import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; 22 | import Collaboration from "@tiptap/extension-collaboration"; 23 | import * as Y from "yjs"; 24 | import { WebrtcProvider } from "y-webrtc"; 25 | 26 | export type InitialDocumentProp = 27 | | { 28 | title?: string; 29 | content?: string; 30 | } 31 | | BoringDocument 32 | | BoringDocumentId; 33 | 34 | export type OnContentChange = (content: string, transaction?) => void; 35 | 36 | export interface ScaffoldProps { 37 | /** 38 | * defaults to false 39 | */ 40 | fullWidth?: boolean; 41 | 42 | /** 43 | * boring in-content extended node blocks configuration 44 | */ 45 | extensions?: Node[]; 46 | contentmode?: "html" | "json"; 47 | 48 | // region document model 49 | initial?: InitialDocumentProp; 50 | onTitleChange?: (title: string) => void; 51 | 52 | onContentChange?: OnContentChange; 53 | // endregion document model 54 | 55 | onTriggerSave?: () => void; 56 | 57 | config?: EditorConfig; 58 | 59 | readonly?: boolean; 60 | 61 | fileUploader?: (file: File) => Promise; 62 | 63 | titleStyle?: TitleProps["style"]; 64 | collaboration?: { 65 | enabled: boolean; 66 | }; 67 | } 68 | 69 | export function Scaffold({ 70 | initial, 71 | fullWidth, 72 | onTitleChange, 73 | onContentChange, 74 | extensions, 75 | contentmode = "html", 76 | config = DefaultConfig, 77 | readonly = false, 78 | onTriggerSave, 79 | fileUploader, 80 | titleStyle, 81 | collaboration, 82 | ...props 83 | }: ScaffoldProps) { 84 | // region doc init 85 | const initializer = handleDocumentInitialization(initial); 86 | const id = initializer.id; 87 | const [title, setTitle] = useState(initializer.loaded?.title?.raw); 88 | const [content, setContent] = useState( 89 | initializer.loaded?.content?.raw 90 | ); 91 | 92 | useEffect(() => { 93 | if (initializer.shouldload) { 94 | initializer.shouldload.then((d) => { 95 | // load title 96 | setTitle(d?.title?.raw ?? ""); 97 | // load body conditionally 98 | if (collaboration?.enabled) { 99 | // do not set initial data. if already loaed by collaboration module 100 | if (editor && editor.getText()) return; 101 | } 102 | setContent(d?.content?.raw ?? ""); 103 | }); 104 | } 105 | }, [initializer.id]); 106 | 107 | // endregion doc init 108 | 109 | // store 110 | const service = new BoringDocumentStore(id); 111 | 112 | // region collaboration 113 | // A new Y document 114 | const ydoc = useMemo( 115 | () => (collaboration?.enabled ? new Y.Doc({}) : null), 116 | [id, collaboration?.enabled] 117 | ); 118 | // Registered with a WebRTC provider 119 | const provider = useMemo( 120 | () => 121 | collaboration?.enabled 122 | ? new WebrtcProvider("boring.grida.co" + id, ydoc) 123 | : null, 124 | [id, ydoc, collaboration?.enabled] 125 | ); 126 | // endregion collaboration 127 | 128 | const finalcontent = _makecontent(content); 129 | const editor = useEditor( 130 | { 131 | extensions: [ 132 | // StarterKit.configure({ 133 | // gapcursor: false, 134 | // // history: collaboration?.enabled ? false : undefined, 135 | // history: false, 136 | // }), 137 | ...default_extensions({ 138 | onUploadFile: fileUploader, 139 | }), 140 | ...(extensions ?? []), 141 | // region collaboration extensions 142 | ...(collaboration?.enabled 143 | ? [ 144 | Collaboration.configure({ 145 | document: ydoc, 146 | }), 147 | CollaborationCursor.configure({ 148 | provider: provider, 149 | user: { 150 | name: "Editor", 151 | color: randomCursorColor(), 152 | }, 153 | }), 154 | ] 155 | : []), 156 | // endregion collaboration extensions 157 | ] as any, 158 | content: finalcontent, 159 | // onTransaction: ({ editor, transaction }) => { 160 | // // editor.state.selection.anchor; 161 | // // editor.view.posAtDOM() 162 | // }, 163 | editorProps: { 164 | handlePaste: (view, event: ClipboardEvent, slice) => { 165 | console.log("pasted", event, slice, view); 166 | const text = event.clipboardData.getData("Text"); 167 | if (text) { 168 | // parse 169 | const id = get_youtube_video_id(text); 170 | if (id) { 171 | addIframe(make_youtube_video_embed_url(id)); 172 | return true; 173 | } 174 | } 175 | return false; 176 | }, 177 | handleDrop: function (view, event: DragEvent, slice, moved) { 178 | if (!moved && event.dataTransfer && event.dataTransfer.files) { 179 | const coordinates = view.posAtCoords({ 180 | left: event.clientX, 181 | top: event.clientY, 182 | }); 183 | // if dropping external files 184 | // the addImage function checks the files are an image upload, and returns the url 185 | for (let i = 0; i < event.dataTransfer.files.length; i++) { 186 | const file = event.dataTransfer.files.item(i); 187 | if (!file) return false 188 | fileUploader?.(file).then((url) => { 189 | if (url) { 190 | if (file.type.includes("image")) { 191 | return addImage( 192 | // @ts-ignore 193 | view, 194 | url, 195 | coordinates.pos 196 | ); 197 | } 198 | 199 | if (file.type.includes("video")) { 200 | return addVideo( 201 | // @ts-ignore 202 | view, 203 | url, 204 | coordinates.pos 205 | ); 206 | } 207 | } else { 208 | console.error("cannot upload file", event); 209 | return false; 210 | } 211 | }); 212 | } 213 | return true; // drop is handled don't do anything else 214 | } 215 | return false; // not handled as wasn't dragging a file so use default behaviour 216 | }, 217 | // TODO: when pos = 0, < or ^ key pressed, move to title 218 | // handleKeyPress: function (view, event) { 219 | // return true; 220 | // }, 221 | }, 222 | onUpdate: ({ editor, transaction }) => { 223 | const content = editor.getHTML(); 224 | _oncontentchange(content, transaction); 225 | }, 226 | }, 227 | [content] 228 | ); 229 | 230 | const addIframe = (url: string) => { 231 | if (url) { 232 | editor?.chain().focus().setIframe({ src: url }).run(); 233 | } 234 | }; 235 | 236 | // this inserts the image with src url into the editor at the position of the drop 237 | const addImage = (view: EditorView, url: string, pos: number) => { 238 | const { schema } = view.state; 239 | const node = schema.nodes.image.create({ src: url }); 240 | const transaction = view.state.tr.insert(pos, node); 241 | return view.dispatch(transaction); 242 | }; 243 | 244 | const addVideo = (view: EditorView, url: string, pos: number) => { 245 | const { schema } = view.state; 246 | const node = schema.nodes.video.create({ src: url }); 247 | const transaction = view.state.tr.insert(pos, node); 248 | return view.dispatch(transaction); 249 | }; 250 | 251 | const focustocontent = () => { 252 | editor?.chain().focus().run(); 253 | }; 254 | 255 | const focustotop = () => { 256 | editor?.chain().focus("start").run(); 257 | }; 258 | 259 | const _ontitlereturnhit = () => { 260 | focustotop(); 261 | }; 262 | 263 | const _ontitlechange = (t: string) => { 264 | onTitleChange?.(t); 265 | }; 266 | 267 | const _oncontentchange = (c: string, transaction?) => { 268 | service.updateContent(c); 269 | // 270 | onContentChange?.(c, transaction); 271 | }; 272 | 273 | return ( 274 | { 277 | // if cmd + s ignore. 278 | if (e.metaKey && e.key === "s") { 279 | e.preventDefault(); 280 | e.stopPropagation(); 281 | onTriggerSave?.(); 282 | } 283 | }} 284 | > 285 | {/* */} 286 | 291 | {title} 292 | 293 | 294 | 299 | 300 | ); 301 | } 302 | 303 | function handleDocumentInitialization(initial: InitialDocumentProp): { 304 | id: BoringDocumentId; 305 | loaded?: BoringDocument; 306 | shouldload?: Promise; 307 | } { 308 | if (initial instanceof BoringDocument) { 309 | return { 310 | id: initial.id, 311 | loaded: initial, 312 | }; 313 | } else if (typeof initial == "string") { 314 | // fetch document 315 | return { 316 | id: initial, 317 | shouldload: new BoringDocumentStore(initial).get(), 318 | }; 319 | } else { 320 | const _newly = new BoringDocument({ 321 | title: initial?.title, 322 | content: initial?.content, 323 | }); 324 | return { 325 | id: _newly.id, 326 | loaded: _newly, 327 | }; 328 | } 329 | } 330 | 331 | function _makecontent(raw: string | BoringContent): string { 332 | if (raw) { 333 | if (typeof raw == "string") { 334 | return raw; 335 | } 336 | return raw.raw; 337 | } 338 | } 339 | 340 | function randomCursorColor() { 341 | const colors = [ 342 | "#f783ac", 343 | "#f7b7c4", 344 | "#f7d6a7", 345 | "#f7c7a7", 346 | "#f7b7a7", 347 | "#f7a7a7", 348 | "#f7a7b7", 349 | "#f7a7c7", 350 | "#f7a7d6", 351 | "#f7a7e6", 352 | "#f7a7f6", 353 | "#f7a7ff", 354 | "#f7d6ff", 355 | "#f7c6ff", 356 | "#f7b6ff", 357 | "#f7a6ff", 358 | "#f796ff", 359 | "#f7a6f7", 360 | "#f7a6e7", 361 | "#f7a6d7", 362 | "#f7a6c7", 363 | "#f7a6b7", 364 | "#f7a6a7", 365 | "#f7a6a7", 366 | "#f7a7a7", 367 | "#f7b7a7", 368 | "#f7c7a7", 369 | "#f7d6a7", 370 | "#f7e6a7", 371 | "#f7f6a7", 372 | "#ffa7a7", 373 | "#ffa7b7", 374 | "#ffa7c7", 375 | "#ffa7d7", 376 | "#ffa7e7", 377 | "#ffa7f7", 378 | "#ffa7ff", 379 | "#ffd6ff", 380 | "#ffc6ff", 381 | "#ffb6ff", 382 | "#ffa6ff", 383 | "#ff96ff", 384 | "#ffa6f7", 385 | "#ffa6e7", 386 | "#ffa6d7", 387 | "#ffa6c7", 388 | "#ffa6b7", 389 | "#ffa6a7", 390 | "#ffa6a7", 391 | "#ffa7a7", 392 | "#ffb7a7", 393 | "#ffc7a7", 394 | "#ffd6a7", 395 | "#ffe6", 396 | ]; 397 | return colors[Math.floor(Math.random() * colors.length)]; 398 | } 399 | 400 | const TitleAndEditorSeparator = styled.div` 401 | height: 32px; 402 | `; 403 | 404 | const EditorWrap = styled.div<{ 405 | fullWidth?: boolean; 406 | }>` 407 | box-sizing: border-box; 408 | width: 100%; 409 | max-width: 1080px; 410 | height: 100%; 411 | margin: auto; 412 | padding: 160px ${(p) => (p.fullWidth ? "20px" : "80px")}; 413 | 414 | @media (max-width: 600px) { 415 | padding: 120px 20px; 416 | } 417 | `; 418 | -------------------------------------------------------------------------------- /packages/core-react/scaffold/scaffold-extensions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PlaceholderConfig, 3 | // CodeblockConfig, 4 | // BlockQuoteConfig, 5 | UnderlineConfig, 6 | SlashCommandConfig, 7 | } from "../extension-configs"; 8 | import Image from "@tiptap/extension-image"; 9 | import Link from "@tiptap/extension-link"; 10 | import Iframe from "../blocks/iframe-block"; 11 | import Video from "../blocks/video-block"; 12 | import TrailingNode from "../blocks/trailing-node"; 13 | import _StarterKit from "@tiptap/starter-kit"; 14 | 15 | const StarterKit = _StarterKit.configure({ 16 | gapcursor: false, 17 | }); 18 | 19 | interface ExtensionsProps { 20 | onUploadFile: (file: File) => Promise; 21 | } 22 | 23 | export const default_extensions = (props: ExtensionsProps) => [ 24 | TrailingNode, 25 | Image, 26 | Iframe, 27 | Video, 28 | SlashCommandConfig(props), 29 | StarterKit, 30 | PlaceholderConfig, 31 | UnderlineConfig, 32 | 33 | // included in starter kit 34 | // CodeblockConfig, 35 | // BlockQuoteConfig, 36 | Link, 37 | ]; 38 | -------------------------------------------------------------------------------- /packages/core-react/slash-commands/README.md: -------------------------------------------------------------------------------- 1 | # WIP - will resume when experiment label removed 2 | 3 | ref: https://www.tiptap.dev/experiments/commands/#commands 4 | 5 | issue: https://github.com/ueberdosis/tiptap/issues/1508 6 | -------------------------------------------------------------------------------- /packages/core-react/slash-commands/commands.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import Suggestion from "@tiptap/suggestion"; 3 | 4 | const Commands = Extension.create({ 5 | name: "slash-command-suggestion", 6 | 7 | addOptions: () => ({ 8 | suggestion: { 9 | char: "/", 10 | allowSpaces: false, 11 | startOfLine: false, 12 | command: ({ editor, range, props }) => { 13 | props.command({ editor, range, props }); 14 | }, 15 | }, 16 | }), 17 | 18 | addProseMirrorPlugins() { 19 | return [ 20 | Suggestion({ 21 | editor: this.editor, 22 | ...this.options.suggestion, 23 | }), 24 | ]; 25 | }, 26 | }); 27 | 28 | export default Commands; 29 | -------------------------------------------------------------------------------- /packages/core-react/slash-commands/index.ts: -------------------------------------------------------------------------------- 1 | import Commands from "./commands"; 2 | import getSuggestionItems from "../menu/add-block-menu/items"; 3 | import renderItems from "./render-items"; 4 | 5 | export { Commands, getSuggestionItems, renderItems }; 6 | -------------------------------------------------------------------------------- /packages/core-react/slash-commands/render-items.tsx: -------------------------------------------------------------------------------- 1 | import { ReactRenderer } from "@tiptap/react"; 2 | import tippy from "tippy.js"; 3 | import { AddBlockMenuBody } from "../menu/add-block-menu"; 4 | 5 | const renderItems = () => { 6 | let component; 7 | let popup; 8 | let hidden = false; 9 | 10 | return { 11 | onStart: (props) => { 12 | component = new ReactRenderer(AddBlockMenuBody, { 13 | props, 14 | editor: props.editor, 15 | }); 16 | 17 | popup = tippy("body", { 18 | getReferenceClientRect: props.clientRect, 19 | appendTo: () => document.body, 20 | content: component.element, 21 | showOnCreate: true, 22 | interactive: true, 23 | trigger: "manual", 24 | placement: "bottom-start", 25 | }); 26 | }, 27 | onUpdate(props) { 28 | component.updateProps(props); 29 | 30 | popup[0].setProps({ 31 | getReferenceClientRect: props.clientRect, 32 | }); 33 | }, 34 | onKeyDown(props) { 35 | if (props.event.key === "Escape") { 36 | popup[0].hide(); 37 | hidden = true; 38 | 39 | return true; 40 | } 41 | 42 | if (hidden) { 43 | return false; 44 | } 45 | 46 | return component.ref?.onKeyDown(props); 47 | }, 48 | onExit() { 49 | popup[0].destroy(); 50 | component.destroy(); 51 | }, 52 | }; 53 | }; 54 | 55 | export default renderItems; 56 | -------------------------------------------------------------------------------- /packages/core-react/table-of-contents/README.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | https://tiptap.dev/guide/node-views/examples#table-of-contents 4 | -------------------------------------------------------------------------------- /packages/core-react/table-of-contents/index.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from "@tiptap/core"; 2 | 3 | /** 4 | * get headings and update the id if required. 5 | * @param editor 6 | * @returns 7 | */ 8 | export function getHeadingsFrom(editor: Editor) { 9 | const headings = []; 10 | const transaction = editor.state.tr; 11 | 12 | editor.state.doc.descendants((node, pos) => { 13 | if (node.type.name === "heading") { 14 | const id = `heading-i${headings.length + 1}`; 15 | 16 | if (node.attrs.id !== id) { 17 | transaction.setNodeMarkup(pos, undefined, { 18 | ...node.attrs, 19 | id, 20 | }); 21 | } 22 | 23 | headings.push({ 24 | level: node.attrs.level, 25 | text: node.textContent, 26 | id, 27 | }); 28 | } 29 | }); 30 | 31 | transaction.setMeta("addToHistory", false); 32 | transaction.setMeta("preventUpdate", true); 33 | 34 | editor.view.dispatch(transaction); 35 | return headings; 36 | } 37 | -------------------------------------------------------------------------------- /packages/core-react/theme/font-family.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_THEME_FONT_FAMILY = `ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";`; 2 | -------------------------------------------------------------------------------- /packages/core-react/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./font-family"; 2 | -------------------------------------------------------------------------------- /packages/core-react/title/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./title"; 2 | -------------------------------------------------------------------------------- /packages/core-react/title/title.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import styled from "@emotion/styled"; 3 | import TextareaAutoresize from "react-textarea-autosize"; 4 | import { DEFAULT_THEME_FONT_FAMILY } from "../theme"; 5 | 6 | export interface TitleProps { 7 | children: string | undefined; 8 | 9 | /** 10 | * placeholder of title. - @todo - not implemented 11 | */ 12 | placeholder?: string; 13 | noplaceholder?: boolean; 14 | 15 | style?: { 16 | /** 17 | * @default start 18 | */ 19 | textAlign?: React.CSSProperties["textAlign"]; 20 | /** 21 | * @default 48px 22 | */ 23 | fontSize?: React.CSSProperties["fontSize"]; 24 | fontFamily?: React.CSSProperties["fontFamily"]; 25 | /** 26 | * @default bold 27 | */ 28 | fontWeight?: React.CSSProperties["fontWeight"]; 29 | }; 30 | 31 | /** 32 | * title is only allowed to be single line. when enter key hit, it should be handled. 33 | */ 34 | onReturn?: () => void; 35 | onChange?: (title: string) => void; 36 | } 37 | 38 | const DEFAULT_PLACEHOLDER_TEXT = "Untitled"; 39 | export function Title(props: TitleProps) { 40 | const fieldref = useRef(); 41 | 42 | const shouldReturn = (e) => { 43 | // about arrow keycodes - https://stackoverflow.com/a/5597114/5463235 44 | // 13 = return key 45 | // 40 = down key 46 | // 39 = right key 47 | const isNewLineEnter = e.keyCode === 13; 48 | const isDownKeyPress = e.keyCode === 40; 49 | const isCursorEnd = 50 | fieldref.current?.selectionEnd === fieldref.current?.value.length; 51 | const isCursorMoveRightOnEnd = e.keyCode === 39 && isCursorEnd; 52 | const isCursorMoveDownOnEnd = isDownKeyPress && isCursorEnd; 53 | 54 | if (isNewLineEnter || isCursorMoveDownOnEnd || isCursorMoveRightOnEnd) { 55 | props.onReturn?.(); 56 | 57 | // disable line break 58 | e.preventDefault(); 59 | } 60 | }; 61 | 62 | const onkeydown = (e) => { 63 | shouldReturn(e); 64 | }; 65 | 66 | const onchange = (e) => { 67 | const title = e.target.value; 68 | props.onChange?.(title); 69 | }; 70 | 71 | return ( 72 | <_Wrap> 73 | 88 | 89 | ); 90 | } 91 | 92 | const _Wrap = styled.div` 93 | max-width: 100%; 94 | `; 95 | 96 | const TitleText = styled(TextareaAutoresize)<{ 97 | textAlign?: React.CSSProperties["textAlign"]; 98 | fontSize?: React.CSSProperties["fontSize"]; 99 | fontFamily?: React.CSSProperties["fontFamily"]; 100 | fontWeight?: React.CSSProperties["fontWeight"]; 101 | }>` 102 | border: none; 103 | background: transparent; 104 | user-select: none; 105 | width: 100%; 106 | resize: none; 107 | text-align: ${(props) => props.textAlign ?? "start"}; 108 | font-size: ${(props) => props.fontSize ?? "48px"}; 109 | font-weight: ${(props) => props.fontWeight ?? "bold"}; 110 | font-family: ${(props) => props.fontFamily ?? DEFAULT_THEME_FONT_FAMILY}; 111 | 112 | :focus { 113 | outline: none; 114 | } 115 | 116 | ::placeholder { 117 | color: rgba(0, 0, 0, 0.12); 118 | } 119 | `; 120 | -------------------------------------------------------------------------------- /packages/core-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "Node", 6 | "removeComments": true, 7 | "noImplicitAny": false, 8 | "allowJs": false, 9 | "allowSyntheticDefaultImports": true, 10 | "skipDefaultLibCheck": true, 11 | "skipLibCheck": true, 12 | "experimentalDecorators": true, 13 | "importHelpers": true, 14 | "pretty": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "jsx": "react", 18 | "esModuleInterop": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "noEmitHelpers": true, 21 | "noEmitOnError": true, 22 | "noErrorTruncation": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noImplicitReturns": true, 25 | "declaration": true, 26 | "lib": ["es2018", "es2017", "esnext", "dom", "esnext.asynciterable"], 27 | "outDir": "./dist" 28 | }, 29 | "exclude": [ 30 | "node_modules", 31 | "dist", 32 | "src/__tests__", 33 | "src/**/__tests__/**/*.*", 34 | "src/**/__mocks__/**/*.*", 35 | "*.test.ts", 36 | "*.spec.ts" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-common" 4 | ] 5 | } 6 | --------------------------------------------------------------------------------