├── .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 |
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 |
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 |
10 |
11 |
12 | {
15 | saveStatus = 'Unsaved';
16 | }}
17 | onDebouncedUpdate={() => {
18 | saveStatus = 'Saving...';
19 | // Simulate a delay in saving.
20 | setTimeout(() => {
21 | saveStatus = 'Saved';
22 | }, 500);
23 | }}
24 | >
25 |
28 | {saveStatus}
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/routes/api/generate/+server.ts:
--------------------------------------------------------------------------------
1 | import OpenAI from 'openai';
2 | import { OpenAIStream, StreamingTextResponse } from 'ai';
3 | import { kv } from '@vercel/kv';
4 | import { Ratelimit } from '@upstash/ratelimit';
5 | import { OPENAI_API_KEY } from '$env/static/private';
6 | import type { RequestHandler } from '@sveltejs/kit';
7 |
8 | // Create an OpenAI API client (that's edge friendly!)
9 | const openai = new OpenAI({
10 | apiKey: OPENAI_API_KEY || ''
11 | });
12 |
13 | // IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
14 | export const config = {
15 | runtime: 'edge'
16 | };
17 |
18 | export const POST: RequestHandler = async ({ request: req }) => {
19 | // Check if the OPENAI_API_KEY is set, if not return 400
20 | if (!OPENAI_API_KEY) {
21 | return new Response('Missing OPENAI_API_KEY – make sure to add it to your .env file.', {
22 | status: 400
23 | });
24 | }
25 | if (
26 | process.env.NODE_ENV != 'development' &&
27 | process.env.KV_REST_API_URL &&
28 | process.env.KV_REST_API_TOKEN
29 | ) {
30 | const ip = req.headers.get('x-forwarded-for');
31 | const ratelimit = new Ratelimit({
32 | redis: kv,
33 | limiter: Ratelimit.slidingWindow(50, '1 d')
34 | });
35 |
36 | const { success, limit, reset, remaining } = await ratelimit.limit(`novel_ratelimit_${ip}`);
37 |
38 | if (!success) {
39 | return new Response('You have reached your request limit for the day.', {
40 | status: 429,
41 | headers: {
42 | 'X-RateLimit-Limit': limit.toString(),
43 | 'X-RateLimit-Remaining': remaining.toString(),
44 | 'X-RateLimit-Reset': reset.toString()
45 | }
46 | });
47 | }
48 | }
49 |
50 | const { prompt } = await req.json();
51 |
52 | const response = await openai.chat.completions.create({
53 | model: 'gpt-3.5-turbo',
54 | messages: [
55 | {
56 | role: 'system',
57 | content:
58 | 'You are an AI writing assistant that continues existing text based on context from prior text. ' +
59 | 'Give more weight/priority to the later characters than the beginning ones. ' +
60 | 'Limit your response to no more than 200 characters, but make sure to construct complete sentences.'
61 | // we're disabling markdown for now until we can figure out a way to stream markdown text with proper formatting: https://github.com/steven-tey/novel/discussions/7
62 | // "Use Markdown formatting when appropriate.",
63 | },
64 | {
65 | role: 'user',
66 | content: prompt
67 | }
68 | ],
69 | temperature: 0.7,
70 | top_p: 1,
71 | frequency_penalty: 0,
72 | presence_penalty: 0,
73 | stream: true,
74 | n: 1
75 | });
76 |
77 | // Convert the response into a friendly text-stream
78 | const stream = OpenAIStream(response);
79 |
80 | // Respond with the stream
81 | return new StreamingTextResponse(stream);
82 | };
83 |
--------------------------------------------------------------------------------
/src/routes/github.svelte:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/src/routes/nav.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
19 |
--------------------------------------------------------------------------------
/src/routes/theme-switch.svelte:
--------------------------------------------------------------------------------
1 |
38 |
39 | {#if browser}
40 |
55 | {/if}
56 |
57 |
61 |
62 |
73 |
84 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fonts/CalSans-SemiBold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TGlide/novel-svelte/016e3286335c32f6a283ee6636aeaa4abcf509c9/static/fonts/CalSans-SemiBold.otf
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { preprocessMeltUI } from '@melt-ui/pp';
2 | import sequence from 'svelte-sequential-preprocessor';
3 | import adapter from '@sveltejs/adapter-auto';
4 | import { vitePreprocess } from '@sveltejs/kit/vite';
5 | /** @type {import('@sveltejs/kit').Config}*/
6 | const config = {
7 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
8 | // for more information about preprocessors
9 | preprocess: sequence([vitePreprocess({}), preprocessMeltUI()]),
10 | kit: {
11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter.
13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters.
14 | adapter: adapter(),
15 | alias: {
16 | $lib: './src/lib'
17 | }
18 | }
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { Config } from 'tailwindcss';
2 |
3 | import sharedConfig from './src/lib/styles/tailwind.config.cjs';
4 | import plugin from 'tailwindcss/plugin';
5 |
6 | const config: Config = {
7 | presets: [sharedConfig],
8 | content: sharedConfig.content,
9 | plugins: [
10 | plugin(function ({ addVariant, matchUtilities, theme }) {
11 | addVariant('hocus', ['&:hover', '&:focus']);
12 | // Square utility
13 | matchUtilities(
14 | {
15 | square: (value) => ({
16 | width: value,
17 | height: value
18 | })
19 | },
20 | { values: theme('spacing') }
21 | );
22 | })
23 | ]
24 | };
25 |
26 | export default config;
27 |
--------------------------------------------------------------------------------
/tests/test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test('index page has expected h1', async ({ page }) => {
4 | await page.goto('/');
5 | await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
6 | });
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "noImplicitAny": true,
13 | "moduleResolution": "NodeNext"
14 | }
15 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import { defineConfig } from 'vitest/config';
3 |
4 | export default defineConfig({
5 | plugins: [sveltekit()],
6 | test: {
7 | include: ['src/**/*.{test,spec}.{js,ts}']
8 | }
9 | });
10 |
--------------------------------------------------------------------------------