├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── app.d.ts ├── app.html ├── app.postcss ├── index.test.ts ├── lib │ ├── editor.ts │ ├── index.ts │ ├── stores │ │ └── localStorage.ts │ ├── styles │ │ ├── index.css │ │ ├── prosemirror.css │ │ ├── tailwind.config.cjs │ │ └── tailwind.css │ ├── ui │ │ ├── editor │ │ │ ├── bubble-menu │ │ │ │ ├── color-selector.svelte │ │ │ │ ├── index.svelte │ │ │ │ ├── link-selector.svelte │ │ │ │ └── node-selector.svelte │ │ │ ├── default-content.ts │ │ │ ├── extensions │ │ │ │ ├── CommandList.svelte │ │ │ │ ├── ImageResizer.svelte │ │ │ │ ├── index.ts │ │ │ │ ├── slash-command.ts │ │ │ │ └── updated-image.ts │ │ │ ├── index.svelte │ │ │ ├── plugins │ │ │ │ └── upload-images.ts │ │ │ └── props.ts │ │ ├── icons │ │ │ ├── index.ts │ │ │ ├── loading-circle.svelte │ │ │ └── magic.svelte │ │ └── toasts.svelte │ └── utils.ts └── routes │ ├── +layout.svelte │ ├── +page.svelte │ ├── api │ └── generate │ │ └── +server.ts │ ├── github.svelte │ ├── nav.svelte │ └── theme-switch.svelte ├── static ├── favicon.svg └── fonts │ └── CalSans-SemiBold.otf ├── svelte.config.js ├── tailwind.config.ts ├── tests └── test.ts ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:svelte/recommended', 7 | 'prettier' 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | extraFileExtensions: ['.svelte'] 15 | }, 16 | env: { 17 | browser: true, 18 | es2017: true, 19 | node: true 20 | }, 21 | overrides: [ 22 | { 23 | files: ['*.svelte'], 24 | parser: 'svelte-eslint-parser', 25 | parserOptions: { 26 | parser: '@typescript-eslint/parser' 27 | } 28 | } 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | .vercel 13 | .env*.local 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Thomas G. Lopes 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Novel Svelte 2 | 3 | Based on [Novel](https://github.com/steven-tey/novel) 4 | 5 | ## TODO: 6 | 7 | - [x] Slash Menus 8 | - [x] Rate Limiting/Provide OpenAPI key on demo (feature flag?) 9 | - [x] Theme control in demo 10 | - [x] Bubble Menu 11 | - [x] Toasts 12 | - [ ] Image upload support (using Appwrite) 13 | - [ ] Improve Readme 14 | - [ ] Properly test out package 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "novel-svelte", 3 | "version": "0.1.7", 4 | "scripts": { 5 | "dev": "vite dev", 6 | "build": "vite build && npm run package", 7 | "preview": "vite preview", 8 | "package": "npx tailwindcss -o ./src/lib/styles/tailwind.css && svelte-kit sync && svelte-package && publint", 9 | "prepublishOnly": "npm run package", 10 | "test": "npm run test:integration && npm run test:unit", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 14 | "format": "prettier --plugin-search-dir . --write .", 15 | "test:integration": "playwright test", 16 | "test:unit": "vitest" 17 | }, 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "svelte": "./dist/index.js" 22 | } 23 | }, 24 | "files": [ 25 | "dist", 26 | "!dist/**/*.test.*", 27 | "!dist/**/*.spec.*" 28 | ], 29 | "peerDependencies": { 30 | "svelte": "^4.0.0" 31 | }, 32 | "devDependencies": { 33 | "@playwright/test": "^1.28.1", 34 | "@sveltejs/adapter-auto": "^2.0.0", 35 | "@sveltejs/kit": "^1.20.4", 36 | "@sveltejs/package": "^2.0.0", 37 | "@tailwindcss/typography": "^0.5.9", 38 | "@typescript-eslint/eslint-plugin": "^5.45.0", 39 | "@typescript-eslint/parser": "^5.45.0", 40 | "@upstash/ratelimit": "^0.4.4", 41 | "@vercel/kv": "^0.2.2", 42 | "autoprefixer": "^10.4.14", 43 | "eslint": "^8.28.0", 44 | "eslint-config-prettier": "^8.5.0", 45 | "eslint-plugin-svelte": "^2.30.0", 46 | "postcss": "^8.4.24", 47 | "postcss-load-config": "^4.0.1", 48 | "prettier": "^2.8.0", 49 | "prettier-plugin-svelte": "^2.10.1", 50 | "publint": "^0.1.9", 51 | "svelte": "^4.0.5", 52 | "svelte-check": "^3.4.3", 53 | "tailwindcss": "^3.3.2", 54 | "tailwindcss-animate": "^1.0.7", 55 | "tslib": "^2.4.1", 56 | "typescript": "^5.0.0", 57 | "vite": "^4.4.2", 58 | "vitest": "^0.32.2" 59 | }, 60 | "svelte": "./dist/index.js", 61 | "types": "./dist/index.d.ts", 62 | "type": "module", 63 | "dependencies": { 64 | "@melt-ui/pp": "^0.1.2", 65 | "@melt-ui/svelte": "^0.42.0", 66 | "svelte-sequential-preprocessor": "^2.0.1", 67 | "@tiptap/core": "^2.1.7", 68 | "@tiptap/extension-bubble-menu": "^2.1.7", 69 | "@tiptap/extension-color": "^2.0.4", 70 | "@tiptap/extension-highlight": "^2.0.4", 71 | "@tiptap/extension-horizontal-rule": "^2.0.4", 72 | "@tiptap/extension-image": "^2.0.4", 73 | "@tiptap/extension-link": "^2.0.4", 74 | "@tiptap/extension-placeholder": "2.0.3", 75 | "@tiptap/extension-task-item": "^2.0.4", 76 | "@tiptap/extension-task-list": "^2.0.4", 77 | "@tiptap/extension-text-style": "^2.0.4", 78 | "@tiptap/extension-underline": "^2.0.4", 79 | "@tiptap/pm": "^2.1.7", 80 | "@tiptap/starter-kit": "^2.1.7", 81 | "@tiptap/suggestion": "^2.0.4", 82 | "ai": "^2.2.11", 83 | "cal-sans": "^1.0.1", 84 | "clsx": "^2.0.0", 85 | "lucide-svelte": "0.275.0-beta.0", 86 | "openai": "^4.4.0", 87 | "prettier-plugin-tailwindcss": "^0.5.4", 88 | "svelte-moveable": "^0.43.1", 89 | "tailwind-merge": "^1.14.0", 90 | "tippy.js": "^6.3.7", 91 | "tiptap-markdown": "^0.8.2" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: 'npm run build && npm run preview', 6 | port: 4173 7 | }, 8 | testDir: 'tests', 9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/ 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {} 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Novel Svelte 8 | %sveltekit.head% 9 | 10 | 11 | 27 |
%sveltekit.body%
28 | 29 | 30 | -------------------------------------------------------------------------------- /src/app.postcss: -------------------------------------------------------------------------------- 1 | /* Write your global styles here, in PostCSS syntax */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | @layer base { 7 | /* h1 { 8 | font-size: theme('fontSize.2xl'); 9 | } 10 | 11 | h2 { 12 | font-size: theme('fontSize.xl'); 13 | } */ 14 | 15 | body { 16 | background-color: var(--novel-white); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/editor.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/core'; 2 | 3 | export const getPrevText = ( 4 | editor: Editor, 5 | { 6 | chars, 7 | offset = 0 8 | }: { 9 | chars: number; 10 | offset?: number; 11 | } 12 | ) => { 13 | // for now, we're using textBetween for now until we can figure out a way to stream markdown text 14 | // with proper formatting: https://github.com/steven-tey/novel/discussions/7 15 | return editor.state.doc.textBetween( 16 | Math.max(0, editor.state.selection.from - chars), 17 | editor.state.selection.from - offset, 18 | '\n' 19 | ); 20 | // complete(editor.storage.markdown.getMarkdown()); 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Editor } from './ui/editor/index.svelte'; 2 | export { Editor as EditorType } from '@tiptap/core'; 3 | -------------------------------------------------------------------------------- /src/lib/stores/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from '$lib/utils.js'; 2 | import { onDestroy } from 'svelte'; 3 | import { writable } from 'svelte/store'; 4 | 5 | export const createLocalStorageStore = (key: string, initialValue: T) => { 6 | const store = writable(initialValue); 7 | try { 8 | store.set( 9 | isBrowser() && localStorage.getItem(key) 10 | ? JSON.parse(localStorage.getItem(key) as string) 11 | : initialValue 12 | ); 13 | } catch (e) { 14 | // Do nothing 15 | } 16 | onDestroy( 17 | store.subscribe((v) => { 18 | if (!isBrowser()) return; 19 | localStorage.setItem(key, JSON.stringify(v)); 20 | }) 21 | ); 22 | 23 | return store; 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/styles/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --novel-black: rgb(0 0 0); 3 | --novel-white: rgb(255 255 255); 4 | --novel-stone-50: rgb(250 250 249); 5 | --novel-stone-100: rgb(245 245 244); 6 | --novel-stone-200: rgb(231 229 228); 7 | --novel-stone-300: rgb(214 211 209); 8 | --novel-stone-400: rgb(168 162 158); 9 | --novel-stone-500: rgb(120 113 108); 10 | --novel-stone-600: rgb(87 83 78); 11 | --novel-stone-700: rgb(68 64 60); 12 | --novel-stone-800: rgb(41 37 36); 13 | --novel-stone-900: rgb(28 25 23); 14 | 15 | --novel-highlight-default: #ffffff; 16 | --novel-highlight-purple: #f6f3f8; 17 | --novel-highlight-red: #fdebeb; 18 | --novel-highlight-yellow: #fbf4a2; 19 | --novel-highlight-blue: #c1ecf9; 20 | --novel-highlight-green: #acf79f; 21 | --novel-highlight-orange: #faebdd; 22 | --novel-highlight-pink: #faf1f5; 23 | --novel-highlight-gray: #f1f1ef; 24 | 25 | --font-title: 'Cal Sans', sans-serif; 26 | } 27 | 28 | .dark-theme { 29 | --novel-black: rgb(255 255 255); 30 | --novel-white: rgb(25 25 25); 31 | --novel-stone-50: rgb(35 35 34); 32 | --novel-stone-100: rgb(41 37 36); 33 | --novel-stone-200: rgb(66 69 71); 34 | --novel-stone-300: rgb(112 118 123); 35 | --novel-stone-400: rgb(160 167 173); 36 | --novel-stone-500: rgb(193 199 204); 37 | --novel-stone-600: rgb(212 217 221); 38 | --novel-stone-700: rgb(229 232 235); 39 | --novel-stone-800: rgb(232 234 235); 40 | --novel-stone-900: rgb(240, 240, 241); 41 | 42 | --novel-highlight-default: #000000; 43 | --novel-highlight-purple: #3f2c4b; 44 | --novel-highlight-red: #5c1a1a; 45 | --novel-highlight-yellow: #5c4b1a; 46 | --novel-highlight-blue: #1a3d5c; 47 | --novel-highlight-green: #1a5c20; 48 | --novel-highlight-orange: #5c3a1a; 49 | --novel-highlight-pink: #5c1a3a; 50 | --novel-highlight-gray: #3a3a3a; 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/styles/prosemirror.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | height: 100%; 3 | } 4 | 5 | .ProseMirror .is-editor-empty:first-child::before { 6 | content: attr(data-placeholder); 7 | float: left; 8 | color: var(--novel-stone-400); 9 | pointer-events: none; 10 | height: 0; 11 | } 12 | .ProseMirror .is-empty::before { 13 | content: attr(data-placeholder); 14 | float: left; 15 | color: var(--novel-stone-400); 16 | pointer-events: none; 17 | height: 0; 18 | } 19 | 20 | /* Custom image styles */ 21 | 22 | .ProseMirror img { 23 | transition: filter 0.1s ease-in-out; 24 | 25 | &:hover { 26 | cursor: pointer; 27 | filter: brightness(90%); 28 | } 29 | 30 | &.ProseMirror-selectednode { 31 | outline: 3px solid #5abbf7; 32 | filter: brightness(90%); 33 | } 34 | } 35 | 36 | .img-placeholder { 37 | position: relative; 38 | 39 | &:before { 40 | content: ''; 41 | box-sizing: border-box; 42 | position: absolute; 43 | top: 50%; 44 | left: 50%; 45 | width: 36px; 46 | height: 36px; 47 | border-radius: 50%; 48 | border: 3px solid var(--novel-stone-200); 49 | border-top-color: var(--novel-stone-800); 50 | animation: spinning 0.6s linear infinite; 51 | } 52 | } 53 | 54 | @keyframes spinning { 55 | to { 56 | transform: rotate(360deg); 57 | } 58 | } 59 | 60 | /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ 61 | 62 | ul[data-type='taskList'] li > label { 63 | margin-right: 0.2rem; 64 | user-select: none; 65 | } 66 | 67 | @media screen and (max-width: 768px) { 68 | ul[data-type='taskList'] li > label { 69 | margin-right: 0.5rem; 70 | } 71 | } 72 | 73 | ul[data-type='taskList'] li > label input[type='checkbox'] { 74 | -webkit-appearance: none; 75 | appearance: none; 76 | background-color: var(--novel-white); 77 | margin: 0; 78 | cursor: pointer; 79 | width: 1.2em; 80 | height: 1.2em; 81 | position: relative; 82 | top: 5px; 83 | border: 2px solid var(--novel-stone-900); 84 | margin-right: 0.3rem; 85 | display: grid; 86 | place-content: center; 87 | 88 | &:hover { 89 | background-color: var(--novel-stone-50); 90 | } 91 | 92 | &:active { 93 | background-color: var(--novel-stone-200); 94 | } 95 | 96 | &::before { 97 | content: ''; 98 | width: 0.65em; 99 | height: 0.65em; 100 | transform: scale(0); 101 | transition: 120ms transform ease-in-out; 102 | box-shadow: inset 1em 1em; 103 | transform-origin: center; 104 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 105 | } 106 | 107 | &:checked::before { 108 | transform: scale(1); 109 | } 110 | } 111 | 112 | ul[data-type='taskList'] li[data-checked='true'] > div > p { 113 | color: var(--novel-stone-400); 114 | text-decoration: line-through; 115 | text-decoration-thickness: 2px; 116 | } 117 | 118 | /* Overwrite tippy-box original max-width */ 119 | 120 | .tippy-box { 121 | max-width: 400px !important; 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/styles/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config}*/ 2 | const config = { 3 | content: ['./src/**/*.{html,js,svelte,ts}'], 4 | 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | title: ['var(--font-title)', 'system-ui', 'sans-serif'], 9 | default: ['var(--font-default)', 'system-ui', 'sans-serif'] 10 | }, 11 | colors: { 12 | white: 'var(--novel-white)', 13 | stone: { 14 | 50: 'var(--novel-stone-50)', 15 | 100: 'var(--novel-stone-100)', 16 | 200: 'var(--novel-stone-200)', 17 | 300: 'var(--novel-stone-300)', 18 | 400: 'var(--novel-stone-400)', 19 | 500: 'var(--novel-stone-500)', 20 | 600: 'var(--novel-stone-600)', 21 | 700: 'var(--novel-stone-700)', 22 | 800: 'var(--novel-stone-800)', 23 | 900: 'var(--novel-stone-900)' 24 | } 25 | } 26 | } 27 | }, 28 | plugins: [ 29 | // Tailwind plugins 30 | require('@tailwindcss/typography'), 31 | require('tailwindcss-animate') 32 | ] 33 | }; 34 | 35 | module.exports = config; 36 | -------------------------------------------------------------------------------- /src/lib/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | */ 36 | 37 | html { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | -o-tab-size: 4; 45 | tab-size: 4; 46 | /* 3 */ 47 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | } 54 | 55 | /* 56 | 1. Remove the margin in all browsers. 57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 58 | */ 59 | 60 | body { 61 | margin: 0; 62 | /* 1 */ 63 | line-height: inherit; 64 | /* 2 */ 65 | } 66 | 67 | /* 68 | 1. Add the correct height in Firefox. 69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 70 | 3. Ensure horizontal rules are visible by default. 71 | */ 72 | 73 | hr { 74 | height: 0; 75 | /* 1 */ 76 | color: inherit; 77 | /* 2 */ 78 | border-top-width: 1px; 79 | /* 3 */ 80 | } 81 | 82 | /* 83 | Add the correct text decoration in Chrome, Edge, and Safari. 84 | */ 85 | 86 | abbr:where([title]) { 87 | -webkit-text-decoration: underline dotted; 88 | text-decoration: underline dotted; 89 | } 90 | 91 | /* 92 | Remove the default font size and weight for headings. 93 | */ 94 | 95 | h1, 96 | h2, 97 | h3, 98 | h4, 99 | h5, 100 | h6 { 101 | font-size: inherit; 102 | font-weight: inherit; 103 | } 104 | 105 | /* 106 | Reset links to optimize for opt-in styling instead of opt-out. 107 | */ 108 | 109 | a { 110 | color: inherit; 111 | text-decoration: inherit; 112 | } 113 | 114 | /* 115 | Add the correct font weight in Edge and Safari. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | /* 124 | 1. Use the user's configured `mono` font family by default. 125 | 2. Correct the odd `em` font sizing in all browsers. 126 | */ 127 | 128 | code, 129 | kbd, 130 | samp, 131 | pre { 132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 133 | /* 1 */ 134 | font-size: 1em; 135 | /* 2 */ 136 | } 137 | 138 | /* 139 | Add the correct font size in all browsers. 140 | */ 141 | 142 | small { 143 | font-size: 80%; 144 | } 145 | 146 | /* 147 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 148 | */ 149 | 150 | sub, 151 | sup { 152 | font-size: 75%; 153 | line-height: 0; 154 | position: relative; 155 | vertical-align: baseline; 156 | } 157 | 158 | sub { 159 | bottom: -0.25em; 160 | } 161 | 162 | sup { 163 | top: -0.5em; 164 | } 165 | 166 | /* 167 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 168 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 169 | 3. Remove gaps between table borders by default. 170 | */ 171 | 172 | table { 173 | text-indent: 0; 174 | /* 1 */ 175 | border-color: inherit; 176 | /* 2 */ 177 | border-collapse: collapse; 178 | /* 3 */ 179 | } 180 | 181 | /* 182 | 1. Change the font styles in all browsers. 183 | 2. Remove the margin in Firefox and Safari. 184 | 3. Remove default padding in all browsers. 185 | */ 186 | 187 | button, 188 | input, 189 | optgroup, 190 | select, 191 | textarea { 192 | font-family: inherit; 193 | /* 1 */ 194 | font-feature-settings: inherit; 195 | /* 1 */ 196 | font-variation-settings: inherit; 197 | /* 1 */ 198 | font-size: 100%; 199 | /* 1 */ 200 | font-weight: inherit; 201 | /* 1 */ 202 | line-height: inherit; 203 | /* 1 */ 204 | color: inherit; 205 | /* 1 */ 206 | margin: 0; 207 | /* 2 */ 208 | padding: 0; 209 | /* 3 */ 210 | } 211 | 212 | /* 213 | Remove the inheritance of text transform in Edge and Firefox. 214 | */ 215 | 216 | button, 217 | select { 218 | text-transform: none; 219 | } 220 | 221 | /* 222 | 1. Correct the inability to style clickable types in iOS and Safari. 223 | 2. Remove default button styles. 224 | */ 225 | 226 | button, 227 | [type='button'], 228 | [type='reset'], 229 | [type='submit'] { 230 | -webkit-appearance: button; 231 | /* 1 */ 232 | background-color: transparent; 233 | /* 2 */ 234 | background-image: none; 235 | /* 2 */ 236 | } 237 | 238 | /* 239 | Use the modern Firefox focus style for all focusable elements. 240 | */ 241 | 242 | :-moz-focusring { 243 | outline: auto; 244 | } 245 | 246 | /* 247 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 248 | */ 249 | 250 | :-moz-ui-invalid { 251 | box-shadow: none; 252 | } 253 | 254 | /* 255 | Add the correct vertical alignment in Chrome and Firefox. 256 | */ 257 | 258 | progress { 259 | vertical-align: baseline; 260 | } 261 | 262 | /* 263 | Correct the cursor style of increment and decrement buttons in Safari. 264 | */ 265 | 266 | ::-webkit-inner-spin-button, 267 | ::-webkit-outer-spin-button { 268 | height: auto; 269 | } 270 | 271 | /* 272 | 1. Correct the odd appearance in Chrome and Safari. 273 | 2. Correct the outline style in Safari. 274 | */ 275 | 276 | [type='search'] { 277 | -webkit-appearance: textfield; 278 | /* 1 */ 279 | outline-offset: -2px; 280 | /* 2 */ 281 | } 282 | 283 | /* 284 | Remove the inner padding in Chrome and Safari on macOS. 285 | */ 286 | 287 | ::-webkit-search-decoration { 288 | -webkit-appearance: none; 289 | } 290 | 291 | /* 292 | 1. Correct the inability to style clickable types in iOS and Safari. 293 | 2. Change font properties to `inherit` in Safari. 294 | */ 295 | 296 | ::-webkit-file-upload-button { 297 | -webkit-appearance: button; 298 | /* 1 */ 299 | font: inherit; 300 | /* 2 */ 301 | } 302 | 303 | /* 304 | Add the correct display in Chrome and Safari. 305 | */ 306 | 307 | summary { 308 | display: list-item; 309 | } 310 | 311 | /* 312 | Removes the default spacing and border for appropriate elements. 313 | */ 314 | 315 | blockquote, 316 | dl, 317 | dd, 318 | h1, 319 | h2, 320 | h3, 321 | h4, 322 | h5, 323 | h6, 324 | hr, 325 | figure, 326 | p, 327 | pre { 328 | margin: 0; 329 | } 330 | 331 | fieldset { 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | legend { 337 | padding: 0; 338 | } 339 | 340 | ol, 341 | ul, 342 | menu { 343 | list-style: none; 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | /* 349 | Reset default styling for dialogs. 350 | */ 351 | 352 | dialog { 353 | padding: 0; 354 | } 355 | 356 | /* 357 | Prevent resizing textareas horizontally by default. 358 | */ 359 | 360 | textarea { 361 | resize: vertical; 362 | } 363 | 364 | /* 365 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 366 | 2. Set the default placeholder color to the user's configured gray 400 color. 367 | */ 368 | 369 | input::-moz-placeholder, textarea::-moz-placeholder { 370 | opacity: 1; 371 | /* 1 */ 372 | color: #9ca3af; 373 | /* 2 */ 374 | } 375 | 376 | input::placeholder, 377 | textarea::placeholder { 378 | opacity: 1; 379 | /* 1 */ 380 | color: #9ca3af; 381 | /* 2 */ 382 | } 383 | 384 | /* 385 | Set the default cursor for buttons. 386 | */ 387 | 388 | button, 389 | [role="button"] { 390 | cursor: pointer; 391 | } 392 | 393 | /* 394 | Make sure disabled buttons don't get the pointer cursor. 395 | */ 396 | 397 | :disabled { 398 | cursor: default; 399 | } 400 | 401 | /* 402 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 403 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 404 | This can trigger a poorly considered lint error in some tools but is included by design. 405 | */ 406 | 407 | img, 408 | svg, 409 | video, 410 | canvas, 411 | audio, 412 | iframe, 413 | embed, 414 | object { 415 | display: block; 416 | /* 1 */ 417 | vertical-align: middle; 418 | /* 2 */ 419 | } 420 | 421 | /* 422 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 423 | */ 424 | 425 | img, 426 | video { 427 | max-width: 100%; 428 | height: auto; 429 | } 430 | 431 | /* Make elements with the HTML hidden attribute stay hidden by default */ 432 | 433 | [hidden] { 434 | display: none; 435 | } 436 | 437 | *, ::before, ::after { 438 | --tw-border-spacing-x: 0; 439 | --tw-border-spacing-y: 0; 440 | --tw-translate-x: 0; 441 | --tw-translate-y: 0; 442 | --tw-rotate: 0; 443 | --tw-skew-x: 0; 444 | --tw-skew-y: 0; 445 | --tw-scale-x: 1; 446 | --tw-scale-y: 1; 447 | --tw-pan-x: ; 448 | --tw-pan-y: ; 449 | --tw-pinch-zoom: ; 450 | --tw-scroll-snap-strictness: proximity; 451 | --tw-gradient-from-position: ; 452 | --tw-gradient-via-position: ; 453 | --tw-gradient-to-position: ; 454 | --tw-ordinal: ; 455 | --tw-slashed-zero: ; 456 | --tw-numeric-figure: ; 457 | --tw-numeric-spacing: ; 458 | --tw-numeric-fraction: ; 459 | --tw-ring-inset: ; 460 | --tw-ring-offset-width: 0px; 461 | --tw-ring-offset-color: #fff; 462 | --tw-ring-color: rgb(59 130 246 / 0.5); 463 | --tw-ring-offset-shadow: 0 0 #0000; 464 | --tw-ring-shadow: 0 0 #0000; 465 | --tw-shadow: 0 0 #0000; 466 | --tw-shadow-colored: 0 0 #0000; 467 | --tw-blur: ; 468 | --tw-brightness: ; 469 | --tw-contrast: ; 470 | --tw-grayscale: ; 471 | --tw-hue-rotate: ; 472 | --tw-invert: ; 473 | --tw-saturate: ; 474 | --tw-sepia: ; 475 | --tw-drop-shadow: ; 476 | --tw-backdrop-blur: ; 477 | --tw-backdrop-brightness: ; 478 | --tw-backdrop-contrast: ; 479 | --tw-backdrop-grayscale: ; 480 | --tw-backdrop-hue-rotate: ; 481 | --tw-backdrop-invert: ; 482 | --tw-backdrop-opacity: ; 483 | --tw-backdrop-saturate: ; 484 | --tw-backdrop-sepia: ; 485 | } 486 | 487 | ::backdrop { 488 | --tw-border-spacing-x: 0; 489 | --tw-border-spacing-y: 0; 490 | --tw-translate-x: 0; 491 | --tw-translate-y: 0; 492 | --tw-rotate: 0; 493 | --tw-skew-x: 0; 494 | --tw-skew-y: 0; 495 | --tw-scale-x: 1; 496 | --tw-scale-y: 1; 497 | --tw-pan-x: ; 498 | --tw-pan-y: ; 499 | --tw-pinch-zoom: ; 500 | --tw-scroll-snap-strictness: proximity; 501 | --tw-gradient-from-position: ; 502 | --tw-gradient-via-position: ; 503 | --tw-gradient-to-position: ; 504 | --tw-ordinal: ; 505 | --tw-slashed-zero: ; 506 | --tw-numeric-figure: ; 507 | --tw-numeric-spacing: ; 508 | --tw-numeric-fraction: ; 509 | --tw-ring-inset: ; 510 | --tw-ring-offset-width: 0px; 511 | --tw-ring-offset-color: #fff; 512 | --tw-ring-color: rgb(59 130 246 / 0.5); 513 | --tw-ring-offset-shadow: 0 0 #0000; 514 | --tw-ring-shadow: 0 0 #0000; 515 | --tw-shadow: 0 0 #0000; 516 | --tw-shadow-colored: 0 0 #0000; 517 | --tw-blur: ; 518 | --tw-brightness: ; 519 | --tw-contrast: ; 520 | --tw-grayscale: ; 521 | --tw-hue-rotate: ; 522 | --tw-invert: ; 523 | --tw-saturate: ; 524 | --tw-sepia: ; 525 | --tw-drop-shadow: ; 526 | --tw-backdrop-blur: ; 527 | --tw-backdrop-brightness: ; 528 | --tw-backdrop-contrast: ; 529 | --tw-backdrop-grayscale: ; 530 | --tw-backdrop-hue-rotate: ; 531 | --tw-backdrop-invert: ; 532 | --tw-backdrop-opacity: ; 533 | --tw-backdrop-saturate: ; 534 | --tw-backdrop-sepia: ; 535 | } 536 | 537 | .container { 538 | width: 100%; 539 | } 540 | 541 | @media (min-width: 640px) { 542 | .container { 543 | max-width: 640px; 544 | } 545 | } 546 | 547 | @media (min-width: 768px) { 548 | .container { 549 | max-width: 768px; 550 | } 551 | } 552 | 553 | @media (min-width: 1024px) { 554 | .container { 555 | max-width: 1024px; 556 | } 557 | } 558 | 559 | @media (min-width: 1280px) { 560 | .container { 561 | max-width: 1280px; 562 | } 563 | } 564 | 565 | @media (min-width: 1536px) { 566 | .container { 567 | max-width: 1536px; 568 | } 569 | } 570 | 571 | .prose-lg { 572 | font-size: 1.125rem; 573 | line-height: 1.7777778; 574 | } 575 | 576 | .prose-lg :where(p):not(:where([class~="not-prose"] *)) { 577 | margin-top: 1.3333333em; 578 | margin-bottom: 1.3333333em; 579 | } 580 | 581 | .prose-lg :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 582 | font-size: 1.2222222em; 583 | line-height: 1.4545455; 584 | margin-top: 1.0909091em; 585 | margin-bottom: 1.0909091em; 586 | } 587 | 588 | .prose-lg :where(blockquote):not(:where([class~="not-prose"] *)) { 589 | margin-top: 1.6666667em; 590 | margin-bottom: 1.6666667em; 591 | padding-left: 1em; 592 | } 593 | 594 | .prose-lg :where(h1):not(:where([class~="not-prose"] *)) { 595 | font-size: 2.6666667em; 596 | margin-top: 0; 597 | margin-bottom: 0.8333333em; 598 | line-height: 1; 599 | } 600 | 601 | .prose-lg :where(h2):not(:where([class~="not-prose"] *)) { 602 | font-size: 1.6666667em; 603 | margin-top: 1.8666667em; 604 | margin-bottom: 1.0666667em; 605 | line-height: 1.3333333; 606 | } 607 | 608 | .prose-lg :where(h3):not(:where([class~="not-prose"] *)) { 609 | font-size: 1.3333333em; 610 | margin-top: 1.6666667em; 611 | margin-bottom: 0.6666667em; 612 | line-height: 1.5; 613 | } 614 | 615 | .prose-lg :where(h4):not(:where([class~="not-prose"] *)) { 616 | margin-top: 1.7777778em; 617 | margin-bottom: 0.4444444em; 618 | line-height: 1.5555556; 619 | } 620 | 621 | .prose-lg :where(img):not(:where([class~="not-prose"] *)) { 622 | margin-top: 1.7777778em; 623 | margin-bottom: 1.7777778em; 624 | } 625 | 626 | .prose-lg :where(video):not(:where([class~="not-prose"] *)) { 627 | margin-top: 1.7777778em; 628 | margin-bottom: 1.7777778em; 629 | } 630 | 631 | .prose-lg :where(figure):not(:where([class~="not-prose"] *)) { 632 | margin-top: 1.7777778em; 633 | margin-bottom: 1.7777778em; 634 | } 635 | 636 | .prose-lg :where(figure > *):not(:where([class~="not-prose"] *)) { 637 | margin-top: 0; 638 | margin-bottom: 0; 639 | } 640 | 641 | .prose-lg :where(figcaption):not(:where([class~="not-prose"] *)) { 642 | font-size: 0.8888889em; 643 | line-height: 1.5; 644 | margin-top: 1em; 645 | } 646 | 647 | .prose-lg :where(code):not(:where([class~="not-prose"] *)) { 648 | font-size: 0.8888889em; 649 | } 650 | 651 | .prose-lg :where(h2 code):not(:where([class~="not-prose"] *)) { 652 | font-size: 0.8666667em; 653 | } 654 | 655 | .prose-lg :where(h3 code):not(:where([class~="not-prose"] *)) { 656 | font-size: 0.875em; 657 | } 658 | 659 | .prose-lg :where(pre):not(:where([class~="not-prose"] *)) { 660 | font-size: 0.8888889em; 661 | line-height: 1.75; 662 | margin-top: 2em; 663 | margin-bottom: 2em; 664 | border-radius: 0.375rem; 665 | padding-top: 1em; 666 | padding-right: 1.5em; 667 | padding-bottom: 1em; 668 | padding-left: 1.5em; 669 | } 670 | 671 | .prose-lg :where(ol):not(:where([class~="not-prose"] *)) { 672 | margin-top: 1.3333333em; 673 | margin-bottom: 1.3333333em; 674 | padding-left: 1.5555556em; 675 | } 676 | 677 | .prose-lg :where(ul):not(:where([class~="not-prose"] *)) { 678 | margin-top: 1.3333333em; 679 | margin-bottom: 1.3333333em; 680 | padding-left: 1.5555556em; 681 | } 682 | 683 | .prose-lg :where(li):not(:where([class~="not-prose"] *)) { 684 | margin-top: 0.6666667em; 685 | margin-bottom: 0.6666667em; 686 | } 687 | 688 | .prose-lg :where(ol > li):not(:where([class~="not-prose"] *)) { 689 | padding-left: 0.4444444em; 690 | } 691 | 692 | .prose-lg :where(ul > li):not(:where([class~="not-prose"] *)) { 693 | padding-left: 0.4444444em; 694 | } 695 | 696 | .prose-lg :where(.prose-lg > ul > li p):not(:where([class~="not-prose"] *)) { 697 | margin-top: 0.8888889em; 698 | margin-bottom: 0.8888889em; 699 | } 700 | 701 | .prose-lg :where(.prose-lg > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 702 | margin-top: 1.3333333em; 703 | } 704 | 705 | .prose-lg :where(.prose-lg > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 706 | margin-bottom: 1.3333333em; 707 | } 708 | 709 | .prose-lg :where(.prose-lg > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 710 | margin-top: 1.3333333em; 711 | } 712 | 713 | .prose-lg :where(.prose-lg > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 714 | margin-bottom: 1.3333333em; 715 | } 716 | 717 | .prose-lg :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 718 | margin-top: 0.8888889em; 719 | margin-bottom: 0.8888889em; 720 | } 721 | 722 | .prose-lg :where(hr):not(:where([class~="not-prose"] *)) { 723 | margin-top: 3.1111111em; 724 | margin-bottom: 3.1111111em; 725 | } 726 | 727 | .prose-lg :where(hr + *):not(:where([class~="not-prose"] *)) { 728 | margin-top: 0; 729 | } 730 | 731 | .prose-lg :where(h2 + *):not(:where([class~="not-prose"] *)) { 732 | margin-top: 0; 733 | } 734 | 735 | .prose-lg :where(h3 + *):not(:where([class~="not-prose"] *)) { 736 | margin-top: 0; 737 | } 738 | 739 | .prose-lg :where(h4 + *):not(:where([class~="not-prose"] *)) { 740 | margin-top: 0; 741 | } 742 | 743 | .prose-lg :where(table):not(:where([class~="not-prose"] *)) { 744 | font-size: 0.8888889em; 745 | line-height: 1.5; 746 | } 747 | 748 | .prose-lg :where(thead th):not(:where([class~="not-prose"] *)) { 749 | padding-right: 0.75em; 750 | padding-bottom: 0.75em; 751 | padding-left: 0.75em; 752 | } 753 | 754 | .prose-lg :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 755 | padding-left: 0; 756 | } 757 | 758 | .prose-lg :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 759 | padding-right: 0; 760 | } 761 | 762 | .prose-lg :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 763 | padding-top: 0.75em; 764 | padding-right: 0.75em; 765 | padding-bottom: 0.75em; 766 | padding-left: 0.75em; 767 | } 768 | 769 | .prose-lg :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 770 | padding-left: 0; 771 | } 772 | 773 | .prose-lg :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 774 | padding-right: 0; 775 | } 776 | 777 | .prose-lg :where(.prose-lg > :first-child):not(:where([class~="not-prose"] *)) { 778 | margin-top: 0; 779 | } 780 | 781 | .prose-lg :where(.prose-lg > :last-child):not(:where([class~="not-prose"] *)) { 782 | margin-bottom: 0; 783 | } 784 | 785 | .prose-stone { 786 | --tw-prose-body: #44403c; 787 | --tw-prose-headings: #1c1917; 788 | --tw-prose-lead: #57534e; 789 | --tw-prose-links: #1c1917; 790 | --tw-prose-bold: #1c1917; 791 | --tw-prose-counters: #78716c; 792 | --tw-prose-bullets: #d6d3d1; 793 | --tw-prose-hr: #e7e5e4; 794 | --tw-prose-quotes: #1c1917; 795 | --tw-prose-quote-borders: #e7e5e4; 796 | --tw-prose-captions: #78716c; 797 | --tw-prose-code: #1c1917; 798 | --tw-prose-pre-code: #e7e5e4; 799 | --tw-prose-pre-bg: #292524; 800 | --tw-prose-th-borders: #d6d3d1; 801 | --tw-prose-td-borders: #e7e5e4; 802 | --tw-prose-invert-body: #d6d3d1; 803 | --tw-prose-invert-headings: #fff; 804 | --tw-prose-invert-lead: #a8a29e; 805 | --tw-prose-invert-links: #fff; 806 | --tw-prose-invert-bold: #fff; 807 | --tw-prose-invert-counters: #a8a29e; 808 | --tw-prose-invert-bullets: #57534e; 809 | --tw-prose-invert-hr: #44403c; 810 | --tw-prose-invert-quotes: #f5f5f4; 811 | --tw-prose-invert-quote-borders: #44403c; 812 | --tw-prose-invert-captions: #a8a29e; 813 | --tw-prose-invert-code: #fff; 814 | --tw-prose-invert-pre-code: #d6d3d1; 815 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 816 | --tw-prose-invert-th-borders: #57534e; 817 | --tw-prose-invert-td-borders: #44403c; 818 | } 819 | 820 | .sr-only { 821 | position: absolute; 822 | width: 1px; 823 | height: 1px; 824 | padding: 0; 825 | margin: -1px; 826 | overflow: hidden; 827 | clip: rect(0, 0, 0, 0); 828 | white-space: nowrap; 829 | border-width: 0; 830 | } 831 | 832 | .fixed { 833 | position: fixed; 834 | } 835 | 836 | .absolute { 837 | position: absolute; 838 | } 839 | 840 | .relative { 841 | position: relative; 842 | } 843 | 844 | .bottom-0 { 845 | bottom: 0px; 846 | } 847 | 848 | .right-0 { 849 | right: 0px; 850 | } 851 | 852 | .right-5 { 853 | right: 1.25rem; 854 | } 855 | 856 | .top-0 { 857 | top: 0px; 858 | } 859 | 860 | .top-5 { 861 | top: 1.25rem; 862 | } 863 | 864 | .top-full { 865 | top: 100%; 866 | } 867 | 868 | .z-10 { 869 | z-index: 10; 870 | } 871 | 872 | .z-50 { 873 | z-index: 50; 874 | } 875 | 876 | .z-\[999999\] { 877 | z-index: 999999; 878 | } 879 | 880 | .z-\[99999\] { 881 | z-index: 99999; 882 | } 883 | 884 | .z-\[9999\] { 885 | z-index: 9999; 886 | } 887 | 888 | .m-4 { 889 | margin: 1rem; 890 | } 891 | 892 | .my-1 { 893 | margin-top: 0.25rem; 894 | margin-bottom: 0.25rem; 895 | } 896 | 897 | .my-4 { 898 | margin-top: 1rem; 899 | margin-bottom: 1rem; 900 | } 901 | 902 | .-mb-2 { 903 | margin-bottom: -0.5rem; 904 | } 905 | 906 | .-mt-2 { 907 | margin-top: -0.5rem; 908 | } 909 | 910 | .mb-1 { 911 | margin-bottom: 0.25rem; 912 | } 913 | 914 | .mb-5 { 915 | margin-bottom: 1.25rem; 916 | } 917 | 918 | .mb-6 { 919 | margin-bottom: 1.5rem; 920 | } 921 | 922 | .ml-auto { 923 | margin-left: auto; 924 | } 925 | 926 | .mt-1 { 927 | margin-top: 0.25rem; 928 | } 929 | 930 | .mt-2 { 931 | margin-top: 0.5rem; 932 | } 933 | 934 | .mt-4 { 935 | margin-top: 1rem; 936 | } 937 | 938 | .block { 939 | display: block; 940 | } 941 | 942 | .flex { 943 | display: flex; 944 | } 945 | 946 | .h-10 { 947 | height: 2.5rem; 948 | } 949 | 950 | .h-3 { 951 | height: 0.75rem; 952 | } 953 | 954 | .h-4 { 955 | height: 1rem; 956 | } 957 | 958 | .h-auto { 959 | height: auto; 960 | } 961 | 962 | .h-full { 963 | height: 100%; 964 | } 965 | 966 | .max-h-80 { 967 | max-height: 20rem; 968 | } 969 | 970 | .max-h-\[330px\] { 971 | max-height: 330px; 972 | } 973 | 974 | .min-h-\[500px\] { 975 | min-height: 500px; 976 | } 977 | 978 | .w-10 { 979 | width: 2.5rem; 980 | } 981 | 982 | .w-3 { 983 | width: 0.75rem; 984 | } 985 | 986 | .w-4 { 987 | width: 1rem; 988 | } 989 | 990 | .w-48 { 991 | width: 12rem; 992 | } 993 | 994 | .w-60 { 995 | width: 15rem; 996 | } 997 | 998 | .w-72 { 999 | width: 18rem; 1000 | } 1001 | 1002 | .w-\[24rem\] { 1003 | width: 24rem; 1004 | } 1005 | 1006 | .w-fit { 1007 | width: -moz-fit-content; 1008 | width: fit-content; 1009 | } 1010 | 1011 | .w-full { 1012 | width: 100%; 1013 | } 1014 | 1015 | .min-w-\[200px\] { 1016 | min-width: 200px; 1017 | } 1018 | 1019 | .max-w-\[calc\(100vw-2rem\)\] { 1020 | max-width: calc(100vw - 2rem); 1021 | } 1022 | 1023 | .max-w-full { 1024 | max-width: 100%; 1025 | } 1026 | 1027 | .max-w-screen-lg { 1028 | max-width: 1024px; 1029 | } 1030 | 1031 | .flex-1 { 1032 | flex: 1 1 0%; 1033 | } 1034 | 1035 | .transform { 1036 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1037 | } 1038 | 1039 | @keyframes spin { 1040 | to { 1041 | transform: rotate(360deg); 1042 | } 1043 | } 1044 | 1045 | .animate-spin { 1046 | animation: spin 1s linear infinite; 1047 | } 1048 | 1049 | .cursor-pointer { 1050 | cursor: pointer; 1051 | } 1052 | 1053 | .scroll-my-2 { 1054 | scroll-margin-top: 0.5rem; 1055 | scroll-margin-bottom: 0.5rem; 1056 | } 1057 | 1058 | .list-outside { 1059 | list-style-position: outside; 1060 | } 1061 | 1062 | .list-decimal { 1063 | list-style-type: decimal; 1064 | } 1065 | 1066 | .list-disc { 1067 | list-style-type: disc; 1068 | } 1069 | 1070 | .flex-col { 1071 | flex-direction: column; 1072 | } 1073 | 1074 | .items-start { 1075 | align-items: flex-start; 1076 | } 1077 | 1078 | .items-end { 1079 | align-items: flex-end; 1080 | } 1081 | 1082 | .items-center { 1083 | align-items: center; 1084 | } 1085 | 1086 | .justify-center { 1087 | justify-content: center; 1088 | } 1089 | 1090 | .justify-between { 1091 | justify-content: space-between; 1092 | } 1093 | 1094 | .gap-1 { 1095 | gap: 0.25rem; 1096 | } 1097 | 1098 | .gap-2 { 1099 | gap: 0.5rem; 1100 | } 1101 | 1102 | .space-x-2 > :not([hidden]) ~ :not([hidden]) { 1103 | --tw-space-x-reverse: 0; 1104 | margin-right: calc(0.5rem * var(--tw-space-x-reverse)); 1105 | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); 1106 | } 1107 | 1108 | .divide-x > :not([hidden]) ~ :not([hidden]) { 1109 | --tw-divide-x-reverse: 0; 1110 | border-right-width: calc(1px * var(--tw-divide-x-reverse)); 1111 | border-left-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); 1112 | } 1113 | 1114 | .divide-stone-200 > :not([hidden]) ~ :not([hidden]) { 1115 | border-color: var(--novel-stone-200); 1116 | } 1117 | 1118 | .overflow-hidden { 1119 | overflow: hidden; 1120 | } 1121 | 1122 | .overflow-y-auto { 1123 | overflow-y: auto; 1124 | } 1125 | 1126 | .whitespace-nowrap { 1127 | white-space: nowrap; 1128 | } 1129 | 1130 | .rounded { 1131 | border-radius: 0.25rem; 1132 | } 1133 | 1134 | .rounded-\[5px\] { 1135 | border-radius: 5px; 1136 | } 1137 | 1138 | .rounded-lg { 1139 | border-radius: 0.5rem; 1140 | } 1141 | 1142 | .rounded-md { 1143 | border-radius: 0.375rem; 1144 | } 1145 | 1146 | .rounded-sm { 1147 | border-radius: 0.125rem; 1148 | } 1149 | 1150 | .rounded-xl { 1151 | border-radius: 0.75rem; 1152 | } 1153 | 1154 | .border { 1155 | border-width: 1px; 1156 | } 1157 | 1158 | .border-l-4 { 1159 | border-left-width: 4px; 1160 | } 1161 | 1162 | .border-t { 1163 | border-top-width: 1px; 1164 | } 1165 | 1166 | .border-stone-200 { 1167 | border-color: var(--novel-stone-200); 1168 | } 1169 | 1170 | .border-stone-300 { 1171 | border-color: var(--novel-stone-300); 1172 | } 1173 | 1174 | .border-stone-700 { 1175 | border-color: var(--novel-stone-700); 1176 | } 1177 | 1178 | .bg-stone-100 { 1179 | background-color: var(--novel-stone-100); 1180 | } 1181 | 1182 | .bg-stone-200 { 1183 | background-color: var(--novel-stone-200); 1184 | } 1185 | 1186 | .bg-white { 1187 | background-color: var(--novel-white); 1188 | } 1189 | 1190 | .fill-stone-600 { 1191 | fill: var(--novel-stone-600); 1192 | } 1193 | 1194 | .p-1 { 1195 | padding: 0.25rem; 1196 | } 1197 | 1198 | .p-12 { 1199 | padding: 3rem; 1200 | } 1201 | 1202 | .p-2 { 1203 | padding: 0.5rem; 1204 | } 1205 | 1206 | .p-4 { 1207 | padding: 1rem; 1208 | } 1209 | 1210 | .p-5 { 1211 | padding: 1.25rem; 1212 | } 1213 | 1214 | .px-1 { 1215 | padding-left: 0.25rem; 1216 | padding-right: 0.25rem; 1217 | } 1218 | 1219 | .px-1\.5 { 1220 | padding-left: 0.375rem; 1221 | padding-right: 0.375rem; 1222 | } 1223 | 1224 | .px-2 { 1225 | padding-left: 0.5rem; 1226 | padding-right: 0.5rem; 1227 | } 1228 | 1229 | .px-3 { 1230 | padding-left: 0.75rem; 1231 | padding-right: 0.75rem; 1232 | } 1233 | 1234 | .px-4 { 1235 | padding-left: 1rem; 1236 | padding-right: 1rem; 1237 | } 1238 | 1239 | .px-8 { 1240 | padding-left: 2rem; 1241 | padding-right: 2rem; 1242 | } 1243 | 1244 | .py-1 { 1245 | padding-top: 0.25rem; 1246 | padding-bottom: 0.25rem; 1247 | } 1248 | 1249 | .py-1\.5 { 1250 | padding-top: 0.375rem; 1251 | padding-bottom: 0.375rem; 1252 | } 1253 | 1254 | .py-2 { 1255 | padding-top: 0.5rem; 1256 | padding-bottom: 0.5rem; 1257 | } 1258 | 1259 | .py-4 { 1260 | padding-top: 1rem; 1261 | padding-bottom: 1rem; 1262 | } 1263 | 1264 | .py-px { 1265 | padding-top: 1px; 1266 | padding-bottom: 1px; 1267 | } 1268 | 1269 | .pb-24 { 1270 | padding-bottom: 6rem; 1271 | } 1272 | 1273 | .pl-2 { 1274 | padding-left: 0.5rem; 1275 | } 1276 | 1277 | .text-left { 1278 | text-align: left; 1279 | } 1280 | 1281 | .font-default { 1282 | font-family: var(--font-default), system-ui, sans-serif; 1283 | } 1284 | 1285 | .font-mono { 1286 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 1287 | } 1288 | 1289 | .text-base { 1290 | font-size: 1rem; 1291 | line-height: 1.5rem; 1292 | } 1293 | 1294 | .text-sm { 1295 | font-size: 0.875rem; 1296 | line-height: 1.25rem; 1297 | } 1298 | 1299 | .text-xs { 1300 | font-size: 0.75rem; 1301 | line-height: 1rem; 1302 | } 1303 | 1304 | .font-medium { 1305 | font-weight: 500; 1306 | } 1307 | 1308 | .italic { 1309 | font-style: italic; 1310 | } 1311 | 1312 | .leading-3 { 1313 | line-height: .75rem; 1314 | } 1315 | 1316 | .leading-normal { 1317 | line-height: 1.5; 1318 | } 1319 | 1320 | .leading-relaxed { 1321 | line-height: 1.625; 1322 | } 1323 | 1324 | .text-blue-500 { 1325 | --tw-text-opacity: 1; 1326 | color: rgb(59 130 246 / var(--tw-text-opacity)); 1327 | } 1328 | 1329 | .text-red-600 { 1330 | --tw-text-opacity: 1; 1331 | color: rgb(220 38 38 / var(--tw-text-opacity)); 1332 | } 1333 | 1334 | .text-stone-200 { 1335 | color: var(--novel-stone-200); 1336 | } 1337 | 1338 | .text-stone-400 { 1339 | color: var(--novel-stone-400); 1340 | } 1341 | 1342 | .text-stone-500 { 1343 | color: var(--novel-stone-500); 1344 | } 1345 | 1346 | .text-stone-600 { 1347 | color: var(--novel-stone-600); 1348 | } 1349 | 1350 | .text-stone-700 { 1351 | color: var(--novel-stone-700); 1352 | } 1353 | 1354 | .text-stone-800 { 1355 | color: var(--novel-stone-800); 1356 | } 1357 | 1358 | .text-stone-900 { 1359 | color: var(--novel-stone-900); 1360 | } 1361 | 1362 | .underline { 1363 | text-decoration-line: underline; 1364 | } 1365 | 1366 | .decoration-stone-400 { 1367 | text-decoration-color: var(--novel-stone-400); 1368 | } 1369 | 1370 | .underline-offset-4 { 1371 | text-underline-offset: 4px; 1372 | } 1373 | 1374 | .underline-offset-\[3px\] { 1375 | text-underline-offset: 3px; 1376 | } 1377 | 1378 | .opacity-40 { 1379 | opacity: 0.4; 1380 | } 1381 | 1382 | .shadow { 1383 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 1384 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 1385 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1386 | } 1387 | 1388 | .shadow-md { 1389 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1390 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 1391 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1392 | } 1393 | 1394 | .shadow-xl { 1395 | --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 1396 | --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); 1397 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1398 | } 1399 | 1400 | .outline-none { 1401 | outline: 2px solid transparent; 1402 | outline-offset: 2px; 1403 | } 1404 | 1405 | .filter { 1406 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1407 | } 1408 | 1409 | .transition-all { 1410 | transition-property: all; 1411 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1412 | transition-duration: 150ms; 1413 | } 1414 | 1415 | .transition-colors { 1416 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 1417 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1418 | transition-duration: 150ms; 1419 | } 1420 | 1421 | .duration-200 { 1422 | transition-duration: 200ms; 1423 | } 1424 | 1425 | .ease-out { 1426 | transition-timing-function: cubic-bezier(0, 0, 0.2, 1); 1427 | } 1428 | 1429 | @keyframes enter { 1430 | from { 1431 | opacity: var(--tw-enter-opacity, 1); 1432 | transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0)); 1433 | } 1434 | } 1435 | 1436 | @keyframes exit { 1437 | to { 1438 | opacity: var(--tw-exit-opacity, 1); 1439 | transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0)); 1440 | } 1441 | } 1442 | 1443 | .animate-in { 1444 | animation-name: enter; 1445 | animation-duration: 150ms; 1446 | --tw-enter-opacity: initial; 1447 | --tw-enter-scale: initial; 1448 | --tw-enter-rotate: initial; 1449 | --tw-enter-translate-x: initial; 1450 | --tw-enter-translate-y: initial; 1451 | } 1452 | 1453 | .fade-in { 1454 | --tw-enter-opacity: 0; 1455 | } 1456 | 1457 | .slide-in-from-top-1 { 1458 | --tw-enter-translate-y: -0.25rem; 1459 | } 1460 | 1461 | .duration-200 { 1462 | animation-duration: 200ms; 1463 | } 1464 | 1465 | .ease-out { 1466 | animation-timing-function: cubic-bezier(0, 0, 0.2, 1); 1467 | } 1468 | 1469 | .square-4 { 1470 | width: 1rem; 1471 | height: 1rem; 1472 | } 1473 | 1474 | .square-5 { 1475 | width: 1.25rem; 1476 | height: 1.25rem; 1477 | } 1478 | 1479 | .square-6 { 1480 | width: 1.5rem; 1481 | height: 1.5rem; 1482 | } 1483 | 1484 | @media (prefers-color-scheme: dark) { 1485 | .dark\:prose-invert { 1486 | --tw-prose-body: var(--tw-prose-invert-body); 1487 | --tw-prose-headings: var(--tw-prose-invert-headings); 1488 | --tw-prose-lead: var(--tw-prose-invert-lead); 1489 | --tw-prose-links: var(--tw-prose-invert-links); 1490 | --tw-prose-bold: var(--tw-prose-invert-bold); 1491 | --tw-prose-counters: var(--tw-prose-invert-counters); 1492 | --tw-prose-bullets: var(--tw-prose-invert-bullets); 1493 | --tw-prose-hr: var(--tw-prose-invert-hr); 1494 | --tw-prose-quotes: var(--tw-prose-invert-quotes); 1495 | --tw-prose-quote-borders: var(--tw-prose-invert-quote-borders); 1496 | --tw-prose-captions: var(--tw-prose-invert-captions); 1497 | --tw-prose-code: var(--tw-prose-invert-code); 1498 | --tw-prose-pre-code: var(--tw-prose-invert-pre-code); 1499 | --tw-prose-pre-bg: var(--tw-prose-invert-pre-bg); 1500 | --tw-prose-th-borders: var(--tw-prose-invert-th-borders); 1501 | --tw-prose-td-borders: var(--tw-prose-invert-td-borders); 1502 | } 1503 | } 1504 | 1505 | .hover\:bg-red-100:hover { 1506 | --tw-bg-opacity: 1; 1507 | background-color: rgb(254 226 226 / var(--tw-bg-opacity)); 1508 | } 1509 | 1510 | .hover\:bg-stone-100:hover { 1511 | background-color: var(--novel-stone-100); 1512 | } 1513 | 1514 | .hover\:text-stone-600:hover { 1515 | color: var(--novel-stone-600); 1516 | } 1517 | 1518 | .focus\:outline-none:focus { 1519 | outline: 2px solid transparent; 1520 | outline-offset: 2px; 1521 | } 1522 | 1523 | .active\:bg-stone-200:active { 1524 | background-color: var(--novel-stone-200); 1525 | } 1526 | 1527 | .data-\[highlighted\]\:bg-stone-100[data-highlighted] { 1528 | background-color: var(--novel-stone-100); 1529 | } 1530 | 1531 | .prose-headings\:font-title :is(:where(h1, h2, h3, h4, h5, h6, th):not(:where([class~="not-prose"] *))) { 1532 | font-family: var(--font-title), system-ui, sans-serif; 1533 | } 1534 | 1535 | @media (prefers-color-scheme: dark) { 1536 | .dark\:hover\:bg-red-800:hover { 1537 | --tw-bg-opacity: 1; 1538 | background-color: rgb(153 27 27 / var(--tw-bg-opacity)); 1539 | } 1540 | } 1541 | 1542 | @media (min-width: 640px) { 1543 | .sm\:bottom-\[unset\] { 1544 | bottom: unset; 1545 | } 1546 | 1547 | .sm\:top-0 { 1548 | top: 0px; 1549 | } 1550 | 1551 | .sm\:mb-\[calc\(20vh\)\] { 1552 | margin-bottom: calc(20vh); 1553 | } 1554 | 1555 | .sm\:rounded-lg { 1556 | border-radius: 0.5rem; 1557 | } 1558 | 1559 | .sm\:border { 1560 | border-width: 1px; 1561 | } 1562 | 1563 | .sm\:border-none { 1564 | border-style: none; 1565 | } 1566 | 1567 | .sm\:bg-transparent { 1568 | background-color: transparent; 1569 | } 1570 | 1571 | .sm\:px-12 { 1572 | padding-left: 3rem; 1573 | padding-right: 3rem; 1574 | } 1575 | 1576 | .sm\:px-4 { 1577 | padding-left: 1rem; 1578 | padding-right: 1rem; 1579 | } 1580 | 1581 | .sm\:pb-12 { 1582 | padding-bottom: 3rem; 1583 | } 1584 | 1585 | .sm\:pt-\[15vh\] { 1586 | padding-top: 15vh; 1587 | } 1588 | 1589 | .sm\:shadow-lg { 1590 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 1591 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 1592 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1593 | } 1594 | } 1595 | 1596 | @media (min-width: 768px) { 1597 | .md\:bottom-0 { 1598 | bottom: 0px; 1599 | } 1600 | 1601 | .md\:top-auto { 1602 | top: auto; 1603 | } 1604 | } -------------------------------------------------------------------------------- /src/lib/ui/editor/bubble-menu/color-selector.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 114 | 115 |
116 |
117 | 132 | 133 |
138 |
Color
139 | {#each TEXT_COLORS as { name, color }, index (index)} 140 | 164 | {/each} 165 | 166 |
Background
167 | 168 | {#each HIGHLIGHT_COLORS as { name, color }, index (index)} 169 | 191 | {/each} 192 |
193 |
194 |
195 | -------------------------------------------------------------------------------- /src/lib/ui/editor/bubble-menu/index.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 119 | 120 |
124 | 125 | 126 |
127 | {#each items as item, index (index)} 128 | 140 | {/each} 141 |
142 | 143 |
144 | -------------------------------------------------------------------------------- /src/lib/ui/editor/bubble-menu/link-selector.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | 29 | {#if isOpen} 30 |
{ 32 | e.preventDefault(); 33 | const input = anyify(e.target)[0]; 34 | const url = getUrlFromString(input.value); 35 | url && editor.chain().focus().setLink({ href: url }).run(); 36 | isOpen = false; 37 | }} 38 | class="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-stone-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-top-1" 39 | > 40 | 41 | 49 | {#if editor.getAttributes('link').href} 50 | 60 | {:else} 61 | 66 | {/if} 67 |
68 | {/if} 69 |
70 | -------------------------------------------------------------------------------- /src/lib/ui/editor/bubble-menu/node-selector.svelte: -------------------------------------------------------------------------------- 1 | 99 | 100 |
101 |
102 | 110 | 111 |
116 | {#each items as item, index (index)} 117 | 135 | {/each} 136 |
137 |
138 |
139 | -------------------------------------------------------------------------------- /src/lib/ui/editor/default-content.ts: -------------------------------------------------------------------------------- 1 | export const defaultEditorContent = { 2 | type: 'doc', 3 | content: [ 4 | { 5 | type: 'heading', 6 | attrs: { level: 2 }, 7 | content: [{ type: 'text', text: 'Introducing Novel Svelte' }] 8 | }, 9 | { 10 | type: 'paragraph', 11 | content: [ 12 | { 13 | type: 'text', 14 | marks: [ 15 | { 16 | type: 'link', 17 | attrs: { 18 | href: 'https://github.com/tglide/novel-svelte', 19 | target: '_blank', 20 | class: 21 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 22 | } 23 | } 24 | ], 25 | text: 'Novel Svelte' 26 | }, 27 | { 28 | type: 'text', 29 | text: ' is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ' 30 | }, 31 | { 32 | type: 'text', 33 | marks: [ 34 | { 35 | type: 'link', 36 | attrs: { 37 | href: 'https://tiptap.dev/', 38 | target: '_blank', 39 | class: 40 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 41 | } 42 | } 43 | ], 44 | text: 'Tiptap' 45 | }, 46 | { type: 'text', text: ' + ' }, 47 | { 48 | type: 'text', 49 | marks: [ 50 | { 51 | type: 'link', 52 | attrs: { 53 | href: 'https://sdk.vercel.ai/docs', 54 | target: '_blank', 55 | class: 56 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 57 | } 58 | } 59 | ], 60 | text: 'Vercel AI SDK' 61 | }, 62 | { type: 'text', text: ". Ported From Steven Tey's " }, 63 | { 64 | type: 'text', 65 | marks: [ 66 | { 67 | type: 'link', 68 | attrs: { 69 | href: 'https://github.com/steven-tey/novel', 70 | target: '_blank', 71 | class: 72 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 73 | } 74 | } 75 | ], 76 | text: 'Novel' 77 | }, 78 | { type: 'text', text: ' project.' } 79 | ] 80 | }, 81 | { 82 | type: 'heading', 83 | attrs: { level: 3 }, 84 | content: [{ type: 'text', text: 'Installation' }] 85 | }, 86 | { 87 | type: 'codeBlock', 88 | attrs: { language: null }, 89 | content: [{ type: 'text', text: 'npm i novel-svelte' }] 90 | }, 91 | { 92 | type: 'heading', 93 | attrs: { level: 3 }, 94 | content: [{ type: 'text', text: 'Usage' }] 95 | }, 96 | { 97 | type: 'codeBlock', 98 | attrs: { language: null }, 99 | content: [ 100 | { 101 | type: 'text', 102 | text: '\n\n' 103 | } 104 | ] 105 | }, 106 | { 107 | type: 'heading', 108 | attrs: { level: 3 }, 109 | content: [{ type: 'text', text: 'Features' }] 110 | }, 111 | { 112 | type: 'orderedList', 113 | attrs: { tight: true, start: 1 }, 114 | content: [ 115 | { 116 | type: 'listItem', 117 | content: [ 118 | { 119 | type: 'paragraph', 120 | content: [{ type: 'text', text: 'Slash menu & bubble menu' }] 121 | } 122 | ] 123 | }, 124 | { 125 | type: 'listItem', 126 | content: [ 127 | { 128 | type: 'paragraph', 129 | content: [ 130 | { type: 'text', text: 'AI autocomplete (type ' }, 131 | { type: 'text', marks: [{ type: 'code' }], text: '++' }, 132 | { 133 | type: 'text', 134 | text: ' to activate, or select from slash menu)' 135 | } 136 | ] 137 | } 138 | ] 139 | }, 140 | { 141 | type: 'listItem', 142 | content: [ 143 | { 144 | type: 'paragraph', 145 | content: [ 146 | { 147 | type: 'text', 148 | text: 'Image uploads (drag & drop / copy & paste, or select from slash menu) ' 149 | } 150 | ] 151 | } 152 | ] 153 | } 154 | ] 155 | }, 156 | { 157 | type: 'image', 158 | attrs: { 159 | src: 'https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png', 160 | alt: 'banner.png', 161 | title: 'banner.png', 162 | width: null, 163 | height: null 164 | } 165 | }, 166 | { type: 'horizontalRule' }, 167 | { 168 | type: 'heading', 169 | attrs: { level: 3 }, 170 | content: [{ type: 'text', text: 'Learn more' }] 171 | }, 172 | { 173 | type: 'taskList', 174 | content: [ 175 | { 176 | type: 'taskItem', 177 | attrs: { checked: false }, 178 | content: [ 179 | { 180 | type: 'paragraph', 181 | content: [ 182 | { type: 'text', text: 'Star us on ' }, 183 | { 184 | type: 'text', 185 | marks: [ 186 | { 187 | type: 'link', 188 | attrs: { 189 | href: 'https://github.com/tglide/novel-svelte', 190 | target: '_blank', 191 | class: 192 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 193 | } 194 | } 195 | ], 196 | text: 'GitHub' 197 | } 198 | ] 199 | } 200 | ] 201 | }, 202 | { 203 | type: 'taskItem', 204 | attrs: { checked: false }, 205 | content: [ 206 | { 207 | type: 'paragraph', 208 | content: [ 209 | { type: 'text', text: 'Install the ' }, 210 | { 211 | type: 'text', 212 | marks: [ 213 | { 214 | type: 'link', 215 | attrs: { 216 | href: 'https://www.npmjs.com/package/novel-svelte', 217 | target: '_blank', 218 | class: 219 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 220 | } 221 | } 222 | ], 223 | text: 'NPM package' 224 | } 225 | ] 226 | } 227 | ] 228 | } 229 | ] 230 | } 231 | ] 232 | }; 233 | -------------------------------------------------------------------------------- /src/lib/ui/editor/extensions/CommandList.svelte: -------------------------------------------------------------------------------- 1 | 90 | 91 | 92 | 93 | {#if items.length > 0} 94 |
99 | {#each items as item, index (index)} 100 | 121 | {/each} 122 |
123 | {/if} 124 | -------------------------------------------------------------------------------- /src/lib/ui/editor/extensions/ImageResizer.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#key key} 25 | { 43 | delta[0] && (target.style.width = `${width}px`); 44 | delta[1] && (target.style.height = `${height}px`); 45 | }} 46 | on:resizeEnd={() => { 47 | updateMediaSize(); 48 | key++; 49 | }} 50 | scalable={true} 51 | throttleScale={0} 52 | renderDirections={['w', 'e']} 53 | on:scale={({ 54 | detail: { 55 | target, 56 | // scale, 57 | // dist, 58 | // delta, 59 | transform 60 | } 61 | }) => { 62 | target.style.transform = transform; 63 | }} 64 | /> 65 | {/key} 66 | -------------------------------------------------------------------------------- /src/lib/ui/editor/extensions/index.ts: -------------------------------------------------------------------------------- 1 | import { InputRule } from '@tiptap/core'; 2 | import { Color } from '@tiptap/extension-color'; 3 | import Highlight from '@tiptap/extension-highlight'; 4 | import HorizontalRule from '@tiptap/extension-horizontal-rule'; 5 | import TiptapImage from '@tiptap/extension-image'; 6 | import TiptapLink from '@tiptap/extension-link'; 7 | import Placeholder from '@tiptap/extension-placeholder'; 8 | import TaskItem from '@tiptap/extension-task-item'; 9 | import TaskList from '@tiptap/extension-task-list'; 10 | import TextStyle from '@tiptap/extension-text-style'; 11 | import TiptapUnderline from '@tiptap/extension-underline'; 12 | import StarterKit from '@tiptap/starter-kit'; 13 | import { Markdown } from 'tiptap-markdown'; 14 | import UploadImagesPlugin from '../plugins/upload-images.js'; 15 | import SlashCommand from './slash-command.js'; 16 | import UpdatedImage from './updated-image.js'; 17 | 18 | export const defaultExtensions = [ 19 | StarterKit.configure({ 20 | bulletList: { 21 | HTMLAttributes: { 22 | class: 'list-disc list-outside leading-3 -mt-2' 23 | } 24 | }, 25 | orderedList: { 26 | HTMLAttributes: { 27 | class: 'list-decimal list-outside leading-3 -mt-2' 28 | } 29 | }, 30 | listItem: { 31 | HTMLAttributes: { 32 | class: 'leading-normal -mb-2' 33 | } 34 | }, 35 | blockquote: { 36 | HTMLAttributes: { 37 | class: 'border-l-4 border-stone-700' 38 | } 39 | }, 40 | codeBlock: { 41 | HTMLAttributes: { 42 | class: 'rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800' 43 | } 44 | }, 45 | code: { 46 | HTMLAttributes: { 47 | class: 'rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-stone-900', 48 | spellcheck: 'false' 49 | } 50 | }, 51 | horizontalRule: false, 52 | dropcursor: { 53 | color: '#DBEAFE', 54 | width: 4 55 | }, 56 | gapcursor: false 57 | }), 58 | // patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740 59 | HorizontalRule.extend({ 60 | addInputRules() { 61 | return [ 62 | new InputRule({ 63 | find: /^(?:---|—-|___\s|\*\*\*\s)$/, 64 | handler: ({ state, range }) => { 65 | const attributes = {}; 66 | 67 | const { tr } = state; 68 | const start = range.from; 69 | const end = range.to; 70 | 71 | tr.insert(start - 1, this.type.create(attributes)).delete( 72 | tr.mapping.map(start), 73 | tr.mapping.map(end) 74 | ); 75 | } 76 | }) 77 | ]; 78 | } 79 | }).configure({ 80 | HTMLAttributes: { 81 | class: 'mt-4 mb-6 border-t border-stone-300' 82 | } 83 | }), 84 | TiptapLink.configure({ 85 | HTMLAttributes: { 86 | class: 87 | 'text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer' 88 | } 89 | }), 90 | TiptapImage.extend({ 91 | addProseMirrorPlugins() { 92 | return [UploadImagesPlugin()]; 93 | } 94 | }).configure({ 95 | allowBase64: true, 96 | HTMLAttributes: { 97 | class: 'rounded-lg border border-stone-200' 98 | } 99 | }), 100 | UpdatedImage.configure({ 101 | HTMLAttributes: { 102 | class: 'rounded-lg border border-stone-200' 103 | } 104 | }), 105 | Placeholder.configure({ 106 | placeholder: ({ node }: any) => { 107 | if (node.type.name === 'heading') { 108 | return `Heading ${node.attrs.level}`; 109 | } 110 | return "Press '/' for commands, or '++' for AI autocomplete..."; 111 | }, 112 | includeChildren: true 113 | }), 114 | SlashCommand, 115 | TiptapUnderline, 116 | TextStyle, 117 | Color, 118 | Highlight.configure({ 119 | multicolor: true 120 | }), 121 | TaskList.configure({ 122 | HTMLAttributes: { 123 | class: 'not-prose pl-2' 124 | } 125 | }), 126 | TaskItem.configure({ 127 | HTMLAttributes: { 128 | class: 'flex items-start my-4' 129 | }, 130 | nested: true 131 | }), 132 | Markdown.configure({ 133 | html: false, 134 | transformCopiedText: true 135 | }) 136 | ]; 137 | -------------------------------------------------------------------------------- /src/lib/ui/editor/extensions/slash-command.ts: -------------------------------------------------------------------------------- 1 | import { Editor, Extension, type Range } from '@tiptap/core'; 2 | import Suggestion from '@tiptap/suggestion'; 3 | 4 | import tippy from 'tippy.js'; 5 | 6 | import { 7 | CheckSquare, 8 | Code, 9 | Heading1, 10 | Heading2, 11 | Heading3, 12 | List, 13 | ListOrdered, 14 | MessageSquarePlus, 15 | Text, 16 | TextQuote 17 | } from 'lucide-svelte'; 18 | import CommandList from './CommandList.svelte'; 19 | // import { toast } from 'sonner'; 20 | // import va from '@vercel/analytics'; 21 | // import { startImageUpload } from '@/ui/editor/plugins/upload-images'; 22 | import { Magic } from '$lib/ui/icons/index.js'; 23 | import type { SvelteComponent } from 'svelte'; 24 | 25 | export interface CommandItemProps { 26 | title: string; 27 | description: string; 28 | icon: SvelteComponent; 29 | } 30 | 31 | interface CommandProps { 32 | editor: Editor; 33 | range: Range; 34 | } 35 | 36 | const Command = Extension.create({ 37 | name: 'slash-command', 38 | addOptions() { 39 | return { 40 | suggestion: { 41 | char: '/', 42 | command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { 43 | props.command({ editor, range }); 44 | } 45 | } 46 | }; 47 | }, 48 | addProseMirrorPlugins() { 49 | return [ 50 | Suggestion({ 51 | editor: this.editor, 52 | ...this.options.suggestion 53 | }) 54 | ]; 55 | } 56 | }); 57 | 58 | const getSuggestionItems = ({ query }: { query: string }) => { 59 | return [ 60 | { 61 | title: 'Continue writing', 62 | description: 'Use AI to expand your thoughts.', 63 | searchTerms: ['gpt'], 64 | icon: Magic 65 | }, 66 | // { 67 | // title: 'Send Feedback', 68 | // description: 'Let us know how we can improve.', 69 | // icon: MessageSquarePlus, 70 | // command: ({ editor, range }: CommandProps) => { 71 | // editor.chain().focus().deleteRange(range).run(); 72 | // window.open('/feedback', '_blank'); 73 | // } 74 | // }, 75 | { 76 | title: 'Text', 77 | description: 'Just start typing with plain text.', 78 | searchTerms: ['p', 'paragraph'], 79 | icon: Text, 80 | command: ({ editor, range }: CommandProps) => { 81 | editor.chain().focus().deleteRange(range).toggleNode('paragraph', 'paragraph').run(); 82 | } 83 | }, 84 | { 85 | title: 'To-do List', 86 | description: 'Track tasks with a to-do list.', 87 | searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'], 88 | icon: CheckSquare, 89 | command: ({ editor, range }: CommandProps) => { 90 | editor.chain().focus().deleteRange(range).toggleTaskList().run(); 91 | } 92 | }, 93 | { 94 | title: 'Heading 1', 95 | description: 'Big section heading.', 96 | searchTerms: ['title', 'big', 'large'], 97 | icon: Heading1, 98 | command: ({ editor, range }: CommandProps) => { 99 | editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(); 100 | } 101 | }, 102 | { 103 | title: 'Heading 2', 104 | description: 'Medium section heading.', 105 | searchTerms: ['subtitle', 'medium'], 106 | icon: Heading2, 107 | command: ({ editor, range }: CommandProps) => { 108 | editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(); 109 | } 110 | }, 111 | { 112 | title: 'Heading 3', 113 | description: 'Small section heading.', 114 | searchTerms: ['subtitle', 'small'], 115 | icon: Heading3, 116 | command: ({ editor, range }: CommandProps) => { 117 | editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run(); 118 | } 119 | }, 120 | { 121 | title: 'Bullet List', 122 | description: 'Create a simple bullet list.', 123 | searchTerms: ['unordered', 'point'], 124 | icon: List, 125 | command: ({ editor, range }: CommandProps) => { 126 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 127 | } 128 | }, 129 | { 130 | title: 'Numbered List', 131 | description: 'Create a list with numbering.', 132 | searchTerms: ['ordered'], 133 | icon: ListOrdered, 134 | command: ({ editor, range }: CommandProps) => { 135 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 136 | } 137 | }, 138 | { 139 | title: 'Quote', 140 | description: 'Capture a quote.', 141 | searchTerms: ['blockquote'], 142 | icon: TextQuote, 143 | command: ({ editor, range }: CommandProps) => 144 | editor 145 | .chain() 146 | .focus() 147 | .deleteRange(range) 148 | .toggleNode('paragraph', 'paragraph') 149 | .toggleBlockquote() 150 | .run() 151 | }, 152 | { 153 | title: 'Code', 154 | description: 'Capture a code snippet.', 155 | searchTerms: ['codeblock'], 156 | icon: Code, 157 | command: ({ editor, range }: CommandProps) => 158 | editor.chain().focus().deleteRange(range).toggleCodeBlock().run() 159 | } 160 | // { 161 | // title: 'Image', 162 | // description: 'Upload an image from your computer.', 163 | // searchTerms: ['photo', 'picture', 'media'], 164 | // // icon: , 165 | // command: ({ editor, range }: CommandProps) => { 166 | // editor.chain().focus().deleteRange(range).run(); 167 | // // upload image 168 | // const input = document.createElement('input'); 169 | // input.type = 'file'; 170 | // input.accept = 'image/*'; 171 | // input.onchange = async () => { 172 | // if (input.files?.length) { 173 | // const file = input.files[0]; 174 | // const pos = editor.view.state.selection.from; 175 | // // startImageUpload(file, editor.view, pos); 176 | // } 177 | // }; 178 | // input.click(); 179 | // } 180 | // } 181 | ].filter((item) => { 182 | if (typeof query === 'string' && query.length > 0) { 183 | const search = query.toLowerCase(); 184 | return ( 185 | item.title.toLowerCase().includes(search) || 186 | item.description.toLowerCase().includes(search) || 187 | (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) 188 | ); 189 | } 190 | return true; 191 | }); 192 | }; 193 | 194 | export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { 195 | const containerHeight = container.offsetHeight; 196 | const itemHeight = item ? item.offsetHeight : 0; 197 | 198 | const top = item.offsetTop; 199 | const bottom = top + itemHeight; 200 | 201 | if (top < container.scrollTop) { 202 | container.scrollTop -= container.scrollTop - top + 5; 203 | } else if (bottom > containerHeight + container.scrollTop) { 204 | container.scrollTop += bottom - containerHeight - container.scrollTop + 5; 205 | } 206 | }; 207 | 208 | const renderItems = () => { 209 | let component: CommandList | null = null; 210 | let popup: any | null = null; 211 | 212 | return { 213 | onStart: (props: { editor: Editor; clientRect: DOMRect }) => { 214 | // component = new SvelteRenderer(CommandList, { 215 | // props, 216 | // editor: props.editor 217 | // }); 218 | 219 | // component.dom; 220 | const el = document.createElement('div'); 221 | component = new CommandList({ 222 | target: el, 223 | props: props as any 224 | }); 225 | 226 | popup = (tippy as any)('body', { 227 | getReferenceClientRect: props.clientRect, 228 | appendTo: () => document.body, 229 | content: el, 230 | showOnCreate: true, 231 | interactive: true, 232 | trigger: 'manual', 233 | placement: 'bottom-start' 234 | }); 235 | }, 236 | onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { 237 | component?.$set(props); 238 | 239 | popup && 240 | popup[0].setProps({ 241 | getReferenceClientRect: props.clientRect 242 | }); 243 | }, 244 | onKeyDown: (props: { event: KeyboardEvent }) => { 245 | if (props.event.key === 'Escape') { 246 | popup?.[0].hide(); 247 | 248 | return true; 249 | } 250 | 251 | // return component?.ref?.onKeyDown(props); 252 | }, 253 | onExit: () => { 254 | popup?.[0].destroy(); 255 | // component?.destroy(); 256 | component?.$destroy(); 257 | } 258 | }; 259 | }; 260 | 261 | const SlashCommand = Command.configure({ 262 | suggestion: { 263 | items: getSuggestionItems, 264 | render: renderItems 265 | } 266 | }); 267 | 268 | export default SlashCommand; 269 | -------------------------------------------------------------------------------- /src/lib/ui/editor/extensions/updated-image.ts: -------------------------------------------------------------------------------- 1 | import Image from '@tiptap/extension-image'; 2 | 3 | const UpdatedImage = Image.extend({ 4 | addAttributes() { 5 | return { 6 | ...this.parent?.(), 7 | width: { 8 | default: null 9 | }, 10 | height: { 11 | default: null 12 | } 13 | }; 14 | } 15 | }); 16 | 17 | export default UpdatedImage; 18 | -------------------------------------------------------------------------------- /src/lib/ui/editor/index.svelte: -------------------------------------------------------------------------------- 1 | 81 | * 82 | * 83 | */ 84 | export let editor: Editor | undefined = undefined; 85 | 86 | let element: Element; 87 | 88 | const { complete, completion, isLoading, stop } = useCompletion({ 89 | id: 'novel', 90 | api: completionApi, 91 | onFinish: (_prompt, completion) => { 92 | editor?.commands.setTextSelection({ 93 | from: editor.state.selection.from - completion.length, 94 | to: editor.state.selection.from 95 | }); 96 | }, 97 | onError: (err) => { 98 | addToast({ 99 | data: { 100 | text: err.message, 101 | type: 'error' 102 | } 103 | }); 104 | // if (err.message === 'You have reached your request limit for the day.') { 105 | // va.track('Rate Limit Reached'); 106 | // } 107 | } 108 | }); 109 | 110 | const content = createLocalStorageStore(storageKey, defaultValue); 111 | let hydrated = false; 112 | $: if (editor && !hydrated) { 113 | const value = disableLocalStorage ? defaultValue : $content; 114 | 115 | if (value) { 116 | editor.commands.setContent(value); 117 | } 118 | 119 | hydrated = true; 120 | } 121 | 122 | let prev = ''; 123 | 124 | function insertAiCompletion() { 125 | const diff = $completion.slice(prev.length); 126 | 127 | prev = $completion; 128 | editor?.commands.insertContent(diff); 129 | } 130 | 131 | $: { 132 | [$completion]; 133 | insertAiCompletion(); 134 | } 135 | 136 | const debouncedUpdates = createDebouncedCallback(async ({ editor }) => { 137 | if (!disableLocalStorage) { 138 | const json = editor.getJSON(); 139 | content.set(json); 140 | } 141 | 142 | onDebouncedUpdate(editor); 143 | }, debounceDuration); 144 | 145 | onMount(() => { 146 | editor = new Editor({ 147 | element: element, 148 | onTransaction: () => { 149 | // force re-render so `editor.isActive` works as expected 150 | editor = editor; 151 | }, 152 | extensions: [...defaultExtensions, ...extensions], 153 | editorProps: { 154 | ...defaultEditorProps, 155 | ...editorProps 156 | }, 157 | onUpdate: (e) => { 158 | const selection = e.editor.state.selection; 159 | const lastTwo = getPrevText(e.editor, { 160 | chars: 2 161 | }); 162 | 163 | if (lastTwo === '++' && !$isLoading) { 164 | e.editor.commands.deleteRange({ 165 | from: selection.from - 2, 166 | to: selection.from 167 | }); 168 | complete( 169 | getPrevText(e.editor, { 170 | chars: 5000 171 | }) 172 | ); 173 | // complete(e.editor.storage.markdown.getMarkdown()); 174 | } else { 175 | onUpdate(e.editor); 176 | debouncedUpdates(e); 177 | } 178 | }, 179 | autofocus: 'end' 180 | }); 181 | 182 | return () => editor.destroy(); 183 | }); 184 | 185 | 186 | {#if editor && editor.isEditable} 187 | 188 | {/if} 189 | 190 |
191 | 192 | {#if editor?.isActive('image')} 193 | 194 | {/if} 195 |
196 | 197 | 198 | -------------------------------------------------------------------------------- /src/lib/ui/editor/plugins/upload-images.ts: -------------------------------------------------------------------------------- 1 | // import { BlobResult } from "@vercel/blob"; 2 | // import { toast } from "sonner"; 3 | import { addToast } from '$lib/ui/toasts.svelte'; 4 | import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'; 5 | import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'; 6 | 7 | const uploadKey = new PluginKey('upload-image'); 8 | 9 | const UploadImagesPlugin = () => 10 | new Plugin({ 11 | key: uploadKey, 12 | state: { 13 | init() { 14 | return DecorationSet.empty; 15 | }, 16 | apply(tr, set) { 17 | set = set.map(tr.mapping, tr.doc); 18 | // See if the transaction adds or removes any placeholders 19 | const action = tr.getMeta(this as any); 20 | if (action && action.add) { 21 | const { id, pos, src } = action.add; 22 | 23 | const placeholder = document.createElement('div'); 24 | placeholder.setAttribute('class', 'img-placeholder'); 25 | const image = document.createElement('img'); 26 | image.setAttribute('class', 'opacity-40 rounded-lg border border-stone-200'); 27 | image.src = src; 28 | placeholder.appendChild(image); 29 | const deco = Decoration.widget(pos + 1, placeholder, { 30 | id 31 | }); 32 | set = set.add(tr.doc, [deco]); 33 | } else if (action && action.remove) { 34 | set = set.remove( 35 | set.find(null as any, null as any, (spec) => spec.id == action.remove.id) 36 | ); 37 | } 38 | return set; 39 | } 40 | }, 41 | props: { 42 | decorations(state) { 43 | return this.getState(state); 44 | } 45 | } 46 | }); 47 | 48 | export default UploadImagesPlugin; 49 | 50 | function findPlaceholder(state: EditorState, id: any) { 51 | const decos = uploadKey.getState(state); 52 | const found = decos.find(null, null, (spec: any) => spec.id == id); 53 | return found.length ? found[0].from : null; 54 | } 55 | 56 | export function startImageUpload(file: File, view: EditorView, pos: number) { 57 | // check if the file is an image 58 | if (!file.type.includes('image/')) { 59 | addToast({ 60 | data: { 61 | text: 'File type not supported.', 62 | type: 'error' 63 | } 64 | }); 65 | return; 66 | 67 | // check if the file size is less than 20MB 68 | } else if (file.size / 1024 / 1024 > 20) { 69 | addToast({ 70 | data: { 71 | text: 'File size too big (max 20MB).', 72 | type: 'error' 73 | } 74 | }); 75 | 76 | return; 77 | } 78 | 79 | // A fresh object to act as the ID for this upload 80 | const id = {}; 81 | 82 | // Replace the selection with a placeholder 83 | const tr = view.state.tr; 84 | if (!tr.selection.empty) tr.deleteSelection(); 85 | 86 | const reader = new FileReader(); 87 | reader.readAsDataURL(file); 88 | reader.onload = () => { 89 | tr.setMeta(uploadKey, { 90 | add: { 91 | id, 92 | pos, 93 | src: reader.result 94 | } 95 | }); 96 | view.dispatch(tr); 97 | }; 98 | 99 | handleImageUpload(file).then((src) => { 100 | const { schema } = view.state; 101 | 102 | const pos = findPlaceholder(view.state, id); 103 | // If the content around the placeholder has been deleted, drop 104 | // the image 105 | if (pos == null) return; 106 | 107 | // Otherwise, insert it at the placeholder's position, and remove 108 | // the placeholder 109 | 110 | // When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read 111 | // the image locally 112 | const imageSrc = typeof src === 'object' ? reader.result : src; 113 | 114 | const node = schema.nodes.image.create({ src: imageSrc }); 115 | const transaction = view.state.tr 116 | .replaceWith(pos, pos, node) 117 | .setMeta(uploadKey, { remove: { id } }); 118 | view.dispatch(transaction); 119 | }); 120 | } 121 | 122 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 123 | 124 | export const handleImageUpload = async (file: File) => { 125 | await sleep(1000); 126 | return file; 127 | // upload to Vercel Blob 128 | // return new Promise((resolve) => { 129 | // // toast.promise( 130 | // // fetch("/api/upload", { 131 | // // method: "POST", 132 | // // headers: { 133 | // // "content-type": file?.type || "application/octet-stream", 134 | // // "x-vercel-filename": file?.name || "image.png", 135 | // // }, 136 | // // body: file, 137 | // // }).then(async (res) => { 138 | // // // Successfully uploaded image 139 | // // if (res.status === 200) { 140 | // // const { url } = (await res.json()) as BlobResult; 141 | // // // preload the image 142 | // // let image = new Image(); 143 | // // image.src = url; 144 | // // image.onload = () => { 145 | // // resolve(url); 146 | // // }; 147 | // // // No blob store configured 148 | // // } else if (res.status === 401) { 149 | // // resolve(file); 150 | 151 | // // throw new Error( 152 | // // "`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead." 153 | // // ); 154 | // // // Unknown error 155 | // // } else { 156 | // // throw new Error(`Error uploading image. Please try again.`); 157 | // // } 158 | // // }), 159 | // // { 160 | // // loading: "Uploading image...", 161 | // // success: "Image uploaded successfully.", 162 | // // error: (e) => e.message, 163 | // // } 164 | // // ); 165 | // resolve(file); 166 | // }); 167 | }; 168 | -------------------------------------------------------------------------------- /src/lib/ui/editor/props.ts: -------------------------------------------------------------------------------- 1 | import type { EditorProps } from '@tiptap/pm/view'; 2 | import { startImageUpload } from './plugins/upload-images.js'; 3 | 4 | export const defaultEditorProps: EditorProps = { 5 | attributes: { 6 | class: `prose-lg prose-stone dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full` 7 | }, 8 | handleDOMEvents: { 9 | keydown: (_view, event) => { 10 | // prevent default event listeners from firing when slash command is active 11 | if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) { 12 | const slashCommand = document.querySelector('#slash-command'); 13 | if (slashCommand) { 14 | return true; 15 | } 16 | } 17 | } 18 | }, 19 | handlePaste: (view, event) => { 20 | if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { 21 | event.preventDefault(); 22 | const file = event.clipboardData.files[0]; 23 | const pos = view.state.selection.from; 24 | 25 | startImageUpload(file, view, pos); 26 | return true; 27 | } 28 | return false; 29 | }, 30 | handleDrop: (view, event, _slice, moved) => { 31 | if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { 32 | event.preventDefault(); 33 | const file = event.dataTransfer.files[0]; 34 | const coordinates = view.posAtCoords({ 35 | left: event.clientX, 36 | top: event.clientY 37 | }); 38 | // here we deduct 1 from the pos or else the image will create an extra node 39 | startImageUpload(file, view, coordinates?.pos || 0 - 1); 40 | return true; 41 | } 42 | return false; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/lib/ui/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LoadingCircle } from './loading-circle.svelte'; 2 | export { default as Magic } from './magic.svelte'; 3 | -------------------------------------------------------------------------------- /src/lib/ui/icons/loading-circle.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | -------------------------------------------------------------------------------- /src/lib/ui/icons/magic.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 25 | 29 | 33 | 34 | -------------------------------------------------------------------------------- /src/lib/ui/toasts.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 35 | 36 |
40 | {#each $toasts as { id, data } (id)} 41 |
49 | 50 |

51 | {data.text} 52 |

53 |
54 | {/each} 55 |
56 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export const noop = () => { 5 | // do nothing 6 | }; 7 | 8 | export function cn(...inputs: ClassValue[]) { 9 | return twMerge(clsx(inputs)); 10 | } 11 | 12 | export function isValidUrl(url: string) { 13 | try { 14 | new URL(url); 15 | return true; 16 | } catch (e) { 17 | return false; 18 | } 19 | } 20 | 21 | export function getUrlFromString(str: string) { 22 | if (isValidUrl(str)) return str; 23 | try { 24 | if (str.includes('.') && !str.includes(' ')) { 25 | return new URL(`https://${str}`).toString(); 26 | } 27 | } catch (e) { 28 | return null; 29 | } 30 | } 31 | 32 | export function isBrowser() { 33 | return typeof window !== 'undefined'; 34 | } 35 | 36 | export function createDebouncedCallback any>( 37 | callback: T, 38 | delay: number 39 | ) { 40 | let timeout: ReturnType | null = null; 41 | return (...args: Parameters) => { 42 | if (timeout) clearTimeout(timeout); 43 | timeout = setTimeout(() => callback(...args), delay); 44 | }; 45 | } 46 | 47 | export function anyify(obj: unknown) { 48 | return obj as any; 49 | } 50 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |