diff --git a/.gitignore b/.gitignore index 96d1b843..ee18d284 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,9 @@ yarn-error.log* vite.config.ts*.mjs -.pnpm-store/* \ No newline at end of file +.pnpm-store/* + +**/dist/* + +#Ignore vscode AI rules +.github/instructions/codacy.instructions.md diff --git a/apps/api/package.json b/apps/api/package.json index a804e0e6..ebfff36b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -58,7 +58,7 @@ }, "devDependencies": { "@types/katex": "^0.16.7", - "@types/node": "^20.11.19", + "@types/node": "^20.19.0", "@types/nodemailer": "^6.4.17", "@typescript-eslint/eslint-plugin": "7.0.1", "@typescript-eslint/parser": "7.0.1", diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index f7d94198..cb1bb49c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -15,7 +15,7 @@ "skipLibCheck": true, "sourceMap": true, "target": "ESNext", - "typeRoots": ["./node_modules/@types", "./src/types"], + "typeRoots": ["../../node_modules/@types", "./node_modules/@types", "./src/types"], "types": ["node"] }, "include": ["src/**/*"], diff --git a/apps/dashboard/babel.config.cjs b/apps/dashboard/babel.config.cjs deleted file mode 100644 index 619f8c1b..00000000 --- a/apps/dashboard/babel.config.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [['@babel/preset-env', { targets: { node: 'current' } }]] -}; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 5930cf4c..b059c888 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -21,6 +21,7 @@ "dependencies": { "@carbon/charts-svelte": "^1.13.6", "@cio/api": "workspace:*", + "@cio/ui": "workspace:*", "@lemonsqueezy/lemonsqueezy.js": "^2.2.0", "@lucide/svelte": "^0.545.0", "@polar-sh/sveltekit": "^0.2.1", @@ -34,6 +35,7 @@ "@types/pluralize": "0.0.30", "ai": "^2.1.31", "axios": "^1.8.4", + "bits-ui": "^2.14.1", "body-parser": "^1.20.2", "bufferutil": "^4.0.9", "canvas-confetti": "^1.6.0", @@ -78,8 +80,10 @@ "@babel/runtime": "^7.22.6", "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", + "@rollup/plugin-replace": "^6.0.2", "@sveltejs/kit": "^2.5.27", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/typography": "^0.5.9", "@testing-library/jest-dom": "^6.1.5", "@testing-library/svelte": "^4.0.5", @@ -87,7 +91,6 @@ "@types/lodash": "^4.14.196", "@types/node": "^20", "all-object-keys": "^2.2.0", - "autoprefixer": "^10.4.14", "babel-jest": "^29.7.0", "carbon-components-svelte": "^0.79.0", "diff": "^5.2.0", @@ -107,7 +110,7 @@ "svelte-jester": "^3.0.0", "svelte-loading-spinners": "^0.3.4", "svelte-preprocess": "^6.0.0", - "tailwindcss": "^3.3.2", + "tailwindcss": "^4.1.14", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "@cio/tsconfig": "workspace:*", diff --git a/apps/dashboard/postcss.config.js b/apps/dashboard/postcss.config.js index ba807304..a814fdcb 100644 --- a/apps/dashboard/postcss.config.js +++ b/apps/dashboard/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {} + '@tailwindcss/postcss': {}, } }; diff --git a/apps/dashboard/src/app.postcss b/apps/dashboard/src/app.css similarity index 68% rename from apps/dashboard/src/app.postcss rename to apps/dashboard/src/app.css index bad4aa4d..75b67a49 100644 --- a/apps/dashboard/src/app.postcss +++ b/apps/dashboard/src/app.css @@ -1,8 +1,9 @@ /*Write your global styles here, in PostCSS syntax */ -@tailwind base; -@tailwind base; -@tailwind components; -@tailwind utilities; +@import 'tailwindcss'; + +@import '@cio/ui/output.css'; + +@custom-variant dark (&:where(.dark, .dark *)); /** Global scrollbar styles */ html { @@ -84,7 +85,7 @@ h6 { margin-bottom: 10px !important; } -.dark .prose h1, +/* .dark .prose h1, .dark .prose h2, .dark .prose h3, .dark .prose h4, @@ -95,7 +96,7 @@ h6 { .dark .prose li::marker, .dark .prose p { color: #fff !important; -} +} */ iframe.iframe { width: 100%; @@ -192,10 +193,6 @@ svg.lucide-icon:not(.custom) { } } -@tailwind components; - -@tailwind utilities; - /** Rose Theme */ @@ -710,3 +707,312 @@ svg.lucide-icon:not(.custom) { .theme-violet .border-primary-900 { border-color: #890684 !important; } + +/* Tailwind v4 Custom Animations and Keyframes */ +@theme { + --animate-shake-announcement: shakeAnnouncement 0.5s ease-in-out; + --animate-flip-book: flipBook 0.6s ease-in-out; + --animate-scroll-unroll: scrollUnroll 0.8s ease-out; + --animate-check-items: attendanceCount 0.8s ease-in-out; + --animate-task-complete: taskComplete 0.6s ease-in-out; + --animate-grade-glow: gradeGlow 1s ease-in-out; + --animate-people-wave: peopleWave 0.8s ease-in-out; + --animate-chart-grow: dataScan 1s ease-in-out; + --animate-screen-glow: screenGlow 1s ease-in-out; + --animate-library-expand: libraryExpand 0.7s ease-out; + --animate-template-morph: templateMorph 0.6s ease-in-out; + --animate-ripple: communityRipple 0.9s ease-out; + --animate-roll: quizRoll 0.8s ease-in-out; + --animate-gather: audienceGather 0.7s ease-in-out; + --animate-setup-configure: setupConfigure 1s ease-in-out; +} + +@keyframes shakeAnnouncement { + 0%, + 100% { + transform: translateX(0); + } + 10%, + 30%, + 50%, + 70%, + 90% { + transform: translateX(-2px); + } + 20%, + 40%, + 60%, + 80% { + transform: translateX(2px); + } +} + +@keyframes flipBook { + 0% { + transform: rotateY(0deg); + } + 50% { + transform: rotateY(90deg); + } + 100% { + transform: rotateY(0deg); + } +} + +@keyframes scrollUnroll { + 0% { + transform: scaleY(0) translateY(-50%); + opacity: 0.5; + } + 50% { + transform: scaleY(0.8) translateY(-25%); + opacity: 0.8; + } + 100% { + transform: scaleY(1) translateY(0); + opacity: 1; + } +} + +@keyframes attendanceCount { + 0% { + transform: scale(1); + opacity: 0.8; + } + 20% { + transform: scale(1.1); + opacity: 1; + } + 40% { + transform: scale(1.05); + opacity: 0.9; + } + 60% { + transform: scale(1.15); + opacity: 1; + } + 80% { + transform: scale(1.02); + opacity: 0.95; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes taskComplete { + 0% { + transform: scale(1) rotate(0deg); + } + 25% { + transform: scale(1.1) rotate(-5deg); + } + 50% { + transform: scale(1.2) rotate(5deg); + } + 75% { + transform: scale(1.1) rotate(-2deg); + } + 100% { + transform: scale(1) rotate(0deg); + } +} + +@keyframes gradeGlow { + 0%, + 100% { + transform: scale(1); + filter: brightness(1) drop-shadow(0 0 0 rgba(34, 197, 94, 0)); + } + 50% { + transform: scale(1.15); + filter: brightness(1.3) drop-shadow(0 0 8px rgba(34, 197, 94, 0.6)); + } +} + +@keyframes peopleWave { + 0%, + 100% { + transform: rotate(0deg) scale(1); + } + 25% { + transform: rotate(-5deg) scale(1.05); + } + 50% { + transform: rotate(5deg) scale(1.1); + } + 75% { + transform: rotate(-3deg) scale(1.05); + } +} + +@keyframes dataScan { + 0% { + transform: scale(1); + filter: brightness(1) drop-shadow(0 0 0 rgba(99, 102, 241, 0)); + } + 25% { + transform: scale(1.05); + filter: brightness(1.1) drop-shadow(0 0 4px rgba(99, 102, 241, 0.4)); + } + 50% { + transform: scale(1.1); + filter: brightness(1.2) drop-shadow(0 0 8px rgba(99, 102, 241, 0.6)); + } + 75% { + transform: scale(1.15) rotate(2deg); + filter: brightness(1.3) drop-shadow(0 0 12px rgba(99, 102, 241, 0.8)); + } + 100% { + transform: scale(1); + filter: brightness(1) drop-shadow(0 0 0 rgba(99, 102, 241, 0)); + } +} + +@keyframes screenGlow { + 0%, + 100% { + transform: scale(1); + filter: brightness(1) drop-shadow(0 0 0 rgba(59, 130, 246, 0)); + } + 50% { + transform: scale(1.1); + filter: brightness(1.2) drop-shadow(0 0 6px rgba(59, 130, 246, 0.5)); + } +} + +@keyframes libraryExpand { + 0% { + transform: translateY(-100%) scale(0.8); + opacity: 0; + } + 30% { + transform: translateY(-20%) scale(0.9); + opacity: 0.7; + } + 60% { + transform: translateY(5%) scale(1.05); + opacity: 1; + } + 80% { + transform: translateY(-2%) scale(1.02); + opacity: 1; + } + 100% { + transform: translateY(0%) scale(1); + opacity: 1; + } +} + +@keyframes templateMorph { + 0% { + transform: scale(1) rotate(0deg); + } + 25% { + transform: scale(1.05) rotate(-2deg) skewX(2deg); + } + 50% { + transform: scale(1.1) rotate(2deg) skewX(-2deg); + } + 75% { + transform: scale(1.05) rotate(-1deg) skewX(1deg); + } + 100% { + transform: scale(1) rotate(0deg) skewX(0deg); + } +} + +@keyframes communityRipple { + 0% { + transform: translateX(-100%) scale(0.8); + opacity: 0; + } + 30% { + transform: translateX(-20%) scale(0.9); + opacity: 0.7; + } + 60% { + transform: translateX(5%) scale(1.05); + opacity: 1; + } + 80% { + transform: translateX(-2%) scale(1.02); + opacity: 1; + } + 100% { + transform: translateX(0%) scale(1); + opacity: 1; + } +} + +@keyframes quizRoll { + 0% { + transform: scale(1) rotate(0deg); + } + 20% { + transform: scale(1.05) rotate(72deg); + } + 40% { + transform: scale(1.1) rotate(144deg); + } + 60% { + transform: scale(1.15) rotate(216deg); + } + 80% { + transform: scale(1.1) rotate(288deg); + } + 100% { + transform: scale(1) rotate(360deg); + } +} + +@keyframes audienceGather { + 0% { + transform: scale(1); + filter: brightness(1) drop-shadow(0 0 0 rgba(147, 51, 234, 0)); + } + 25% { + transform: scale(0.95) scaleX(1.1); + filter: brightness(1.05) drop-shadow(0 0 2px rgba(147, 51, 234, 0.3)); + } + 50% { + transform: scale(1.1) scaleX(0.9); + filter: brightness(1.1) drop-shadow(0 0 4px rgba(147, 51, 234, 0.5)); + } + 75% { + transform: scale(1.05) scaleX(1.05); + filter: brightness(1.05) drop-shadow(0 0 2px rgba(147, 51, 234, 0.3)); + } + 100% { + transform: scale(1) scaleX(1); + filter: brightness(1) drop-shadow(0 0 0 rgba(147, 51, 234, 0)); + } +} + +@keyframes setupConfigure { + 0% { + transform: scale(1) rotate(0deg); + } + 15% { + transform: scale(1.02) rotate(15deg); + } + 30% { + transform: scale(1.04) rotate(-10deg); + } + 45% { + transform: scale(1.06) rotate(20deg); + } + 60% { + transform: scale(1.08) rotate(-15deg); + } + 75% { + transform: scale(1.06) rotate(10deg); + } + 90% { + transform: scale(1.02) rotate(-5deg); + } + 100% { + transform: scale(1) rotate(0deg); + } +} diff --git a/apps/dashboard/src/app.html b/apps/dashboard/src/app.html index baba87df..0ade6572 100644 --- a/apps/dashboard/src/app.html +++ b/apps/dashboard/src/app.html @@ -1,33 +1,31 @@ - - - - - - - - + + + + - - - - - + + + + - + + + + - + - %sveltekit.head% - + - -
%sveltekit.body%
- - + %sveltekit.head% + + + +
%sveltekit.body%
+ + + \ No newline at end of file diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte index db185984..899a63d8 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Materials/index.svelte @@ -23,7 +23,7 @@ import { snackbar } from '$lib/components/Snackbar/store'; import TabContent from '$lib/components/TabContent/index.svelte'; import Tabs from '$lib/components/Tabs/index.svelte'; - import TextEditor from '$lib/components/TextEditor/index.svelte'; + // import TextEditor from '$lib/components/TextEditor/index.svelte'; import MODES from '$lib/utils/constants/mode'; import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo'; import { supabase } from '$lib/utils/functions/supabase'; @@ -67,16 +67,27 @@ toggleMode = () => {} }: Props = $props(); + // let content = $state(); + // let editor = $state(); + // function onUpdate() { + // content = editor?.getHTML(); + // console.log('content updated:', content); + // if (mode === MODES.view) return; + + // $lessonByTranslation[lessonId][$lesson.locale] = content; + + // try { + // localStorage.setItem(`lesson-${lessonId}-${$lesson.locale}`, content); + // } catch (error) {} + // $isLessonDirty = true; + // } + let localeExists: Record = {}; // let prevContent = ''; let timeoutId: NodeJS.Timeout; let errors: Record = {}; - let editorWindowRef: Window | undefined = $state(); - let aiButtonRef: HTMLDivElement | undefined = $state(); - let openPopover = $state(false); - let player: HTMLVideoElement | undefined = $state(); - let aiButtonClass = - 'flex items-center gap-2 px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2'; + // let editorWindowRef: Window | undefined = $state(); + ('flex items-center gap-2 px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2'); const lessonTitle = $derived($lesson.title); @@ -324,7 +335,7 @@ initPlyr(player, $lesson.materials.videos); }); - let editorValue = $derived( + let _editorValue = $derived( lessonFallbackNote($lesson.materials.note, $lessonByTranslation[lessonId], $lesson.locale) ); @@ -403,7 +414,26 @@
- + {#if editor && !editor.isDestroyed} + + {:else} +
+ Debug: Editor state - {editor ? 'exists' : 'undefined'}, destroyed: {editor?.isDestroyed || 'N/A'} +
+ {/if} + +
--> + diff --git a/apps/dashboard/src/lib/components/Course/components/Navigation/NavExpandable.svelte b/apps/dashboard/src/lib/components/Course/components/Navigation/NavExpandable.svelte index 471b69be..54875dda 100644 --- a/apps/dashboard/src/lib/components/Course/components/Navigation/NavExpandable.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Navigation/NavExpandable.svelte @@ -79,7 +79,7 @@
+ + +

Help information

+
+ + + {/snippet} + + + + {#snippet template()} + +
+ + + + + +

Save your work (Ctrl+S)

+
+
+ + + + + + +

Edit this item

+
+
+ + + + + + +

Permanently delete this item

+
+
+
+
+ {/snippet} +
+ + + {#snippet template()} + + + + + + +

Settings

+
+
+
+ {/snippet} +
+ + + {#snippet template()} + + + + + + +

+ This is a longer tooltip with more detailed information that might span multiple lines to provide + comprehensive context about the feature or action. +

+
+
+
+ {/snippet} +
diff --git a/packages/storybook/src/index.css b/packages/storybook/src/index.css new file mode 100644 index 00000000..525fd280 --- /dev/null +++ b/packages/storybook/src/index.css @@ -0,0 +1,3 @@ +@import 'tailwindcss'; + +@import '@cio/ui/output.css'; diff --git a/packages/storybook/src/molecules/editor/constants.ts b/packages/storybook/src/molecules/editor/constants.ts new file mode 100644 index 00000000..040b2d31 --- /dev/null +++ b/packages/storybook/src/molecules/editor/constants.ts @@ -0,0 +1,62 @@ +import type { EditorProps } from '@cio/ui/custom/editor/types'; + +export const FIELDS: (keyof EditorProps)[] = [ + 'showToolBar', + 'editable', + 'enablePersistence', + 'contentStorageKey', + 'editableStorageKey', + 'class', + 'editorClass' +]; + +export const DEFAULT_CONTENT = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Welcome to the Editor' }] + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'This is a ' }, + { type: 'text', marks: [{ type: 'bold' }], text: 'rich text editor' }, + { type: 'text', text: ' with support for various formatting options.' } + ] + }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Support for lists' }] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Rich text formatting' }] + } + ] + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'And much more!' }] + } + ] + } + ] + } + ] +}; diff --git a/packages/storybook/src/molecules/editor/editor.stories.svelte b/packages/storybook/src/molecules/editor/editor.stories.svelte new file mode 100644 index 00000000..edd024b1 --- /dev/null +++ b/packages/storybook/src/molecules/editor/editor.stories.svelte @@ -0,0 +1,82 @@ + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + + {/snippet} + + + + {#snippet template()} + console.log('Content changed:', content)} + onEditorReady={(editor) => console.log('Editor ready:', editor)} + /> + {/snippet} + + + + {#snippet template()} +
+
+

Article

+ +
+
+

Notes

+ +
+
+ {/snippet} +
diff --git a/packages/storybook/svelte.config.js b/packages/storybook/svelte.config.js new file mode 100644 index 00000000..5ebff6c2 --- /dev/null +++ b/packages/storybook/svelte.config.js @@ -0,0 +1,16 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + } +}; + +export default config; diff --git a/packages/storybook/tsconfig.json b/packages/storybook/tsconfig.json new file mode 100644 index 00000000..69d0f1f2 --- /dev/null +++ b/packages/storybook/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "@cio/tsconfig/base.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, + "module": "ESNext", + "moduleResolution": "bundler", + "noImplicitAny": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "paths": { + "@cio/ui": ["../ui/src"], + "@cio/ui/*": ["../ui/src/*"], + "$src/*": ["../ui/*"] + }, + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"], + "types": ["node"], + "jsx": "preserve", + "lib": ["ES2020", "DOM"], + "plugins": [ + { + "name": "typescript-svelte-plugin", + "enabled": true, + "assumeIsSvelteProject": true + } + ] + }, + "include": ["src/**/*", ".storybook/**/*", "vite.config.ts"] +} diff --git a/packages/storybook/vite.config.ts b/packages/storybook/vite.config.ts new file mode 100644 index 00000000..27ce9f02 --- /dev/null +++ b/packages/storybook/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import * as path from 'path'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss(), svelte()], + resolve: { + alias: { + '@cio/ui': path.resolve(__dirname, '../ui/src'), + $src: path.resolve(__dirname, '../ui/src') + }, + conditions: ['svelte', 'browser', 'import'] + }, + optimizeDeps: { + include: ['svelte-tiptap'], + exclude: ['@tiptap/core', '@tiptap/pm'] + }, + ssr: { + noExternal: ['svelte-tiptap'] + } +}); diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 891f345b..a670fc45 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "@cio/tsconfig", - "version": "0.0.0", + "version": "0.0.1", "private": true, "license": "MIT", "publishConfig": { diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 00000000..98a4b00e --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/index.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$src", + "utils": "$src/tools/index", + "ui": "$src/base", + "hooks": "$src/hooks" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..4ad4dee4 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,56 @@ +{ + "name": "@cio/ui", + "version": "0.0.1", + "type": "module", + "scripts": { + "generate-css": "echo 'generating CSS' && pnpx @tailwindcss/cli -i ./src/index.css -o ./src/output.css --minify", + "build": "pnpm generate-css", + "dev": "pnpm generate-css --watch" + }, + "devDependencies": { + "@internationalized/date": "^3.8.1", + "@lucide/svelte": "^0.544.0", + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.5.27", + "@sveltejs/vite-plugin-svelte": "^4.0.4", + "@tailwindcss/typography": "^0.5.18", + "bits-ui": "^2.11.7", + "clsx": "^2.1.1", + "svelte": "^5.39.11", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.1.1", + "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0" + }, + "dependencies": { + "@aarkue/tiptap-math-extension": "^1.4.0", + "@floating-ui/dom": "^1.7.4", + "@lucide/svelte": "^0.546.0", + "@tiptap/core": "^3.7.1", + "@tiptap/extension-bubble-menu": "^3.7.1", + "@tiptap/extension-code-block-lowlight": "^3.7.1", + "@tiptap/extension-highlight": "^3.7.1", + "@tiptap/extension-image": "^3.7.1", + "@tiptap/extension-list": "^3.7.1", + "@tiptap/extension-subscript": "^3.7.1", + "@tiptap/extension-superscript": "^3.7.1", + "@tiptap/extension-table": "^3.7.1", + "@tiptap/extension-text-align": "^3.7.1", + "@tiptap/extension-text-style": "^3.7.1", + "@tiptap/extension-typography": "^3.7.1", + "@tiptap/extensions": "^3.7.1", + "@tiptap/pm": "^3.7.1", + "@tiptap/starter-kit": "^3.7.1", + "@tiptap/suggestion": "^3.7.1", + "katex": "^0.16.22", + "lowlight": "^3.3.0", + "mode-watcher": "^0.5.1", + "svelte-tiptap": "^3.0.0", + "tippy.js": "^6.3.7", + "tiptap-extension-auto-joiner": "^0.1.3", + "tiptap-markdown": "^0.9.0" + }, + "peerDependencies": { + "bits-ui": "^2.11.0" + } +} diff --git a/packages/ui/src/base/button/button.svelte b/packages/ui/src/base/button/button.svelte new file mode 100644 index 00000000..f2ab5073 --- /dev/null +++ b/packages/ui/src/base/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} +
+ {@render children?.()} + +{:else} + +{/if} diff --git a/packages/ui/src/base/button/index.ts b/packages/ui/src/base/button/index.ts new file mode 100644 index 00000000..fb585d76 --- /dev/null +++ b/packages/ui/src/base/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/packages/ui/src/base/dark-mode/dark-mode.svelte b/packages/ui/src/base/dark-mode/dark-mode.svelte new file mode 100644 index 00000000..42c63ae8 --- /dev/null +++ b/packages/ui/src/base/dark-mode/dark-mode.svelte @@ -0,0 +1,21 @@ + + + + + + + Toggle theme + + + setMode('light')}>Light + setMode('dark')}>Dark + resetMode()}>System + + diff --git a/packages/ui/src/base/dark-mode/index.ts b/packages/ui/src/base/dark-mode/index.ts new file mode 100644 index 00000000..0cef385e --- /dev/null +++ b/packages/ui/src/base/dark-mode/index.ts @@ -0,0 +1,3 @@ +export { default as ModeSwitcher } from './dark-mode.svelte'; + +export * from 'mode-watcher'; diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 00000000..55f5d9e3 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 00000000..d78f4fdc --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 00000000..a1862241 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 00000000..aca1f7bd --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 00000000..ac114f77 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 00000000..169dcdc8 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 00000000..189aef40 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 00000000..2ffa2640 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 00000000..b6291860 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 00000000..2dc21cf2 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 00000000..feebadfb --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 00000000..fcbee0cc --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 00000000..cb053444 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/base/dropdown-menu/index.ts b/packages/ui/src/base/dropdown-menu/index.ts new file mode 100644 index 00000000..1cf9f701 --- /dev/null +++ b/packages/ui/src/base/dropdown-menu/index.ts @@ -0,0 +1,49 @@ +import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +const Sub = DropdownMenuPrimitive.Sub; +const Root = DropdownMenuPrimitive.Root; + +export { + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/packages/ui/src/base/input/index.ts b/packages/ui/src/base/input/index.ts new file mode 100644 index 00000000..f47b6d3f --- /dev/null +++ b/packages/ui/src/base/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/packages/ui/src/base/input/input.svelte b/packages/ui/src/base/input/input.svelte new file mode 100644 index 00000000..952e236b --- /dev/null +++ b/packages/ui/src/base/input/input.svelte @@ -0,0 +1,51 @@ + + +{#if type === 'file'} + +{:else} + +{/if} diff --git a/packages/ui/src/base/label/index.ts b/packages/ui/src/base/label/index.ts new file mode 100644 index 00000000..8bfca0b3 --- /dev/null +++ b/packages/ui/src/base/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/packages/ui/src/base/label/label.svelte b/packages/ui/src/base/label/label.svelte new file mode 100644 index 00000000..36eac73f --- /dev/null +++ b/packages/ui/src/base/label/label.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/ui/src/base/popover/index.ts b/packages/ui/src/base/popover/index.ts new file mode 100644 index 00000000..9f30922f --- /dev/null +++ b/packages/ui/src/base/popover/index.ts @@ -0,0 +1,17 @@ +import { Popover as PopoverPrimitive } from "bits-ui"; +import Content from "./popover-content.svelte"; +import Trigger from "./popover-trigger.svelte"; +const Root = PopoverPrimitive.Root; +const Close = PopoverPrimitive.Close; + +export { + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, +}; diff --git a/packages/ui/src/base/popover/popover-content.svelte b/packages/ui/src/base/popover/popover-content.svelte new file mode 100644 index 00000000..e09fc407 --- /dev/null +++ b/packages/ui/src/base/popover/popover-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/packages/ui/src/base/popover/popover-trigger.svelte b/packages/ui/src/base/popover/popover-trigger.svelte new file mode 100644 index 00000000..f8c0e174 --- /dev/null +++ b/packages/ui/src/base/popover/popover-trigger.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/ui/src/base/skeleton/index.ts b/packages/ui/src/base/skeleton/index.ts new file mode 100644 index 00000000..186db219 --- /dev/null +++ b/packages/ui/src/base/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/packages/ui/src/base/skeleton/skeleton.svelte b/packages/ui/src/base/skeleton/skeleton.svelte new file mode 100644 index 00000000..42f7e0f9 --- /dev/null +++ b/packages/ui/src/base/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/packages/ui/src/base/tooltip/index.ts b/packages/ui/src/base/tooltip/index.ts new file mode 100644 index 00000000..313a7f06 --- /dev/null +++ b/packages/ui/src/base/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/packages/ui/src/base/tooltip/tooltip-content.svelte b/packages/ui/src/base/tooltip/tooltip-content.svelte new file mode 100644 index 00000000..f6289021 --- /dev/null +++ b/packages/ui/src/base/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
+ {/snippet} +
+
+
diff --git a/packages/ui/src/base/tooltip/tooltip-trigger.svelte b/packages/ui/src/base/tooltip/tooltip-trigger.svelte new file mode 100644 index 00000000..1acdaa47 --- /dev/null +++ b/packages/ui/src/base/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/custom/editor/USAGE.md b/packages/ui/src/custom/editor/USAGE.md new file mode 100644 index 00000000..b9959937 --- /dev/null +++ b/packages/ui/src/custom/editor/USAGE.md @@ -0,0 +1,242 @@ +# Reusable Editor Component Usage Examples + +The Editor component has been rewritten to be fully reusable with configurable props instead of managing internal state. + +## Basic Usage + +```svelte + + + +``` + +## Using HTML Content + +You can also initialize the editor with HTML content by converting it to the appropriate Content format: + +```svelte + + + +``` + +You can also set HTML content programmatically: + +```svelte + + + + + +``` + +## Advanced Usage with All Props + +```svelte + + + +``` + +## Multiple Editors + +```svelte + + +
+
+

Article

+ +
+ +
+

Notes

+ +
+
+``` + +## Props Reference + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `content` | `Content` | Default doc with placeholder | Editor content (bindable) | +| `showToolBar` | `boolean` | `true` | Whether to show the toolbar | +| `editable` | `boolean` | `true` | Whether the editor is editable | +| `enablePersistence` | `boolean` | `false` | Enable localStorage persistence | +| `contentStorageKey` | `string` | `'edra-content'` | localStorage key for content | +| `editableStorageKey` | `string` | `'edra-editable'` | localStorage key for editable state | +| `class` | `string` | Default wrapper classes | CSS classes for wrapper | +| `editorClass` | `string` | `'h-[32rem] overflow-auto'` | CSS classes for editor | +| `onContentChange` | `(content: Content) => void` | `undefined` | Callback when content changes | +| `onEditorReady` | `(editor: Editor) => void` | `undefined` | Callback when editor is ready | +| `onEditorDestroy` | `() => void` | `undefined` | Callback when editor is destroyed | + +## Callbacks + +| Callback | Type | Description | +|----------|------|-------------| +| `onContentChange` | `(content: Content) => void` | Called when editor content changes | +| `onEditorReady` | `(editor: Editor) => void` | Called when editor is ready | +| `onEditorDestroy` | `() => void` | Called when editor is destroyed | + +```svelte + { + console.log('New content:', content); + // Save to database, update state, etc. + }} + onEditorReady={(editor) => { + console.log('Editor is ready, you can focus it:', editor); + editor.commands.focus(); + }} +/> +``` + +## Migration from Old Version + +### Before (internal state) + +```svelte + + + +``` + +### After (prop-based) + +```svelte + + + +``` + +## Benefits of the Rewrite + +1. **Reusability**: Can be used multiple times with different configurations +2. **State Control**: Parent components control the state +3. **Flexibility**: Persistence can be disabled or customized +4. **Callback Handling**: Modern callback props instead of deprecated events +5. **TypeScript Support**: Full type safety with proper interfaces +6. **Customization**: All styling can be overridden via props diff --git a/packages/ui/src/custom/editor/commands/index.ts b/packages/ui/src/custom/editor/commands/index.ts new file mode 100644 index 00000000..3dac44f4 --- /dev/null +++ b/packages/ui/src/custom/editor/commands/index.ts @@ -0,0 +1 @@ +export { default as ToolBarCommands } from './toolbar-commands'; diff --git a/packages/ui/src/custom/editor/commands/toolbar-commands.ts b/packages/ui/src/custom/editor/commands/toolbar-commands.ts new file mode 100644 index 00000000..3f3d9192 --- /dev/null +++ b/packages/ui/src/custom/editor/commands/toolbar-commands.ts @@ -0,0 +1,404 @@ +import AlighJustify from '@lucide/svelte/icons/align-justify'; +import AlignCenter from '@lucide/svelte/icons/align-center'; +import AlignLeft from '@lucide/svelte/icons/align-left'; +import AlignRight from '@lucide/svelte/icons/align-right'; +import Audio from '@lucide/svelte/icons/audio-lines'; +import Bold from '@lucide/svelte/icons/bold'; +import Code from '@lucide/svelte/icons/code'; +import type { EdraToolBarCommands } from './types'; +import Heading1 from '@lucide/svelte/icons/heading-1'; +import Heading2 from '@lucide/svelte/icons/heading-2'; +import Heading3 from '@lucide/svelte/icons/heading-3'; +import Heading4 from '@lucide/svelte/icons/heading-4'; +import IFrame from '@lucide/svelte/icons/code-xml'; +import Image from '@lucide/svelte/icons/image'; +import Italic from '@lucide/svelte/icons/italic'; +import Link from '@lucide/svelte/icons/link-2'; +import List from '@lucide/svelte/icons/list'; +import ListChecks from '@lucide/svelte/icons/list-checks'; +import ListOrdered from '@lucide/svelte/icons/list-ordered'; +import Quote from '@lucide/svelte/icons/quote'; +import Redo from '@lucide/svelte/icons/redo-2'; +import StrikeThrough from '@lucide/svelte/icons/strikethrough'; +import Subscript from '@lucide/svelte/icons/subscript'; +import Superscript from '@lucide/svelte/icons/superscript'; +import Table from '@lucide/svelte/icons/table'; +import Underline from '@lucide/svelte/icons/underline'; +import Undo from '@lucide/svelte/icons/undo-2'; +import Video from '@lucide/svelte/icons/video'; +import { isMac } from '../utils'; + +const commands: Record = { + 'undo-redo': [ + { + icon: Undo, + name: 'undo', + tooltip: 'Undo', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}Z`, + onClick: (editor) => { + editor.chain().focus().undo().run(); + }, + clickable: (editor) => { + return editor.can().undo(); + } + }, + { + icon: Redo, + name: 'redo', + tooltip: 'Redo', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}Y`, + onClick: (editor) => { + editor.chain().focus().redo().run(); + }, + clickable: (editor) => { + return editor.can().redo(); + } + } + ], + headings: [ + { + icon: Heading1, + name: 'h1', + tooltip: 'Heading 1', + shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}1`, + onClick: (editor) => { + editor.chain().focus().toggleHeading({ level: 1 }).run(); + }, + clickable: (editor) => { + return editor.can().toggleHeading({ level: 1 }); + }, + isActive: (editor) => { + return editor.isActive('heading', { level: 1 }); + } + }, + { + icon: Heading2, + name: 'h2', + tooltip: 'Heading 2', + shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}2`, + onClick: (editor) => { + editor.chain().focus().toggleHeading({ level: 2 }).run(); + }, + clickable: (editor) => { + return editor.can().toggleHeading({ level: 2 }); + }, + isActive: (editor) => { + return editor.isActive('heading', { level: 2 }); + } + }, + { + icon: Heading3, + name: 'h3', + tooltip: 'Heading 3', + shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}3`, + onClick: (editor) => { + editor.chain().focus().toggleHeading({ level: 3 }).run(); + }, + clickable: (editor) => { + return editor.can().toggleHeading({ level: 3 }); + }, + isActive: (editor) => { + return editor.isActive('heading', { level: 3 }); + } + }, + { + icon: Heading4, + name: 'h4', + tooltip: 'Heading 4', + shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}4`, + onClick: (editor) => { + editor.chain().focus().toggleHeading({ level: 4 }).run(); + }, + clickable: (editor) => { + return editor.can().toggleHeading({ level: 4 }); + }, + isActive: (editor) => { + return editor.isActive('heading', { level: 4 }); + } + } + ], + 'text-formatting': [ + { + icon: Link, + name: 'link', + tooltip: 'Link', + onClick: (editor) => { + if (editor.isActive('link')) { + editor.chain().focus().unsetLink().run(); + } else { + const url = window.prompt('Enter the URL of the link:'); + if (url) { + editor.chain().focus().toggleLink({ href: url }).run(); + } + } + }, + isActive: (editor) => { + return editor.isActive('link'); + } + }, + { + icon: Bold, + name: 'bold', + tooltip: 'Bold', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}B`, + onClick: (editor) => { + editor.chain().focus().toggleBold().run(); + }, + clickable: (editor) => { + return editor.can().toggleBold(); + }, + isActive: (editor) => { + return editor.isActive('bold'); + } + }, + { + icon: Italic, + name: 'italic', + tooltip: 'Italic', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}I`, + onClick: (editor) => { + editor.chain().focus().toggleItalic().run(); + }, + clickable: (editor) => { + return editor.can().toggleItalic(); + }, + isActive: (editor) => { + return editor.isActive('italic'); + } + }, + { + icon: Underline, + name: 'underline', + tooltip: 'Underline', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}U`, + onClick: (editor) => { + editor.chain().focus().toggleUnderline().run(); + }, + clickable: (editor) => { + return editor.can().toggleUnderline(); + }, + isActive: (editor) => { + return editor.isActive('underline'); + } + }, + { + icon: StrikeThrough, + name: 'strikethrough', + tooltip: 'Strikethrough', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}S`, + onClick: (editor) => { + editor.chain().focus().toggleStrike().run(); + }, + clickable: (editor) => { + return editor.can().toggleStrike(); + }, + isActive: (editor) => { + return editor.isActive('strike'); + } + }, + { + icon: Quote, + name: 'blockQuote', + tooltip: 'BlockQuote', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}B`, + onClick: (editor) => { + editor.chain().focus().toggleBlockquote().run(); + }, + clickable: (editor) => { + return editor.can().toggleBlockquote(); + }, + isActive: (editor) => { + return editor.isActive('blockquote'); + } + }, + { + icon: Code, + name: 'code', + tooltip: 'Inline Code', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}E`, + onClick: (editor) => { + editor.chain().focus().toggleCode().run(); + }, + clickable: (editor) => { + return editor.can().toggleCode(); + }, + isActive: (editor) => { + return editor.isActive('code'); + } + }, + { + icon: Superscript, + name: 'superscript', + tooltip: 'Superscript', + shortCut: `${isMac ? '⌘' : 'Ctrl+'}.`, + onClick: (editor) => { + editor.chain().focus().toggleSuperscript().run(); + }, + clickable: (editor) => { + return editor.can().toggleSuperscript(); + }, + isActive: (editor) => { + return editor.isActive('superscript'); + } + }, + { + icon: Subscript, + name: 'subscript', + tooltip: 'Subscript', + shortCut: `${isMac ? '⌘' : 'Ctrl+'},`, + onClick: (editor) => { + editor.chain().focus().toggleSubscript().run(); + }, + clickable: (editor) => { + return editor.can().toggleSubscript(); + }, + isActive: (editor) => { + return editor.isActive('subscript'); + } + } + ], + alignment: [ + { + icon: AlignLeft, + name: 'align-left', + tooltip: 'Align Left', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}L`, + onClick: (editor) => { + editor.chain().focus().toggleTextAlign('left').run(); + }, + clickable: (editor) => { + return editor.can().toggleTextAlign('left'); + }, + isActive: (editor) => editor.isActive({ textAlign: 'left' }) + }, + { + icon: AlignCenter, + name: 'align-center', + tooltip: 'Align Center', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}E`, + onClick: (editor) => { + editor.chain().focus().toggleTextAlign('center').run(); + }, + clickable: (editor) => { + return editor.can().toggleTextAlign('center'); + }, + isActive: (editor) => editor.isActive({ textAlign: 'center' }) + }, + { + icon: AlignRight, + name: 'align-right', + tooltip: 'Align Right', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}R`, + onClick: (editor) => { + editor.chain().focus().toggleTextAlign('right').run(); + }, + clickable: (editor) => { + return editor.can().toggleTextAlign('right'); + }, + isActive: (editor) => editor.isActive({ textAlign: 'right' }) + }, + { + icon: AlighJustify, + name: 'align-justify', + tooltip: 'Align Justify', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}J`, + onClick: (editor) => { + editor.chain().focus().toggleTextAlign('justify').run(); + }, + clickable: (editor) => { + return editor.can().toggleTextAlign('justify'); + }, + isActive: (editor) => editor.isActive({ textAlign: 'justify' }) + } + ], + lists: [ + { + icon: List, + name: 'bulletList', + tooltip: 'Bullet List', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}8`, + onClick: (editor) => { + editor.chain().focus().toggleBulletList().run(); + }, + isActive: (editor) => editor.isActive('bulletList') + }, + { + icon: ListOrdered, + name: 'orderedList', + tooltip: 'Ordered List', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}7`, + onClick: (editor) => { + editor.chain().focus().toggleOrderedList().run(); + }, + isActive: (editor) => editor.isActive('orderedList') + }, + { + icon: ListChecks, + name: 'taskList', + tooltip: 'Task List', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}9`, + onClick: (editor) => { + editor.chain().focus().toggleTaskList().run(); + }, + isActive: (editor) => editor.isActive('taskList') + } + ], + media: [ + { + icon: Image, + name: 'image-placeholder', + tooltip: 'Image Placeholder', + onClick: (editor) => { + editor.chain().focus().insertImagePlaceholder().run(); + }, + isActive: (editor) => editor.isActive('image-placeholder') + }, + { + icon: Video, + name: 'video-placeholder', + tooltip: 'Video Placeholder', + onClick: (editor) => { + editor.chain().focus().insertVideoPlaceholder().run(); + }, + isActive: (editor) => editor.isActive('video-placeholder') + }, + { + icon: Audio, + name: 'audio-placeholder', + tooltip: 'Audio Placeholder', + onClick: (editor) => { + editor.chain().focus().insertAudioPlaceholder().run(); + }, + isActive: (editor) => editor.isActive('audio-placeholder') + }, + { + icon: IFrame, + name: 'iframe-placeholder', + tooltip: 'IFrame Placeholder', + onClick: (editor) => { + editor.chain().focus().insertIFramePlaceholder().run(); + }, + isActive: (editor) => editor.isActive('iframe-placeholder') + } + ], + table: [ + { + icon: Table, + name: 'table', + tooltip: 'Table', + onClick: (editor) => { + if (editor.isActive('table')) { + const del = confirm('Do you really want to delete this table??'); + if (del) { + editor.chain().focus().deleteTable().run(); + return; + } + } + editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: false }).run(); + }, + isActive: (editor) => editor.isActive('table') + } + ] +}; + +export default commands; diff --git a/packages/ui/src/custom/editor/commands/types.ts b/packages/ui/src/custom/editor/commands/types.ts new file mode 100644 index 00000000..2c2225bd --- /dev/null +++ b/packages/ui/src/custom/editor/commands/types.ts @@ -0,0 +1,12 @@ +import type { Editor } from '@tiptap/core'; +import { Icon } from '@lucide/svelte'; + +export interface EdraToolBarCommands { + name: string; + icon: typeof Icon; + tooltip?: string; + shortCut?: string; + onClick?: (editor: Editor) => void; + isActive?: (editor: Editor) => boolean; + clickable?: (editor: Editor) => boolean; +} diff --git a/packages/ui/src/custom/editor/components/BubbleMenu.svelte b/packages/ui/src/custom/editor/components/BubbleMenu.svelte new file mode 100644 index 00000000..09e96c9f --- /dev/null +++ b/packages/ui/src/custom/editor/components/BubbleMenu.svelte @@ -0,0 +1,67 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/custom/editor/components/DragHandle.svelte b/packages/ui/src/custom/editor/components/DragHandle.svelte new file mode 100644 index 00000000..a616681d --- /dev/null +++ b/packages/ui/src/custom/editor/components/DragHandle.svelte @@ -0,0 +1,31 @@ + + +
+ +
diff --git a/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte b/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte new file mode 100644 index 00000000..f830d27e --- /dev/null +++ b/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte @@ -0,0 +1,30 @@ + + + + {#if !children && icon && title} + {@const Icon = icon} + +
{title}
+ {/if} + {@render children?.()} +
diff --git a/packages/ui/src/custom/editor/editor.css b/packages/ui/src/custom/editor/editor.css new file mode 100644 index 00000000..08bad4c2 --- /dev/null +++ b/packages/ui/src/custom/editor/editor.css @@ -0,0 +1,454 @@ +/* Base TipTap Editor Styles with Light/Dark Theme Support */ + +.tiptap :first-child { + margin-top: 0; +} + +/* For Placeholder */ +.tiptap .is-empty::before { + pointer-events: none; + float: left; + height: 0; + color: var(--border-color-hover); + content: attr(data-placeholder); +} + +/* Heading Styles */ +.tiptap h1, +.tiptap h2, +.tiptap h3, +.tiptap h4, +.tiptap h5, +.tiptap h6 { + line-height: 1.2; + margin-top: 1rem; + text-wrap: pretty; +} + +.tiptap h1, +.tiptap h2 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.tiptap h1 { + scroll-margin: 5rem; + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 800; + letter-spacing: -0.015em; +} + +.tiptap h2 { + font-size: 1.5rem; + font-weight: 700; +} + +.tiptap h3 { + font-size: 1.25rem; + font-weight: 600; +} + +.tiptap h4, +.tiptap h5, +.tiptap h6 { + font-size: 1rem; + font-weight: 600; +} + +.tiptap blockquote { + margin: 1rem 0rem; + padding: 0.5rem 0; + padding-left: 0.75rem; + font-style: italic; + color: var(--blockquote-color); + position: relative; +} + +.tiptap blockquote::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0.25rem; + background-color: var(--blockquote-border); + border-radius: 0.5rem; +} + +.tiptap blockquote p { + margin: 0; +} + +/* Horizontal Rule */ +.tiptap hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 1rem 0; +} + +/* Inline Code */ +.tiptap code:not(pre code) { + background-color: var(--code-bg); + border-radius: 0.25rem; + padding: 0.2rem 0.3rem; + font-family: monospace; + font-size: 0.875rem; + font-weight: bold; +} + +/* List Styling */ + +.tiptap ul, +.tiptap ol { + padding: 0 1rem; + margin: 0.5rem 1rem 0.5rem 0.4rem; +} + +.tiptap ul li p, +.tiptap ol li p { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +/* Task List Styling */ +.tiptap ul[data-type='taskList'] { + list-style: none; + margin: 0; + padding: 0; +} + +.tiptap ul[data-type='taskList'] li { + align-items: flex-start; + display: flex; +} + +.tiptap ul[data-type='taskList'] li > label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; +} + +.tiptap ul[data-type='taskList'] li > div { + flex: 1 1 auto; +} + +.tiptap ul[data-type='taskList'] input[type='checkbox'] { + cursor: pointer; +} + +.tiptap ul[data-type='taskList'] ul[data-type='taskList'] { + margin: 0; +} + +ul[data-type='taskList'] li[data-checked='true'] div { + color: var(--task-completed-color); + text-decoration: line-through; +} + +input[type='checkbox'] { + position: relative; + top: 0.25rem; + margin: 0; + display: grid; + place-content: center; + cursor: pointer; + width: 1rem; + height: 1rem; + border-radius: 0.25rem; +} + +/* Color Swatches */ +.color { + white-space: nowrap; +} + +.color::before { + margin-bottom: 0.15rem; + margin-right: 0.1rem; + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 0.25rem; + border: 1px solid var(--border-color); + vertical-align: middle; + background-color: var(--color); + content: ' '; +} + +/* Code Block Styling */ +.tiptap pre { + margin: 0; + display: flex; + height: fit-content; + overflow: auto; + background-color: transparent; + padding: 0; +} + +.tiptap pre code { + flex: 1; + border-radius: 0 !important; + background-color: transparent; + padding: 0; + color: inherit; + font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono'; +} + +/* Drag Handle Styling */ +.drag-handle { + position: fixed; + z-index: 50; + width: 1.5rem; + height: 1.5rem; + display: flex; + padding-right: 0.5rem; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: grab; + opacity: 100; + transition-property: opacity; + transition-duration: 200ms; + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); + color: var(--border-color-hover); +} + +.drag-handle:active { + cursor: grabbing; +} + +.drag-handle.hide { + opacity: 0; + pointer-events: none; +} + +@media screen and (max-width: 600px) { + .drag-handle { + display: none; + pointer-events: none; + } +} + +.drag-handle svg { + height: 1rem; + width: 1rem; +} + +/* Math Equations (KaTeX) */ +.katex:hover { + background-color: var(--code-bg); +} + +.katex.result { + border-bottom: 1px dashed var(--highlight-border); + background-color: var(--highlight-color); +} + +/* Table Styling */ +.ProseMirror .tableWrapper { + margin: 0; + overflow: auto; + padding: 1rem; +} + +.ProseMirror table { + margin-top: 1rem; + margin-bottom: 1rem; + box-sizing: border-box; + width: 100%; + border-collapse: collapse; + border-radius: 0.25rem; + border: 1px solid var(--table-border); +} + +.ProseMirror table td, +.ProseMirror table th { + position: relative; + min-width: 100px; + border: 1px solid var(--table-border); + padding: 0.5rem; + text-align: left; + vertical-align: top; +} + +.ProseMirror table td:first-of-type:not(a), +.ProseMirror table th:first-of-type:not(a) { + margin-top: 0; +} + +.ProseMirror table td p, +.ProseMirror table th p { + margin: 0; +} + +.ProseMirror table td p + p, +.ProseMirror table th p + p { + margin-top: 0.75rem; +} + +.ProseMirror table th { + font-weight: bold; +} + +.ProseMirror table .column-resize-handle { + pointer-events: none; + position: absolute; + top: 0; + right: -0.25rem; + bottom: -2px; + display: flex; + width: 0.5rem; + background-color: var(--table-border); +} + +.ProseMirror table .column-resize-handle::before { + content: ''; + margin-left: 0.5rem; + height: 100%; + width: 1px; + background-color: var(--table-border); +} + +.ProseMirror table .selectedCell { + border-style: double; + border-color: var(--table-border); + background-color: var(--table-bg-selected); +} + +.ProseMirror table .grip-column, +.ProseMirror table .grip-row { + position: absolute; + z-index: 10; + display: flex; + cursor: pointer; + align-items: center; + justify-content: center; + background-color: var(--table-bg-selected); +} + +.ProseMirror table .grip-column { + top: -0.75rem; + left: 0; + margin-left: -1px; + height: 0.75rem; + width: calc(100% + 1px); + border-left: 1px solid var(--table-border); +} + +.ProseMirror table .grip-column:hover::before, +.ProseMirror table .grip-column.selected::before { + content: ''; + width: 0.625rem; +} + +.ProseMirror table .grip-column:hover { + background-color: var(--table-bg-hover); +} + +.ProseMirror table .grip-column:hover::before { + border-bottom: 2px dotted var(--border-color-hover); +} + +.ProseMirror table .grip-column.first { + border-top-left-radius: 0.125rem; + border-color: transparent; +} + +.ProseMirror table .grip-column.last { + border-top-right-radius: 0.125rem; +} + +.ProseMirror table .grip-column.selected { + border-color: var(--table-border); + background-color: var(--table-bg-hover); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.ProseMirror table .grip-column.selected::before { + border-bottom: 2px dotted var(--border-color-hover); +} + +.ProseMirror table .grip-row { + left: -0.75rem; + top: 0; + margin-top: -1px; + height: calc(100% + 1px); + width: 0.75rem; + border-top: 1px solid var(--table-border); +} + +.ProseMirror table .grip-row:hover::before, +.ProseMirror table .grip-row.selected::before { + content: ''; + height: 0.625rem; +} + +.ProseMirror table .grip-row:hover { + background-color: var(--table-bg-hover); +} + +.ProseMirror table .grip-row:hover::before { + border-left: 2px dotted var(--border-color-hover); +} + +.ProseMirror table .grip-row.first { + border-top-left-radius: 0.125rem; + border-color: transparent; +} + +.ProseMirror table .grip-row.last { + border-bottom-left-radius: 0.125rem; +} + +.ProseMirror table .grip-row.selected { + border-color: var(--table-border); + background-color: var(--table-bg-hover); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.ProseMirror table .grip-row.selected::before { + border-left: 2px dotted var(--border-color-hover); +} + +.tiptap .search-result { + background-color: var(--search-result-bg); + color: black; +} + +.tiptap .search-result-current { + background-color: var(--search-result-current-bg); + color: black; +} + +.code-wrapper { + background-color: var(--codeblock-bg); + border-radius: 0.5rem; + padding: 1rem; + position: relative; + height: fit-content; + width: 100%; + margin: 0.5rem 0; +} + +.code-wrapper-tile { + opacity: 0; + width: 100%; + display: flex; + position: absolute; + top: 0; + padding: 0.25rem; + right: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + transition: opacity 0.2s ease-in-out; +} + +.code-wrapper:hover .code-wrapper-tile { + opacity: 1; +} + +.tiptap iframe { + aspect-ratio: 16 / 9; +} diff --git a/packages/ui/src/custom/editor/editor.svelte b/packages/ui/src/custom/editor/editor.svelte new file mode 100644 index 00000000..ebb3ad6a --- /dev/null +++ b/packages/ui/src/custom/editor/editor.svelte @@ -0,0 +1,149 @@ + + +{#if browser} +
+ {#if editor && !editor.isDestroyed} + {#if showToolBar} +
+ +
+ {/if} + + + + {/if} + +
+{/if} diff --git a/packages/ui/src/custom/editor/editor.ts b/packages/ui/src/custom/editor/editor.ts new file mode 100644 index 00000000..a6580327 --- /dev/null +++ b/packages/ui/src/custom/editor/editor.ts @@ -0,0 +1,110 @@ +import { Editor, type Extensions, type EditorOptions, type Content } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { getHandlePaste } from './utils'; +import Subscript from '@tiptap/extension-subscript'; +import Superscript from '@tiptap/extension-superscript'; +import Typography from '@tiptap/extension-typography'; +import { ColorHighlighter } from './extensions/ColorHighlighter'; +import { FontSize, TextStyle, Color } from '@tiptap/extension-text-style'; +import TextAlign from '@tiptap/extension-text-align'; +import Highlight from '@tiptap/extension-highlight'; +import SearchAndReplace from './extensions/FindAndReplace'; +import { TaskItem, TaskList } from '@tiptap/extension-list'; +import { Table, TableCell, TableRow, TableHeader } from './extensions/table'; +import { Placeholder } from '@tiptap/extensions'; +import { Markdown } from 'tiptap-markdown'; +import MathExtension from '@aarkue/tiptap-math-extension'; +import AutoJoiner from 'tiptap-extension-auto-joiner'; +import 'katex/dist/katex.min.css'; + +export default ( + element?: HTMLElement, + content?: Content, + extensions?: Extensions, + options?: Partial +) => { + const editor = new Editor({ + element, + content, + extensions: [ + StarterKit.configure({ + orderedList: { + HTMLAttributes: { + class: 'list-decimal' + } + }, + bulletList: { + HTMLAttributes: { + class: 'list-disc' + } + }, + heading: { + levels: [1, 2, 3, 4] + }, + link: { + openOnClick: false, + autolink: true, + linkOnPaste: true + }, + codeBlock: false + }), + Highlight.configure({ + multicolor: true + }), + Placeholder.configure({ + emptyEditorClass: 'is-empty', + // Use a placeholder: + // Use different placeholders depending on the node type: + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return 'What’s the title?'; + } else if (node.type.name === 'paragraph') { + return 'Press / or write something ...'; + } + return ''; + } + }), + Color, + Subscript, + Superscript, + Typography, + ColorHighlighter, + TextStyle, + FontSize, + TextAlign.configure({ + types: ['heading', 'paragraph'] + }), + TaskList, + TaskItem.configure({ + nested: true + }), + SearchAndReplace, + MathExtension.configure({ evaluation: true }), + AutoJoiner, + Table, + TableHeader, + TableRow, + TableCell, + Markdown.configure({ + html: true, + tightLists: true, + tightListClass: 'tight', + bulletListMarker: '-', + linkify: true, + breaks: true, + transformPastedText: true, + transformCopiedText: false + }), + + ...(extensions ?? []) + ], + ...options + }); + + editor.setOptions({ + editorProps: { + handlePaste: getHandlePaste(editor) + } + }); + return editor; +}; diff --git a/packages/ui/src/custom/editor/extensions/ColorHighlighter.ts b/packages/ui/src/custom/editor/extensions/ColorHighlighter.ts new file mode 100644 index 00000000..ad93058e --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/ColorHighlighter.ts @@ -0,0 +1,27 @@ +import { Extension } from '@tiptap/core'; +import { Plugin } from '@tiptap/pm/state'; +import { findColors } from '../utils'; + +export const ColorHighlighter = Extension.create({ + name: 'colorHighlighter', + + addProseMirrorPlugins() { + return [ + new Plugin({ + state: { + init(_, { doc }) { + return findColors(doc); + }, + apply(transaction, oldState) { + return transaction.docChanged ? findColors(transaction.doc) : oldState; + } + }, + props: { + decorations(state) { + return this.getState(state); + } + } + }) + ]; + } +}); diff --git a/packages/ui/src/custom/editor/extensions/FindAndReplace.ts b/packages/ui/src/custom/editor/extensions/FindAndReplace.ts new file mode 100644 index 00000000..a64e8f19 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/FindAndReplace.ts @@ -0,0 +1,408 @@ +// MIT License + +// Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Extension, type Range, type Dispatch } from '@tiptap/core'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'; +import { Node as PMNode } from '@tiptap/pm/model'; + +declare module '@tiptap/core' { + interface Commands { + search: { + /** + * @description Set search term in extension. + */ + setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set replace term in extension. + */ + setReplaceTerm: (replaceTerm: string) => ReturnType; + /** + * @description Set case sensitivity in extension. + */ + setCaseSensitive: (caseSensitive: boolean) => ReturnType; + /** + * @description Reset current search result to first instance. + */ + resetIndex: () => ReturnType; + /** + * @description Find next instance of search result. + */ + nextSearchResult: () => ReturnType; + /** + * @description Find previous instance of search result. + */ + previousSearchResult: () => ReturnType; + /** + * @description Replace first instance of search result with given replace term. + */ + replace: () => ReturnType; + /** + * @description Replace all instances of search result with given replace term. + */ + replaceAll: () => ReturnType; + }; + } +} + +interface TextNodesWithPosition { + text: string; + pos: number; +} + +const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => { + return RegExp( + disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s, + caseSensitive ? 'gu' : 'gui' + ); +}; + +interface ProcessedSearches { + decorationsToReturn: DecorationSet; + results: Range[]; +} + +function processSearches( + doc: PMNode, + searchTerm: RegExp, + searchResultClass: string, + resultIndex: number +): ProcessedSearches { + const decorations: Decoration[] = []; + const results: Range[] = []; + + let textNodesWithPosition: TextNodesWithPosition[] = []; + let index = 0; + + if (!searchTerm) { + return { + decorationsToReturn: DecorationSet.empty, + results: [] + }; + } + + doc?.descendants((node, pos) => { + if (node.isText) { + if (textNodesWithPosition[index]) { + textNodesWithPosition[index] = { + text: textNodesWithPosition[index].text + node.text, + pos: textNodesWithPosition[index].pos + }; + } else { + textNodesWithPosition[index] = { + text: `${node.text}`, + pos + }; + } + } else { + index += 1; + } + }); + + textNodesWithPosition = textNodesWithPosition.filter(Boolean); + + for (const element of textNodesWithPosition) { + const { text, pos } = element; + const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim()); + + for (const m of matches) { + if (m[0] === '') break; + + if (m.index !== undefined) { + results.push({ + from: pos + m.index, + to: pos + m.index + m[0].length + }); + } + } + } + + for (let i = 0; i < results.length; i += 1) { + const r = results[i]; + const className = + i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass; + const decoration: Decoration = Decoration.inline(r.from, r.to, { + class: className + }); + + decorations.push(decoration); + } + + return { + decorationsToReturn: DecorationSet.create(doc, decorations), + results + }; +} + +const replace = ( + replaceTerm: string, + results: Range[], + { state, dispatch }: { state: EditorState; dispatch: Dispatch } +) => { + const firstResult = results[0]; + + if (!firstResult) return; + + const { from, to } = results[0]; + + if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to)); +}; + +const rebaseNextResult = ( + replaceTerm: string, + index: number, + lastOffset: number, + results: Range[] +): [number, Range[]] | null => { + const nextIndex = index + 1; + + if (!results[nextIndex]) return null; + + const { from: currentFrom, to: currentTo } = results[index]; + + const offset = currentTo - currentFrom - replaceTerm.length + lastOffset; + + const { from, to } = results[nextIndex]; + + results[nextIndex] = { + to: to - offset, + from: from - offset + }; + + return [offset, results]; +}; + +const replaceAll = ( + replaceTerm: string, + results: Range[], + { tr, dispatch }: { tr: Transaction; dispatch: Dispatch } +) => { + let offset = 0; + + let resultsCopy = results.slice(); + + if (!resultsCopy.length) return; + + for (let i = 0; i < resultsCopy.length; i += 1) { + const { from, to } = resultsCopy[i]; + + tr.insertText(replaceTerm, from, to); + + const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy); + + if (!rebaseNextResultResponse) continue; + + offset = rebaseNextResultResponse[0]; + resultsCopy = rebaseNextResultResponse[1]; + } + + if (dispatch) { + dispatch(tr); + } +}; + +export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin'); + +export interface SearchAndReplaceOptions { + searchResultClass: string; + disableRegex: boolean; +} + +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + caseSensitive: boolean; + lastCaseSensitive: boolean; + resultIndex: number; + lastResultIndex: number; +} + +export const SearchAndReplace = Extension.create({ + name: 'searchAndReplace', + + addOptions() { + return { + searchResultClass: 'search-result', + disableRegex: true + }; + }, + + addStorage() { + return { + searchTerm: '', + replaceTerm: '', + results: [], + lastSearchTerm: '', + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0 + }; + }, + + addCommands() { + return { + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; + + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; + + return false; + }, + setCaseSensitive: + (caseSensitive: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.caseSensitive = caseSensitive; + + return false; + }, + resetIndex: + () => + ({ editor }) => { + editor.storage.searchAndReplace.resultIndex = 0; + + return false; + }, + nextSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; + + const nextIndex = resultIndex + 1; + + if (results[nextIndex]) { + editor.storage.searchAndReplace.resultIndex = nextIndex; + } else { + editor.storage.searchAndReplace.resultIndex = 0; + } + + return false; + }, + previousSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; + + const prevIndex = resultIndex - 1; + + if (results[prevIndex]) { + editor.storage.searchAndReplace.resultIndex = prevIndex; + } else { + editor.storage.searchAndReplace.resultIndex = results.length - 1; + } + + return false; + }, + replace: + () => + ({ editor, state, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replace(replaceTerm, results, { state, dispatch }); + + return false; + }, + replaceAll: + () => + ({ editor, tr, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replaceAll(replaceTerm, results, { tr, dispatch }); + + return false; + } + }; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + const { searchResultClass, disableRegex } = this.options; + + const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t); + const setLastCaseSensitive = (t: boolean) => + (editor.storage.searchAndReplace.lastCaseSensitive = t); + const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t); + + return [ + new Plugin({ + key: searchAndReplacePluginKey, + state: { + init: () => DecorationSet.empty, + apply({ doc, docChanged }, oldState) { + const { + searchTerm, + lastSearchTerm, + caseSensitive, + lastCaseSensitive, + resultIndex, + lastResultIndex + } = editor.storage.searchAndReplace; + + if ( + !docChanged && + lastSearchTerm === searchTerm && + lastCaseSensitive === caseSensitive && + lastResultIndex === resultIndex + ) + return oldState; + + setLastSearchTerm(searchTerm); + setLastCaseSensitive(caseSensitive); + setLastResultIndex(resultIndex); + + if (!searchTerm) { + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } + + const { decorationsToReturn, results } = processSearches( + doc, + getRegex(searchTerm, disableRegex, caseSensitive), + searchResultClass, + resultIndex + ); + + editor.storage.searchAndReplace.results = results; + + return decorationsToReturn; + } + }, + props: { + decorations(state) { + return this.getState(state); + } + } + }) + ]; + } +}); + +export default SearchAndReplace; diff --git a/packages/ui/src/custom/editor/extensions/audio/AudiExtended.ts b/packages/ui/src/custom/editor/extensions/audio/AudiExtended.ts new file mode 100644 index 00000000..06db15f8 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/audio/AudiExtended.ts @@ -0,0 +1,34 @@ +import { Audio } from './AudioExtension'; +import type { Component } from 'svelte'; +import type { NodeViewProps } from '@tiptap/core'; +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; + +export const AudioExtended = (content: Component) => + Audio.extend({ + addAttributes() { + return { + src: { + default: null + }, + alt: { + default: null + }, + title: { + default: null + }, + width: { + default: '100%' + }, + height: { + default: null + }, + align: { + default: 'left' + } + }; + }, + + addNodeView: () => { + return SvelteNodeViewRenderer(content); + } + }); diff --git a/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts b/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts new file mode 100644 index 00000000..5e2da234 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts @@ -0,0 +1,147 @@ +import { Node, nodeInputRule } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +export interface AudioOptions { + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + audio: { + /** + * Set a audio node + */ + setAudio: (src: string) => ReturnType; + /** + * Toggle a audio + */ + toggleAudio: (src: string) => ReturnType; + /** + * Remove a audio + */ + removeAudio: () => ReturnType; + }; + } +} + +const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; + +export const Audio = Node.create({ + name: 'audio', + group: 'block', + draggable: true, + isolating: true, + atom: true, + addOptions() { + return { + HTMLAttributes: {} + }; + }, + addAttributes() { + return { + src: { + default: null, + parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'), + renderHTML: (attrs) => ({ src: attrs.src }) + } + }; + }, + parseHTML() { + return [ + { + tag: 'audio', + getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') }) + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'audio', + { controls: 'true', style: 'width: 100%;', ...HTMLAttributes }, + ['source', HTMLAttributes] + ]; + }, + addCommands() { + return { + setAudio: + (src: string) => + ({ commands }) => + commands.insertContent( + `` + ), + + toggleAudio: + () => + ({ commands }) => + commands.toggleNode(this.name, 'paragraph'), + removeAudio: + () => + ({ commands }) => + commands.deleteNode(this.name) + }; + }, + addInputRules() { + return [ + nodeInputRule({ + find: AUDIO_INPUT_REGEX, + type: this.type, + getAttributes: (match) => { + const [, , src] = match; + + return { src }; + } + }) + ]; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('audioDropPlugin'), + + props: { + handleDOMEvents: { + drop(view, event) { + const { + state: { schema, tr }, + dispatch + } = view; + const hasFiles = + event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length; + + if (!hasFiles) return false; + + const audios = Array.from(event.dataTransfer.files).filter((file) => + /audio/i.test(file.type) + ); + + if (audios.length === 0) return false; + + event.preventDefault(); + + const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); + + audios.forEach((audio) => { + const reader = new FileReader(); + + reader.onload = (readerEvent) => { + const node = schema.nodes.audio.create({ src: readerEvent.target?.result }); + + if (coordinates && typeof coordinates.pos === 'number') { + const transaction = tr.insert(coordinates?.pos, node); + + dispatch(transaction); + } + }; + + reader.readAsDataURL(audio); + }); + + return true; + } + } + } + }) + ]; + } +}); diff --git a/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts b/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts new file mode 100644 index 00000000..ca7b2b3d --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts @@ -0,0 +1,64 @@ +import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'; +import type { Component } from 'svelte'; +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; + +export interface AudioPlaceholderOptions { + HTMLAttributes: Record; + onDrop: (files: File[], editor: Editor) => void; + onDropRejected?: (files: File[], editor: Editor) => void; + onEmbed: (url: string, editor: Editor) => void; + allowedMimeTypes?: Record; + maxFiles?: number; + maxSize?: number; +} + +declare module '@tiptap/core' { + interface Commands { + audioPlaceholder: { + /** + * Inserts an audio placeholder + */ + insertAudioPlaceholder: () => ReturnType; + }; + } +} + +export const AudioPlaceholder = ( + component: Component +): Node => + Node.create({ + name: 'audio-placeholder', + addOptions() { + return { + HTMLAttributes: {}, + onDrop: () => {}, + onDropRejected: () => {}, + onEmbed: () => {} + }; + }, + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes)]; + }, + group: 'block', + draggable: true, + atom: true, + content: 'inline*', + isolating: true, + + addNodeView() { + return SvelteNodeViewRenderer(component); + }, + addCommands() { + return { + insertAudioPlaceholder: () => (props: CommandProps) => { + return props.commands.insertContent({ + type: 'audio-placeholder' + }); + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts b/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts new file mode 100644 index 00000000..e7c8648e --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts @@ -0,0 +1,28 @@ +import { Slice } from '@tiptap/pm/model'; +import { EditorView } from '@tiptap/pm/view'; +import * as pmView from '@tiptap/pm/view'; + +function getPmView() { + try { + return pmView; + } catch (error) { + console.error(error); + return null; + } +} + +export function serializeForClipboard(view: EditorView, slice: Slice) { + // Newer Tiptap/ProseMirror + if (view && typeof view.serializeForClipboard === 'function') { + return view.serializeForClipboard(slice); + } + + // Older version fallback + const proseMirrorView = getPmView(); + + if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') { + return proseMirrorView.__serializeForClipboard(view, slice); + } + + throw new Error('No supported clipboard serialization method found.'); +} diff --git a/packages/ui/src/custom/editor/extensions/drag-handle/index.ts b/packages/ui/src/custom/editor/extensions/drag-handle/index.ts new file mode 100644 index 00000000..f9a74168 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/drag-handle/index.ts @@ -0,0 +1,374 @@ +import { Fragment, Node, Slice } from '@tiptap/pm/model'; +import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; + +import { EditorView } from '@tiptap/pm/view'; +import { Extension } from '@tiptap/core'; +import { serializeForClipboard } from './ClipboardSerializer'; + +export interface GlobalDragHandleOptions { + /** + * The width of the drag handle + */ + dragHandleWidth: number; + + /** + * The treshold for scrolling + */ + scrollTreshold: number; + + /* + * The css selector to query for the drag handle. (eg: '.custom-handle'). + * If handle element is found, that element will be used as drag handle. If not, a default handle will be created + */ + dragHandleSelector?: string; + + /** + * Tags to be excluded for drag handle + */ + excludedTags: string[]; + + /** + * Custom nodes to be included for drag handle + */ + customNodes: string[]; + + /** + * onNodeChange callback for drag handle + * @param data + * @returns + */ + onMouseMove?: (data: { node: Node; pos: number }) => void; +} +function absoluteRect(node: Element) { + const data = node.getBoundingClientRect(); + const modal = node.closest('[role="dialog"]'); + + if (modal && window.getComputedStyle(modal).transform !== 'none') { + const modalRect = modal.getBoundingClientRect(); + + return { + top: data.top - modalRect.top, + left: data.left - modalRect.left, + width: data.width + }; + } + return { + top: data.top, + left: data.left, + width: data.width + }; +} + +function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) { + const selectors = [ + 'li', + 'p:not(:first-child)', + 'pre', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + ...options.customNodes.map((node) => `[data-type=${node}]`) + ].join(', '); + return document + .elementsFromPoint(coords.x, coords.y) + .find((elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)); +} +function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) { + const boundingRect = node.getBoundingClientRect(); + + return view.posAtCoords({ + left: boundingRect.left + 50 + options.dragHandleWidth, + top: boundingRect.top + 1 + })?.inside; +} + +function calcNodePos(pos: number, view: EditorView) { + const $pos = view.state.doc.resolve(pos); + if ($pos.depth > 1) return $pos.before($pos.depth); + return pos; +} + +export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) { + let listType = ''; + function handleDragStart(event: DragEvent, view: EditorView) { + view.focus(); + + if (!event.dataTransfer) return; + + const node = nodeDOMAtCoords( + { + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY + }, + options + ); + + if (!(node instanceof Element)) return; + + let draggedNodePos = nodePosAtDOM(node, view, options); + if (draggedNodePos == null || draggedNodePos < 0) return; + draggedNodePos = calcNodePos(draggedNodePos, view); + + const { from, to } = view.state.selection; + const diff = from - to; + + const fromSelectionPos = calcNodePos(from, view); + let differentNodeSelected = false; + + const nodePos = view.state.doc.resolve(fromSelectionPos); + + // Check if nodePos points to the top level node + if (nodePos.node().type.name === 'doc') differentNodeSelected = true; + else { + const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before()); + + // Check if the node where the drag event started is part of the current selection + differentNodeSelected = !( + draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos + ); + } + let selection = view.state.selection; + if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) { + const endSelection = NodeSelection.create(view.state.doc, to - 1); + selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos); + } else { + selection = NodeSelection.create(view.state.doc, draggedNodePos); + + // if inline node is selected, e.g mention -> go to the parent node to select the whole node + // if table row is selected, go to the parent node to select the whole node + if ( + (selection as NodeSelection).node.type.isInline || + (selection as NodeSelection).node.type.name === 'tableRow' + ) { + const $pos = view.state.doc.resolve(selection.from); + selection = NodeSelection.create(view.state.doc, $pos.before()); + } + } + view.dispatch(view.state.tr.setSelection(selection)); + + // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL + if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === 'listItem') { + listType = node.parentElement!.tagName; + } + + const slice = view.state.selection.content(); + const { dom, text } = serializeForClipboard(view, slice); + + event.dataTransfer.clearData(); + event.dataTransfer.setData('text/html', dom.innerHTML); + event.dataTransfer.setData('text/plain', text); + event.dataTransfer.effectAllowed = 'copyMove'; + + event.dataTransfer.setDragImage(node, 0, 0); + + view.dragging = { slice, move: event.ctrlKey }; + } + + let dragHandleElement: HTMLElement | null = null; + + function hideDragHandle() { + if (dragHandleElement) { + dragHandleElement.classList.add('hide'); + } + } + + function showDragHandle() { + if (dragHandleElement) { + dragHandleElement.classList.remove('hide'); + } + } + + function hideHandleOnEditorOut(event: MouseEvent) { + if (event.target instanceof Element) { + // Check if the relatedTarget class is still inside the editor + const relatedTarget = event.relatedTarget as HTMLElement; + const isInsideEditor = + relatedTarget?.classList.contains('tiptap') || relatedTarget?.classList.contains('drag-handle'); + + if (isInsideEditor) return; + } + hideDragHandle(); + } + + return new Plugin({ + key: new PluginKey(options.pluginKey), + view: (view) => { + const handleBySelector = options.dragHandleSelector + ? document.querySelector(options.dragHandleSelector) + : null; + dragHandleElement = handleBySelector ?? document.createElement('div'); + dragHandleElement.draggable = true; + dragHandleElement.dataset.dragHandle = ''; + dragHandleElement.classList.add('drag-handle'); + + function onDragHandleDragStart(e: DragEvent) { + handleDragStart(e, view); + } + + dragHandleElement.addEventListener('dragstart', onDragHandleDragStart); + + function onDragHandleDrag(e: DragEvent) { + hideDragHandle(); + const scrollY = window.scrollY; + if (e.clientY < options.scrollTreshold) { + window.scrollTo({ top: scrollY - 30, behavior: 'smooth' }); + } else if (window.innerHeight - e.clientY < options.scrollTreshold) { + window.scrollTo({ top: scrollY + 30, behavior: 'smooth' }); + } + } + + dragHandleElement.addEventListener('drag', onDragHandleDrag); + + hideDragHandle(); + + if (!handleBySelector) { + view?.dom?.parentElement?.appendChild(dragHandleElement); + } + view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut); + + return { + destroy: () => { + if (!handleBySelector) { + dragHandleElement?.remove?.(); + } + dragHandleElement?.removeEventListener('drag', onDragHandleDrag); + dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart); + dragHandleElement = null; + view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut); + } + }; + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + if (!view.editable) { + return; + } + + const node = nodeDOMAtCoords( + { + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY + }, + options + ); + + const notDragging = node?.closest('.not-draggable'); + const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', '); + + if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) { + hideDragHandle(); + return; + } + + const nodePos = nodePosAtDOM(node, view, options); + if (nodePos !== undefined) { + const currentNode = view.state.doc.nodeAt(nodePos); + if (currentNode !== null) { + options.onMouseMove?.({ node: currentNode, pos: nodePos }); + } + } + + const compStyle = window.getComputedStyle(node); + const parsedLineHeight = parseInt(compStyle.lineHeight, 10); + const lineHeight = isNaN(parsedLineHeight) ? parseInt(compStyle.fontSize) * 1.2 : parsedLineHeight; + const paddingTop = parseInt(compStyle.paddingTop, 10); + + const rect = absoluteRect(node); + + rect.top += (lineHeight - 24) / 2; + rect.top += paddingTop; + // Li markers + if (node.matches('ul:not([data-type=taskList]) li, ol li')) { + rect.left -= options.dragHandleWidth; + } + rect.width = options.dragHandleWidth; + + if (!dragHandleElement) return; + + dragHandleElement.style.left = `${rect.left - rect.width}px`; + dragHandleElement.style.top = `${rect.top}px`; + showDragHandle(); + }, + keydown: () => { + hideDragHandle(); + }, + mousewheel: () => { + hideDragHandle(); + }, + // dragging class is used for CSS + dragstart: (view) => { + view.dom.classList.add('dragging'); + }, + drop: (view, event) => { + view.dom.classList.remove('dragging'); + hideDragHandle(); + let droppedNode: Node | null = null; + const dropPos = view.posAtCoords({ + left: event.clientX, + top: event.clientY + }); + + if (!dropPos) return; + + if (view.state.selection instanceof NodeSelection) { + droppedNode = view.state.selection.node; + } + if (!droppedNode) return; + + const resolvedPos = view.state.doc.resolve(dropPos.pos); + + const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem'; + + // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside
    tag otherwise ol list items will be transformed into ul list item when dropped + if ( + view.state.selection instanceof NodeSelection && + view.state.selection.node.type.name === 'listItem' && + !isDroppedInsideList && + listType == 'OL' + ) { + const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode); + const slice = new Slice(Fragment.from(newList), 0, 0); + view.dragging = { slice, move: event.ctrlKey }; + } + }, + dragend: (view) => { + view.dom.classList.remove('dragging'); + } + } + } + }); +} + +const GlobalDragHandle = Extension.create({ + name: 'globalDragHandle', + + addOptions() { + return { + dragHandleWidth: 20, + scrollTreshold: 100, + excludedTags: [], + customNodes: [] + }; + }, + + addProseMirrorPlugins() { + return [ + DragHandlePlugin({ + pluginKey: 'globalDragHandle', + dragHandleWidth: this.options.dragHandleWidth, + scrollTreshold: this.options.scrollTreshold, + dragHandleSelector: this.options.dragHandleSelector, + excludedTags: this.options.excludedTags, + customNodes: this.options.customNodes, + onMouseMove: this.options.onMouseMove + }) + ]; + } +}); + +export default GlobalDragHandle; diff --git a/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts b/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts new file mode 100644 index 00000000..bf91ece9 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts @@ -0,0 +1,85 @@ +import { Node } from '@tiptap/core'; + +export interface IframeOptions { + allowFullscreen: boolean; + HTMLAttributes: { + [key: string]: unknown; + }; +} + +declare module '@tiptap/core' { + interface Commands { + iframe: { + /** + * Add an iframe with src + */ + setIframe: (options: { src: string }) => ReturnType; + removeIframe: () => ReturnType; + }; + } +} + +export default Node.create({ + name: 'iframe', + + group: 'block', + + atom: true, + + addOptions() { + return { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper' + } + }; + }, + + addAttributes() { + return { + src: { + default: null + }, + frameborder: { + default: 0 + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => this.options.allowFullscreen + } + }; + }, + + parseHTML() { + return [ + { + tag: 'iframe' + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]; + }, + + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr; + const node = this.type.create(options); + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node); + } + + return true; + }, + removeIframe: + () => + ({ commands }) => + commands.deleteNode(this.name) + }; + } +}); diff --git a/packages/ui/src/custom/editor/extensions/iframe/IFrameExtended.ts b/packages/ui/src/custom/editor/extensions/iframe/IFrameExtended.ts new file mode 100644 index 00000000..17316a00 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/iframe/IFrameExtended.ts @@ -0,0 +1,34 @@ +import type { Component } from 'svelte'; +import IFrame from './IFrame'; +import type { NodeViewProps } from '@tiptap/core'; +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; + +export const IFrameExtended = (content: Component) => + IFrame.extend({ + addAttributes() { + return { + src: { + default: null + }, + alt: { + default: null + }, + title: { + default: null + }, + width: { + default: '100%' + }, + height: { + default: null + }, + align: { + default: 'left' + } + }; + }, + + addNodeView: () => { + return SvelteNodeViewRenderer(content); + } + }); diff --git a/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts b/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts new file mode 100644 index 00000000..96ba8bfd --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts @@ -0,0 +1,56 @@ +import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'; +import type { Component } from 'svelte'; +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; + +export interface IFramePlaceholderOptions { + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + iframePlaceholder: { + /** + * Inserts a IFrame placeholder + */ + insertIFramePlaceholder: () => ReturnType; + }; + } +} + +export const IFramePlaceholder = (content: Component) => + Node.create({ + name: 'iframe-placeholder', + addOptions() { + return { + HTMLAttributes: {}, + onDrop: () => {}, + onDropRejected: () => {}, + onEmbed: () => {} + }; + }, + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes)]; + }, + group: 'block', + draggable: true, + atom: true, + content: 'inline*', + isolating: true, + + addNodeView() { + return SvelteNodeViewRenderer(content); + }, + addCommands() { + return { + insertIFramePlaceholder: () => (props: CommandProps) => { + return props.commands.insertContent({ + type: 'iframe-placeholder' + }); + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts b/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts new file mode 100644 index 00000000..e5430956 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts @@ -0,0 +1,36 @@ +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; +import Image, { type ImageOptions } from '@tiptap/extension-image'; +import type { Component } from 'svelte'; +import type { NodeViewProps, Node } from '@tiptap/core'; + +export const ImageExtended = (component: Component): Node => { + return Image.extend({ + addAttributes() { + return { + src: { + default: null + }, + alt: { + default: null + }, + title: { + default: null + }, + width: { + default: '100%' + }, + height: { + default: null + }, + align: { + default: 'left' + } + }; + }, + addNodeView: () => { + return SvelteNodeViewRenderer(component); + } + }).configure({ + allowBase64: true + }); +}; diff --git a/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts b/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts new file mode 100644 index 00000000..b031abf0 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts @@ -0,0 +1,64 @@ +import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'; +import type { Component } from 'svelte'; +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; + +export interface ImagePlaceholderOptions { + HTMLAttributes: Record; + onDrop: (files: File[], editor: Editor) => void; + onDropRejected?: (files: File[], editor: Editor) => void; + onEmbed: (url: string, editor: Editor) => void; + allowedMimeTypes?: Record; + maxFiles?: number; + maxSize?: number; +} + +declare module '@tiptap/core' { + interface Commands { + imagePlaceholder: { + /** + * Inserts an image placeholder + */ + insertImagePlaceholder: () => ReturnType; + }; + } +} + +export const ImagePlaceholder = ( + component: Component +): Node => + Node.create({ + name: 'image-placeholder', + addOptions() { + return { + HTMLAttributes: {}, + onDrop: () => {}, + onDropRejected: () => {}, + onEmbed: () => {} + }; + }, + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes)]; + }, + group: 'block', + draggable: true, + atom: true, + content: 'inline*', + isolating: true, + + addNodeView() { + return SvelteNodeViewRenderer(component); + }, + addCommands() { + return { + insertImagePlaceholder: () => (props: CommandProps) => { + return props.commands.insertContent({ + type: 'image-placeholder' + }); + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/slash-command/groups.ts b/packages/ui/src/custom/editor/extensions/slash-command/groups.ts new file mode 100644 index 00000000..da4e7b93 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/slash-command/groups.ts @@ -0,0 +1,57 @@ +import type { Editor } from '@tiptap/core'; +import type { EdraToolBarCommands } from '../../commands/types'; +import Minus from '@lucide/svelte/icons/minus'; +import Quote from '@lucide/svelte/icons/quote'; +import SquareCode from '@lucide/svelte/icons/square-code'; +import commands from '../../commands/toolbar-commands'; + +export interface Group { + name: string; + title: string; + actions: EdraToolBarCommands[]; +} + +export const GROUPS: Group[] = [ + { + name: 'format', + title: 'Format', + actions: [ + ...commands.headings, + { + icon: Quote, + name: 'blockquote', + tooltip: 'Blockquote', + onClick: (editor: Editor) => { + editor.chain().focus().setBlockquote().run(); + } + }, + { + icon: SquareCode, + name: 'codeBlock', + tooltip: 'Code Block', + onClick: (editor: Editor) => { + editor.chain().focus().setCodeBlock().run(); + } + }, + ...commands.lists + ] + }, + { + name: 'insert', + title: 'Insert', + actions: [ + ...commands.media, + ...commands.table, + { + icon: Minus, + name: 'horizontalRule', + tooltip: 'Horizontal Rule', + onClick: (editor: Editor) => { + editor.chain().focus().setHorizontalRule().run(); + } + } + ] + } +]; + +export default GROUPS; diff --git a/packages/ui/src/custom/editor/extensions/slash-command/slashcommand.ts b/packages/ui/src/custom/editor/extensions/slash-command/slashcommand.ts new file mode 100644 index 00000000..1d25c295 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/slash-command/slashcommand.ts @@ -0,0 +1,255 @@ +import { Editor, Extension } from '@tiptap/core'; +import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'; +import { PluginKey } from '@tiptap/pm/state'; +import { computePosition, flip, offset, autoUpdate, type Placement } from '@floating-ui/dom'; + +import { GROUPS } from './groups'; +import SvelteRenderer from '../../svelte-renderer'; + +import type { Component } from 'svelte'; + +const extensionName = 'slashCommand'; + +interface PopupState { + element: HTMLElement | null; + cleanup: (() => void) | null; + isVisible: boolean; +} + +const popup: PopupState = { + element: null, + cleanup: null, + isVisible: false +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default (menuList: Component): Extension => + Extension.create({ + name: extensionName, + + priority: 200, + + onCreate() { + // Create popup container + popup.element = document.createElement('div'); + popup.element.style.position = 'fixed'; + popup.element.style.zIndex = '9999'; + popup.element.style.maxWidth = '16rem'; + popup.element.style.visibility = 'hidden'; + popup.element.style.pointerEvents = 'none'; + popup.element.className = 'slash-command-popup'; + document.body.appendChild(popup.element); + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '/', + allowSpaces: true, + pluginKey: new PluginKey(extensionName), + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf('/')); + const isValidAfterContent = !afterContent?.endsWith(' '); + + return isValidAfterContent; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + command: ({ editor, props }: { editor: Editor; props: any }) => { + const { view, state } = editor; + const { $head, $from } = view.state.selection; + + try { + const end = $from.pos; + const from = $head?.nodeBefore + ? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ?? 0) + : $from.start(); + + const tr = state.tr.deleteRange(from, end); + view.dispatch(tr); + } catch (error) { + console.error(error); + } + + props.onClick(editor); + view.focus(); + }, + items: ({ query }: { query: string }) => { + const withFilteredCommands = GROUPS.map((group) => ({ + ...group, + commands: group.actions.filter((item) => { + const labelNormalized = item.tooltip!.toLowerCase().trim(); + const queryNormalized = query.toLowerCase().trim(); + return labelNormalized.includes(queryNormalized); + }) + })); + + const withoutEmptyGroups = withFilteredCommands.filter((group) => { + if (group.commands.length > 0) { + return true; + } + + return false; + }); + + const withEnabledSettings = withoutEmptyGroups.map((group) => ({ + ...group, + commands: group.commands.map((command) => ({ + ...command, + isEnabled: true + })) + })); + + return withEnabledSettings; + }, + render: () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let component: any; + + let scrollHandler: (() => void) | null = null; + + return { + onStart: (props: SuggestionProps) => { + component = new SvelteRenderer(menuList, { + props, + editor: props.editor + }); + + const { view } = props.editor; + + if (popup.element) { + popup.element.appendChild(component.element); + popup.element.style.visibility = 'visible'; + popup.element.style.pointerEvents = 'auto'; + popup.isVisible = true; + + const updatePosition = () => { + if (!popup.element || !props.clientRect) return; + + const rect = props.clientRect(); + if (!rect) return; + + const referenceElement = { + getBoundingClientRect: () => rect + }; + + computePosition(referenceElement, popup.element, { + placement: 'bottom-start' as Placement, + middleware: [ + offset({ mainAxis: 8, crossAxis: 16 }), + flip({ fallbackPlacements: ['top-start', 'bottom-start'] }) + ] + }).then(({ x, y }) => { + if (popup.element) { + popup.element.style.left = `${x}px`; + popup.element.style.top = `${y}px`; + } + }); + }; + + updatePosition(); + + // Set up auto-update for scroll events + if (props.clientRect) { + const referenceElement = { + getBoundingClientRect: () => props.clientRect?.() || new DOMRect() + }; + popup.cleanup = autoUpdate(referenceElement, popup.element, updatePosition); + } + + scrollHandler = updatePosition; + view.dom.parentElement?.addEventListener('scroll', scrollHandler); + } + }, + + onUpdate(props: SuggestionProps) { + component.updateProps(props); + + if (popup.element && popup.isVisible && props.clientRect) { + const rect = props.clientRect(); + if (rect) { + const referenceElement = { + getBoundingClientRect: () => rect + }; + + computePosition(referenceElement, popup.element, { + placement: 'bottom-start' as Placement, + middleware: [ + offset({ mainAxis: 8, crossAxis: 16 }), + flip({ fallbackPlacements: ['top-start', 'bottom-start'] }) + ] + }).then(({ x, y }) => { + if (popup.element) { + popup.element.style.left = `${x}px`; + popup.element.style.top = `${y}px`; + } + }); + + props.editor.storage[extensionName].rect = rect; + } + } + }, + + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === 'Escape') { + if (popup.element) { + popup.element.style.visibility = 'hidden'; + popup.element.style.pointerEvents = 'none'; + popup.isVisible = false; + } + return true; + } + + if (!popup.isVisible && popup.element) { + popup.element.style.visibility = 'visible'; + popup.element.style.pointerEvents = 'auto'; + popup.isVisible = true; + } + + if (props.event.key === 'Enter') return true; + + // return component.ref?.onKeyDown(props); + return false; + }, + + onExit(props) { + if (popup.element) { + popup.element.style.visibility = 'hidden'; + popup.element.style.pointerEvents = 'none'; + popup.element.innerHTML = ''; + popup.isVisible = false; + } + + if (popup.cleanup) { + popup.cleanup(); + popup.cleanup = null; + } + + if (scrollHandler) { + const { view } = props.editor; + view.dom.parentElement?.removeEventListener('scroll', scrollHandler); + scrollHandler = null; + } + + component.destroy(); + } + }; + } + }) + ]; + }, + + addStorage() { + return { + rect: { + width: 0, + height: 0, + left: 0, + top: 0, + right: 0, + bottom: 0 + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/table/index.ts b/packages/ui/src/custom/editor/extensions/table/index.ts new file mode 100644 index 00000000..29e17aa7 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/table/index.ts @@ -0,0 +1,4 @@ +export { Table } from './table'; +export { TableCell } from './table-cell'; +export { TableRow } from './table-row'; +export { TableHeader } from './table-header'; diff --git a/packages/ui/src/custom/editor/extensions/table/table-cell.ts b/packages/ui/src/custom/editor/extensions/table/table-cell.ts new file mode 100644 index 00000000..bd79edfa --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/table/table-cell.ts @@ -0,0 +1,124 @@ +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { Node, mergeAttributes } from '@tiptap/core'; +import { getCellsInColumn, isRowSelected, selectRow } from './utils'; + +import { Plugin } from '@tiptap/pm/state'; + +export interface TableCellOptions { + HTMLAttributes: Record; +} + +export const TableCell = Node.create({ + name: 'tableCell', + + content: 'block+', + tableRole: 'cell', + + isolating: true, + + addOptions() { + return { + HTMLAttributes: {} + }; + }, + + parseHTML() { + return [{ tag: 'td' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addAttributes() { + return { + colspan: { + default: 1, + parseHTML: (element) => { + const colspan = element.getAttribute('colspan'); + const value = colspan ? parseInt(colspan, 10) : 1; + + return value; + } + }, + rowspan: { + default: 1, + parseHTML: (element) => { + const rowspan = element.getAttribute('rowspan'); + const value = rowspan ? parseInt(rowspan, 10) : 1; + + return value; + } + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth'); + const value = colwidth ? [parseInt(colwidth, 10)] : null; + + return value; + } + }, + style: { + default: null + } + }; + }, + + addProseMirrorPlugins() { + const { isEditable } = this.editor; + + return [ + new Plugin({ + props: { + decorations: (state) => { + if (!isEditable) { + return DecorationSet.empty; + } + + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInColumn(0)(selection); + + if (cells) { + cells.forEach(({ pos }: { pos: number }, index: number) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const rowSelected = isRowSelected(index)(selection); + let className = 'grip-row'; + + if (rowSelected) { + className += ' selected'; + } + + if (index === 0) { + className += ' first'; + } + + if (index === cells.length - 1) { + className += ' last'; + } + + const grip = document.createElement('a'); + + grip.className = className; + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.editor.view.dispatch(selectRow(index)(this.editor.state.tr)); + }); + + return grip; + }) + ); + }); + } + + return DecorationSet.create(doc, decorations); + } + } + }) + ]; + } +}); diff --git a/packages/ui/src/custom/editor/extensions/table/table-header.ts b/packages/ui/src/custom/editor/extensions/table/table-header.ts new file mode 100644 index 00000000..bcc68462 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/table/table-header.ts @@ -0,0 +1,89 @@ +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { getCellsInRow, isColumnSelected, selectColumn } from './utils'; + +import { Plugin } from '@tiptap/pm/state'; +import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table'; + +export const TableHeader = TiptapTableHeader.extend({ + addAttributes() { + return { + colspan: { + default: 1 + }, + rowspan: { + default: 1 + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth'); + const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null; + + return value; + } + }, + style: { + default: null + } + }; + }, + + addProseMirrorPlugins() { + const { isEditable } = this.editor; + + return [ + new Plugin({ + props: { + decorations: (state) => { + if (!isEditable) { + return DecorationSet.empty; + } + + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInRow(0)(selection); + + if (cells) { + cells.forEach(({ pos }: { pos: number }, index: number) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const colSelected = isColumnSelected(index)(selection); + let className = 'grip-column'; + + if (colSelected) { + className += ' selected'; + } + + if (index === 0) { + className += ' first'; + } + + if (index === cells.length - 1) { + className += ' last'; + } + + const grip = document.createElement('a'); + + grip.className = className; + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr)); + }); + + return grip; + }) + ); + }); + } + + return DecorationSet.create(doc, decorations); + } + } + }) + ]; + } +}); + +export default TableHeader; diff --git a/packages/ui/src/custom/editor/extensions/table/table-row.ts b/packages/ui/src/custom/editor/extensions/table/table-row.ts new file mode 100644 index 00000000..63cc1e1d --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/table/table-row.ts @@ -0,0 +1,8 @@ +import { TableRow as TiptapTableRow } from '@tiptap/extension-table'; + +export const TableRow = TiptapTableRow.extend({ + allowGapCursor: false, + content: '(tableCell | tableHeader)*' +}); + +export default TableRow; diff --git a/packages/ui/src/custom/editor/extensions/table/table.ts b/packages/ui/src/custom/editor/extensions/table/table.ts new file mode 100644 index 00000000..e5f825df --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/table/table.ts @@ -0,0 +1,9 @@ +import { Table as TiptapTable } from '@tiptap/extension-table'; + +export const Table = TiptapTable.configure({ + resizable: true, + lastColumnResizable: true, + allowTableNodeSelection: true +}); + +export default Table; diff --git a/packages/ui/src/custom/editor/extensions/table/utils.ts b/packages/ui/src/custom/editor/extensions/table/utils.ts new file mode 100644 index 00000000..b7a0b45d --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/table/utils.ts @@ -0,0 +1,313 @@ +import { Editor, findParentNode } from '@tiptap/core'; +import { EditorState, Selection, Transaction } from '@tiptap/pm/state'; +import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'; +import { Node, ResolvedPos } from '@tiptap/pm/model'; +import type { EditorView } from '@tiptap/pm/view'; +import Table from './table'; + +export const isRectSelected = (rect: Rect) => (selection: CellSelection) => { + const map = TableMap.get(selection.$anchorCell.node(-1)); + const start = selection.$anchorCell.start(-1); + const cells = map.cellsInRect(rect); + const selectedCells = map.cellsInRect( + map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start) + ); + + for (let i = 0, count = cells.length; i < count; i += 1) { + if (selectedCells.indexOf(cells[i]) === -1) { + return false; + } + } + + return true; +}; + +export const findTable = (selection: Selection) => + findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(selection); + +export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection; + +export const isColumnSelected = (columnIndex: number) => (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: columnIndex, + right: columnIndex + 1, + top: 0, + bottom: map.height + })(selection); + } + + return false; +}; + +export const isRowSelected = (rowIndex: number) => (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: 0, + right: map.width, + top: rowIndex, + bottom: rowIndex + 1 + })(selection); + } + + return false; +}; + +export const isTableSelected = (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: 0, + right: map.width, + top: 0, + bottom: map.height + })(selection); + } + + return false; +}; + +export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => { + const table = findTable(selection); + if (table) { + const map = TableMap.get(table.node); + const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]); + + return indexes.reduce( + (acc, index) => { + if (index >= 0 && index <= map.width - 1) { + const cells = map.cellsInRect({ + left: index, + right: index + 1, + top: 0, + bottom: map.height + }); + + return acc.concat( + cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + + return { pos, start: pos + 1, node }; + }) + ); + } + + return acc; + }, + [] as { pos: number; start: number; node: Node | null | undefined }[] + ); + } + return null; +}; + +export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => { + const table = findTable(selection); + + if (table) { + const map = TableMap.get(table.node); + const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]); + + return indexes.reduce( + (acc, index) => { + if (index >= 0 && index <= map.height - 1) { + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: index, + bottom: index + 1 + }); + + return acc.concat( + cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + return { pos, start: pos + 1, node }; + }) + ); + } + + return acc; + }, + [] as { pos: number; start: number; node: Node | null | undefined }[] + ); + } + + return null; +}; + +export const getCellsInTable = (selection: Selection) => { + const table = findTable(selection); + + if (table) { + const map = TableMap.get(table.node); + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: 0, + bottom: map.height + }); + + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + + return { pos, start: pos + 1, node }; + }); + } + + return null; +}; + +export const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: Node) => boolean) => { + for (let i = $pos.depth; i > 0; i -= 1) { + const node = $pos.node(i); + + if (predicate(node)) { + return { + pos: i > 0 ? $pos.before(i) : 0, + start: $pos.start(i), + depth: i, + node + }; + } + } + + return null; +}; + +export const findCellClosestToPos = ($pos: ResolvedPos) => { + const predicate = (node: Node) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole); + + return findParentNodeClosestToPos($pos, predicate); +}; + +const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => { + const table = findTable(tr.selection); + const isRowSelection = type === 'row'; + + if (table) { + const map = TableMap.get(table.node); + + // Check if the index is valid + if (index >= 0 && index < (isRowSelection ? map.height : map.width)) { + const left = isRowSelection ? 0 : index; + const top = isRowSelection ? index : 0; + const right = isRowSelection ? map.width : index + 1; + const bottom = isRowSelection ? index + 1 : map.height; + + const cellsInFirstRow = map.cellsInRect({ + left, + top, + right: isRowSelection ? right : left + 1, + bottom: isRowSelection ? top + 1 : bottom + }); + + const cellsInLastRow = + bottom - top === 1 + ? cellsInFirstRow + : map.cellsInRect({ + left: isRowSelection ? left : right - 1, + top: isRowSelection ? bottom - 1 : top, + right, + bottom + }); + + const head = table.start + cellsInFirstRow[0]; + const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1]; + const $head = tr.doc.resolve(head); + const $anchor = tr.doc.resolve(anchor); + + return tr.setSelection(new CellSelection($anchor, $head)); + } + } + return tr; +}; + +export const selectColumn = select('column'); + +export const selectRow = select('row'); + +export const selectTable = (tr: Transaction) => { + const table = findTable(tr.selection); + + if (table) { + const { map } = TableMap.get(table.node); + + if (map && map.length) { + const head = table.start + map[0]; + const anchor = table.start + map[map.length - 1]; + const $head = tr.doc.resolve(head); + const $anchor = tr.doc.resolve(anchor); + + return tr.setSelection(new CellSelection($anchor, $head)); + } + } + + return tr; +}; + +export const isColumnGripSelected = ({ + editor, + view, + state, + from +}: { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; +}) => { + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) { + return false; + } + + let container = node; + + while (container && !['TD', 'TH'].includes(container.tagName)) { + container = container.parentElement!; + } + + const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected'); + + return !!gripColumn; +}; + +export const isRowGripSelected = ({ + editor, + view, + state, + from +}: { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; +}) => { + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) { + return false; + } + + let container = node; + + while (container && !['TD', 'TH'].includes(container.tagName)) { + container = container.parentElement!; + } + + const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected'); + + return !!gripRow; +}; diff --git a/packages/ui/src/custom/editor/extensions/video/VideoExtended.ts b/packages/ui/src/custom/editor/extensions/video/VideoExtended.ts new file mode 100644 index 00000000..3693d1a1 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/video/VideoExtended.ts @@ -0,0 +1,34 @@ +import type { Component } from 'svelte'; +import type { NodeViewProps } from '@tiptap/core'; +import { SvelteNodeViewRenderer } from 'svelte-tiptap'; +import { Video } from './VideoExtension'; + +export const VideoExtended = (content: Component) => + Video.extend({ + addAttributes() { + return { + src: { + default: null + }, + alt: { + default: null + }, + title: { + default: null + }, + width: { + default: '100%' + }, + height: { + default: null + }, + align: { + default: 'left' + } + }; + }, + + addNodeView: () => { + return SvelteNodeViewRenderer(content); + } + }); diff --git a/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts b/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts new file mode 100644 index 00000000..f3b9b2b7 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts @@ -0,0 +1,147 @@ +import { Node, nodeInputRule } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +export interface VideoOptions { + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + video: { + /** + * Set a video node + */ + setVideo: (src: string) => ReturnType; + /** + * Toggle a video + */ + toggleVideo: (src: string) => ReturnType; + /** + * Remove a video + */ + removeVideo: () => ReturnType; + }; + } +} + +const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; + +export const Video = Node.create({ + name: 'video', + group: 'block', + content: 'inline*', + draggable: true, + isolating: true, + addOptions() { + return { + HTMLAttributes: {} + }; + }, + addAttributes() { + return { + src: { + default: null, + parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'), + renderHTML: (attrs) => ({ src: attrs.src }) + } + }; + }, + parseHTML() { + return [ + { + tag: 'video', + getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') }) + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'video', + { controls: 'true', style: 'width: fit-content;', ...HTMLAttributes }, + ['source', HTMLAttributes] + ]; + }, + addCommands() { + return { + setVideo: + (src: string) => + ({ commands }) => + commands.insertContent( + `