feat: basic rich text input & format

This commit is contained in:
Shibo Lyu 2024-08-30 03:21:51 +08:00
parent e386fe2583
commit ca380e9ce6
8 changed files with 219 additions and 24 deletions

88
package-lock.json generated
View file

@ -9,7 +9,9 @@
"version": "0.0.1",
"dependencies": {
"svelte-boring-avatars": "^1.2.6",
"tailwind-merge": "^2.5.2"
"tailwind-merge": "^2.5.2",
"typewriter-editor": "^0.12.6",
"zod": "^3.23.8"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
@ -51,7 +53,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@ -642,7 +643,6 @@
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
@ -657,7 +657,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -667,7 +666,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -677,14 +675,12 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -747,6 +743,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.21.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz",
@ -1096,7 +1102,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@ -1342,6 +1347,24 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typewriter/delta": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@typewriter/delta/-/delta-1.2.0.tgz",
"integrity": "sha512-AGFvfeXkef7qUBuBbbAw7werb/BlHuhIY8xmDdm+WECoqX8YxhWtcikaI42UhBlwbpCI2R0jEkaJml5Ngb4uzw==",
"license": "MIT",
"dependencies": {
"fast-diff": "1.3.0"
}
},
"node_modules/@typewriter/document": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@typewriter/document/-/document-0.9.0.tgz",
"integrity": "sha512-PAoqXCHmNtx3qM1vxiRFGJC2tWiQdbXHD19xf48jCbvwZIn5Lkc2FS9GibscPhjSqqK3GYpo1aCkWbUTbaDblg==",
"license": "MIT",
"dependencies": {
"@typewriter/delta": "^1.2.0"
}
},
"node_modules/@vitest/expect": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
@ -1433,7 +1456,6 @@
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -1534,7 +1556,6 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
@ -1592,7 +1613,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -1822,7 +1842,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
@ -1898,7 +1917,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
@ -1970,7 +1988,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -2014,6 +2031,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/easy-signal": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/easy-signal/-/easy-signal-4.1.5.tgz",
"integrity": "sha512-OXMHK788HYND/KHYAlBwYULThsDHaToDSXsiuTfMi4QLp2UfCsssr+Qhb5CIzdNTjDuCKiS9j331QHaa4Lelhw==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.13",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz",
@ -2330,7 +2353,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
@ -2377,6 +2399,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -2843,7 +2871,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
@ -2991,7 +3018,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -3052,7 +3078,6 @@
"version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
"integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
@ -3062,7 +3087,6 @@
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge-stream": {
@ -3477,7 +3501,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@ -4134,7 +4157,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -4390,7 +4412,6 @@
"version": "4.2.19",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz",
"integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@ -4887,6 +4908,20 @@
}
}
},
"node_modules/typewriter-editor": {
"version": "0.12.6",
"resolved": "https://registry.npmjs.org/typewriter-editor/-/typewriter-editor-0.12.6.tgz",
"integrity": "sha512-ZFaFLLjPHL8QzGHPu5tAPbxZxPYB+21dopURqmbDdFEOoNurhFTFrl61Q6hfF7aJL6G9M8TKwAZS5nqV6H618A==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"@typewriter/document": "^0.9.0",
"easy-signal": "^4.1.3"
},
"peerDependencies": {
"svelte": ">=3.43.0 <5"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
@ -5271,6 +5306,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -37,6 +37,8 @@
"type": "module",
"dependencies": {
"svelte-boring-avatars": "^1.2.6",
"tailwind-merge": "^2.5.2"
"tailwind-merge": "^2.5.2",
"typewriter-editor": "^0.12.6",
"zod": "^3.23.8"
}
}

View file

@ -43,3 +43,9 @@
}
}
}
@layer utilities {
.rich-text {
@apply prose prose-slate dark:prose-invert prose-p:my-0 prose-p:leading-tight prose-code:before:content-[''] prose-code:after:content-[''];
}
}

View file

@ -11,7 +11,7 @@
</head>
<body
data-sveltekit-preload-data="hover"
class="relative h-[100dvh] max-w-[100vw] touch-pan-x touch-pan-y select-none overflow-hidden bg-sb-secondary text-sf-primary"
class="relative flex h-[100dvh] max-w-[100vw] touch-pan-x touch-pan-y select-none flex-col overflow-hidden bg-sb-secondary text-sf-primary"
>
<div style="display: contents">%sveltekit.body%</div>
</body>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { Delta } from 'typewriter-editor';
export let delta: Delta;
let className = '';
export { className as class };
const loadClientComponent = async () => {
if (!browser) return;
const { default: ClientInput } = await import('./RichTextInput/ClientInput.svelte');
return ClientInput;
};
</script>
{#await loadClientComponent() then Input}
<svelte:component this={Input} bind:delta class={className}><slot /></svelte:component>
{/await}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import InputFrame from '$lib/components/InputFrame.svelte';
import { tw } from '$lib/tw';
import { Delta, Editor, asRoot } from 'typewriter-editor';
let className = '';
export { className as class };
export let delta: Delta;
const editor = new Editor();
delta = editor.getDelta();
editor.on('change', () => {
delta = editor.getDelta();
});
$: editor.setDelta(delta);
</script>
<InputFrame class={tw('overflow-y-auto', className)}>
<div class="rich-text w-full outline-none" use:asRoot={editor}>
<slot />
</div>
</InputFrame>

70
src/lib/richText.ts Normal file
View file

@ -0,0 +1,70 @@
import type { AttributeMap, Delta } from 'typewriter-editor';
import { z } from 'zod';
export const blahRichTextSpanAttributesSchema = z.object({
b: z.boolean().default(false),
i: z.boolean().default(false),
u: z.boolean().default(false),
s: z.boolean().default(false),
m: z.boolean().default(false),
hashtag: z.boolean().default(false),
link: z.string().url().optional()
});
export type BlahRichTextSpanAttributes = z.input<typeof blahRichTextSpanAttributesSchema>;
export const blahRichTextSpanSchema = z.union([
z.tuple([z.string()]),
z.tuple([z.string(), blahRichTextSpanAttributesSchema])
]);
export type BlahRichTextSpan = z.input<typeof blahRichTextSpanSchema>;
export const blahRichTextBlockSchema = z.array(blahRichTextSpanSchema);
export type BlahRichTextBlock = z.input<typeof blahRichTextBlockSchema>;
export const blahRichTextSchema = z.array(blahRichTextBlockSchema);
export type BlahRichText = z.input<typeof blahRichTextSchema>;
function isObjectEmpty(obj: object) {
for (const _ in obj) return false;
return true;
}
function deltaAttributesToBlahRichTextSpanAttributes(
attributes?: AttributeMap
): BlahRichTextSpanAttributes | null {
if (!attributes) return null;
const blahRichTextSpanAttributes: BlahRichTextSpanAttributes = {};
if (attributes.bold) blahRichTextSpanAttributes.b = true;
if (attributes.italic) blahRichTextSpanAttributes.i = true;
if (attributes.code) blahRichTextSpanAttributes.m = true;
if (attributes.link) blahRichTextSpanAttributes.link = attributes.link;
if (attributes.underline) blahRichTextSpanAttributes.u = true;
if (attributes.strike) blahRichTextSpanAttributes.s = true;
return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes;
}
export function deltaToBlahRichText(delta: Delta): BlahRichText {
const blocks: BlahRichText = [];
let block: BlahRichTextBlock = [];
for (const op of delta.ops) {
const lines = op.insert?.split('\n');
if (!lines) continue;
const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes);
const line = lines.shift();
block.push(attributes ? [line, attributes] : [line]);
for (const line of lines) {
blocks.push(block);
block = [];
block.push(attributes ? [line, attributes] : [line]);
}
}
return blocks;
}

View file

@ -0,0 +1,30 @@
<script lang="ts">
import RichTextInput from '$lib/components/RichTextInput.svelte';
import { deltaToBlahRichText } from '$lib/richText';
import type { Delta } from 'typewriter-editor';
let delta: Delta;
$: brt = delta ? deltaToBlahRichText(delta) : null;
</script>
<RichTextInput bind:delta class="m-4 max-h-32">
<p>A <strong>quick</strong> brown <em>fox</em> jumps over the lazy dog.</p>
<p>A test engineer <a href="https://example.com">tests</a> the <code>RichTextInput</code>.</p>
</RichTextInput>
<div class="flex min-h-0 flex-1 gap-4 p-4">
<div class="flex min-h-0 flex-1 flex-col">
<h2 class="text-lg">Delta (Editor's internal representation)</h2>
<div class="min-h-0 flex-1 select-text overflow-auto">
<pre><code>{JSON.stringify(delta, null, 2)}</code></pre>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<h2 class="text-lg">Blah Rich Text</h2>
<div class="min-h-0 flex-1 select-text overflow-auto">
<pre><code>{JSON.stringify(brt, null, 2)}</code></pre>
</div>
</div>
</div>