feat: migrate dashboard to svelte 5 (#577)

* chore: migration to SvelteKit 2

* stage 1: migrate to svelte 5 and fix rerendering issues

* stage 2: fix more $: reactivity across dashboard

* fix merge conflicts

* chore: migrate code in main to svelte 5

* chore: update preventDefault

* chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte

* chore: enforce .ts across dashboard

* rename from classroomio-com to website

* stage-3: Replace carbon icon svelte with lucide icon

* update lock file

* chore: finish icon replacement

* fix: settings and setup page multiple rerender

* stage 3: More icon and rerendering fixes

* feat: checkpoint

* feat: update editor

* [not working] connect everything with ui build

* storybook + ui + dashboard all working independently

* add bg-black to +page.svelte

* feat: add editor to storybook

* chore: animate lucid icons (#590)

* v1 animations

* feat: change some course navigation icons & animate org navigation icons

* chore: change settings icon

* chore: use css instead of js for hover

* rename animations

---------

Co-authored-by: tunny17 <[email protected]>

* chore: update path to node_modules types in api tsconfig

* fix: update import path for DropdownMenu component

* fix: add rollupOptions to build configuration in vite.config.ts

* fix: remove rollupOptions from build configuration and add @floating-ui/dom dependency

* install

---------

Co-authored-by: tunny17 <[email protected]>
This commit is contained in:
Rotimi Best
2025-11-01 16:18:29 +01:00
committed by GitHub
co-authored by tunny17
parent 0fb66a3782
commit f5a4156c8b
159 changed files with 15225 additions and 3722 deletions
+6 -1
View File
@@ -37,4 +37,9 @@ yarn-error.log*
vite.config.ts*.mjs
.pnpm-store/*
.pnpm-store/*
**/dist/*
#Ignore vscode AI rules
.github/instructions/codacy.instructions.md
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/**/*"],
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
};
+5 -2
View File
@@ -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:*",
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
'@tailwindcss/postcss': {},
}
};
@@ -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);
}
}
+23 -25
View File
@@ -1,33 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="theme-color" content="#333333" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="theme-color" content="#333333" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<link rel="stylesheet" href="https://assets.cdn.clsrio.com/carbon.css" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<script
defer
src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"
></script>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
<script defer src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
%sveltekit.head%
</head>
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
<body data-sveltekit-preload-data="hover" class="">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -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<EdraEditorProps['Content']>();
// let editor = $state<EdraEditorProps['Editor']>();
// 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<string, boolean> = {};
// let prevContent = '';
let timeoutId: NodeJS.Timeout;
let errors: Record<string, string> = {};
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)
);
</script>
@@ -403,7 +414,26 @@
</div>
<div class="mt-5 h-[60vh]">
<TextEditor
<!-- <div class="bg-background z-50 mt-12 size-full max-w-5xl rounded-md border border-dashed">
{#if editor && !editor.isDestroyed}
<EdraToolBar
class="bg-secondary/50 flex w-full items-center overflow-x-auto border-b border-dashed p-0.5"
{editor}
/>
{:else}
<div>
Debug: Editor state - {editor ? 'exists' : 'undefined'}, destroyed: {editor?.isDestroyed || 'N/A'}
</div>
{/if}
<EdraEditor
bind:editor
content={editorValue}
autofocus={true}
class="h-[300px] outline-none"
{onUpdate}
/>
</div> -->
<!-- <TextEditor
id={lessonId}
bind:editorWindowRef
value={editorValue}
@@ -419,7 +449,7 @@
$isLessonDirty = true;
}}
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
/>
/> -->
</div>
</TabContent>
@@ -79,7 +79,7 @@
<div class={className}>
<button
class="item relative flex items-center {btnPadding} mb-1 ml-2 {NavClasses.item} {isGroupActive &&
class="item group relative flex items-center {btnPadding} mb-1 ml-2 {NavClasses.item} {isGroupActive &&
!isLoading &&
NavClasses.active} w-[95%]"
tabindex="0"
@@ -1,15 +1,15 @@
<script lang="ts">
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents'; // overview
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import BookCheckIcon from '@lucide/svelte/icons/book-check';
import ListTodoIcon from '@lucide/svelte/icons/list-todo';
import UsersIcon from '@lucide/svelte/icons/users';
import ScreenShareIcon from '@lucide/svelte/icons/screen-share';
import AwardIcon from '@lucide/svelte/icons/award';
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text';
import SettingsIcon from '@lucide/svelte/icons/settings';
import MegaphoneIcon from '@lucide/svelte/icons/megaphone';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import ListIcon from '@lucide/svelte/icons/list';
import SquareLibrary from '@lucide/svelte/icons/square-library';
import BookOpenCheck from '@lucide/svelte/icons/book-open-check';
import PanelTop from '@lucide/svelte/icons/panel-top';
import { NAV_IDS } from './constants';
interface Props {
@@ -20,25 +20,25 @@
</script>
{#if name === NAV_IDS.SECTION}
<TableOfContentsIcon size={16} />
<TableOfContentsIcon size={16} class="group-hover:animate-flip-book" />
{:else if name === NAV_IDS.NEWS_FEED}
<MegaphoneIcon size={16} />
<MegaphoneIcon size={16} class="group-hover:animate-shake-announcement" />
{:else if name === NAV_IDS.LESSONS}
<ScrollTextIcon size={16} />
<ScrollTextIcon size={16} class="group-hover:animate-scroll-unroll" />
{:else if name === NAV_IDS.ATTENDANCE}
<ListIcon size={16} />
<BookOpenCheck size={16} class="group-hover:animate-check-items" />
{:else if name === NAV_IDS.SUBMISSIONS}
<ListTodoIcon size={16} />
<SquareLibrary size={16} class="group-hover:animate-task-complete" />
{:else if name === NAV_IDS.MARKS}
<BookCheckIcon size={16} />
<BookCheckIcon size={16} class="group-hover:animate-grade-glow" />
{:else if name === NAV_IDS.PEOPLE}
<UsersIcon size={16} />
<UsersIcon size={16} class="group-hover:animate-people-wave" />
{:else if name === NAV_IDS.ANALYTICS}
<ChartLineIcon size={16} />
<ChartLineIcon size={16} class="group-hover:animate-chart-grow" />
{:else if name === NAV_IDS.LANDING_PAGE}
<ScreenShareIcon size={16} />
<PanelTop size={16} class="group-hover:animate-screen-glow" />
{:else if name === NAV_IDS.CERTIFICATES}
<AwardIcon size={16} />
<AwardIcon size={16} class="group-hover:animate-bounce" />
{:else if name === NAV_IDS.SETTINGS}
<SettingsIcon size={16} />
<SettingsIcon size={16} class="group-hover:animate-spin" />
{/if}
@@ -35,7 +35,7 @@
}
});
const result = (await response.json());
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
@@ -34,11 +34,6 @@
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false
}: Props = $props();
export let courses: Course[] = [];
export let emptyTitle = $t('courses.course_card.empty_title');
export let emptyDescription = $t('courses.course_card.empty_description');
export let isExplore = false;
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
@@ -55,19 +50,19 @@
try {
await deleteCourse($deleteCourseModal.id);
// Remove the course from the courses store
$coursesStore = $coursesStore.filter(course => course.id !== $deleteCourseModal.id);
$coursesStore = $coursesStore.filter((course) => course.id !== $deleteCourseModal.id);
// Show success message
snackbar.success('snackbar.course_deleted');
// Close modal and reset state
deleteCourseModal.set(deleteCourseModalInitialState);
} catch (error) {
console.error('Error deleting course:', error);
snackbar.error('snackbar.course_settings.error.went_wrong');
// Stop deleting state on error
$deleteCourseModal.isDeleting = false;
}
@@ -75,8 +70,8 @@
</script>
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
@@ -0,0 +1,6 @@
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import defaultContent from './default-content';
</script>
<Editor content={defaultContent} />
@@ -0,0 +1,825 @@
export default {
type: 'doc',
content: [
{
type: 'heading',
attrs: {
textAlign: 'center',
level: 1
},
content: [
{
type: 'text',
text: 'Introducing ClassroomIO'
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: 'justify',
class: null
},
content: [
{
type: 'text',
text: 'The AI native LMS. We have '
},
{
type: 'text',
marks: [
{
type: 'bold'
}
],
text: 'bold'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'italic'
}
],
text: 'italic'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'strike'
}
],
text: 'strike'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'underline'
}
],
text: 'underline'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'https://github.com',
target: '_blank',
rel: 'noopener noreferrer',
class: null
}
}
],
text: 'link'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'code'
}
],
text: 'code'
},
{
type: 'text',
text: ', Superscript A'
},
{
type: 'text',
marks: [
{
type: 'superscript'
}
],
text: 'b'
},
{
type: 'text',
text: ', subscript A'
},
{
type: 'text',
marks: [
{
type: 'subscript'
}
],
text: 'b'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'highlight',
attrs: {
color: '#800080'
}
}
],
text: 'highlight1'
},
{
type: 'text',
marks: [
{
type: 'highlight',
attrs: {
color: '#1a1600'
}
}
],
text: ','
},
{
type: 'text',
text: ' '
},
{
type: 'text',
marks: [
{
type: 'highlight',
attrs: {
color: '#0000FF'
}
}
],
text: 'highlight2'
},
{
type: 'text',
text: ', '
},
{
type: 'text',
marks: [
{
type: 'textStyle',
attrs: {
color: '#008000',
fontSize: ''
}
}
],
text: 'textcolor1'
},
{
type: 'text',
marks: [
{
type: 'textStyle',
attrs: {
color: 'rgb(78, 211, 21)',
fontSize: ''
}
}
],
text: ', '
},
{
type: 'text',
marks: [
{
type: 'textStyle',
attrs: {
color: '#FFA500',
fontSize: ''
}
}
],
text: 'textcolor2'
}
]
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
attrs: {
textAlign: 'justify',
class: null
},
content: [
{
type: 'text',
text: 'This is a block Quote'
}
]
}
]
},
{
type: 'codeBlock',
attrs: {
language: 'javascript'
},
content: [
{
type: 'text',
text: 'console.log("Hello World")'
}
]
},
{
type: 'horizontalRule'
},
{
type: 'heading',
attrs: {
textAlign: 'left',
level: 2
},
content: [
{
type: 'text',
text: 'Support For Media Placeholders and Medias'
}
]
},
{
type: 'image-placeholder'
},
{
type: 'image',
attrs: {
src: 'https://placehold.co/800x400/6A00F5/white',
alt: null,
title: 'A Sample Image',
width: 658,
height: null,
align: 'center'
}
},
{
type: 'video-placeholder'
},
{
type: 'video',
attrs: {
src: 'https://videos.pexels.com/video-files/2491284/2491284-uhd_2732_1440_24fps.mp4',
alt: null,
title: 'A Sample Video',
width: 673,
height: null,
align: 'center'
}
},
{
type: 'audio-placeholder'
},
{
type: 'audio',
attrs: {
src: 'https://cdn.pixabay.com/audio/2024/10/30/audio_42e6870f29.mp3',
alt: null,
title: 'A Sample Audio',
width: 670,
height: null,
align: 'center'
}
},
{
type: 'horizontalRule'
},
{
type: 'heading',
attrs: {
textAlign: null,
level: 2
},
content: [
{
type: 'text',
text: 'IFrames'
}
]
},
{
type: 'iframe-placeholder'
},
{
type: 'iframe',
attrs: {
src: 'https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3748.5645078258503!2d75.17451197505285!3d20.026784381383496!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3bdb93bd138ae4bd%3A0x574c6482cf0b89cf!2sEllora%20Caves!5e0!3m2!1sen!2sin!4v1742466514831!5m2!1sen!2sin',
alt: null,
title: 'Ellora Caves, Maharastra, India',
width: 621,
height: null,
align: 'center'
}
},
{
type: 'iframe',
attrs: {
src: 'https://www.youtube.com/embed/jfKfPfyJRdk?si=SYBE0PkI5v79-54W',
alt: null,
title: 'Lofi Hip Hop Youtube Streaming Video',
width: 623,
height: null,
align: 'center'
}
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
}
},
{
type: 'horizontalRule'
},
{
type: 'heading',
attrs: {
textAlign: null,
level: 2
},
content: [
{
type: 'text',
text: 'Typography'
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Smilipicker, type '
},
{
type: 'text',
marks: [
{
type: 'code'
}
],
text: ':)'
},
{
type: 'text',
text: ' and it becomes 🙂 or 😉. Typography support e.g. type '
},
{
type: 'text',
marks: [
{
type: 'code'
}
],
text: '!='
},
{
type: 'text',
text: ' and it becomes ≠. Similarly (c) becomes ©, -> becomes → and many more like 1×2, ½. We also have color visualizer. #FFF, #000, #FF00FF can be visualized.'
}
]
},
{
type: 'horizontalRule'
},
{
type: 'heading',
attrs: {
textAlign: null,
level: 2
},
content: [
{
type: 'text',
text: 'Tables'
}
]
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [323],
style: null
},
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Hello World'
}
]
}
]
},
{
type: 'tableCell',
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [328],
style: null
},
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Hello World'
}
]
}
]
},
{
type: 'tableCell',
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [330],
style: null
},
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Hello World'
}
]
}
]
}
]
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [323],
style: null
},
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Hello World'
}
]
}
]
},
{
type: 'tableCell',
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [328],
style: null
},
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Hello World'
}
]
}
]
},
{
type: 'tableCell',
attrs: {
colspan: 1,
rowspan: 1,
colwidth: [330],
style: null
},
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Hello World'
}
]
}
]
}
]
}
]
},
{
type: 'heading',
attrs: {
textAlign: null,
level: 3
},
content: [
{
type: 'text',
marks: [
{
type: 'textStyle',
attrs: {
color: 'var(--tw-prose-headings)',
fontSize: '1.5em'
}
}
],
text: 'Rending Math and '
},
{
type: 'inlineMath',
attrs: {
latex: '\\LaTeX',
evaluate: 'no',
display: 'no'
}
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'The editor supports the '
},
{
type: 'inlineMath',
attrs: {
latex: '\\LaTeX',
evaluate: 'no',
display: 'no'
}
},
{
type: 'text',
text: ' rendering. e.g.'
}
]
},
{
type: 'orderedList',
attrs: {
start: 1
},
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Functions like, '
},
{
type: 'inlineMath',
attrs: {
latex: 'sin²\\theta + cos²\\theta=1, a²+b²=c²',
evaluate: 'no',
display: 'no'
}
}
]
}
]
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Matrix like unit matrix'
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'inlineMath',
attrs: {
latex: '\\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}',
evaluate: 'no',
display: 'no'
}
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'or more matrices like'
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'inlineMath',
attrs: {
latex:
'\\begin{pmatrix} 1 & 0 & \\cdots & 0 \\\\ 0 & 1 & \\cdots & 0 \\\\ \\vdots & \\vdots & \\ddots & \\vdots \\\\ 0 & 0 & \\cdots & 1 \\end{pmatrix}',
evaluate: 'no',
display: 'no'
}
}
]
}
]
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'text',
text: 'Integrals and differential'
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'inlineMath',
attrs: {
latex:
'\\int_{0}^{\\frac{\\pi}{2}} \\sin(x) \\,dx = \\left[-\\cos(x)\\right]_0^{\\frac{\\pi}{2}} = -\\cos\\left(\\frac{\\pi}{2}\\right) + \\cos(0)',
evaluate: 'no',
display: 'no'
}
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
},
content: [
{
type: 'inlineMath',
attrs: {
latex: '\\frac{\\mathrm{d}}{\\mathrm{d}x}(\\sin x) = \\cos x',
evaluate: 'no',
display: 'no'
}
}
]
}
]
}
]
},
{
type: 'heading',
attrs: {
textAlign: null,
level: 3
},
content: [
{
type: 'text',
text: 'Explore More…'
}
]
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
}
},
{
type: 'paragraph',
attrs: {
textAlign: null,
class: null
}
}
]
};
@@ -11,11 +11,6 @@
}
let { open = $bindable(false), onDelete = () => {}, isLoading = false }: Props = $props();
function handleDelete() {
onDelete();
open = false;
}
</script>
<Modal
@@ -1,17 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import BellIcon from '@lucide/svelte/icons/bell';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import MenuIcon from '@lucide/svelte/icons/menu';
import XIcon from '@lucide/svelte/icons/x';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
import { IconButton } from '$lib/components/IconButton';
import { globalStore } from '$lib/utils/store/app';
import { currentOrgPath } from '$lib/utils/store/org';
import { toggleBodyByMode } from '$lib/utils/functions/app';
import { sideBar } from '../Org/store';
import { t } from '$lib/utils/functions/translations';
@@ -27,16 +24,6 @@
$sideBar.hidden = !$sideBar.hidden;
};
function toggleDarkMode() {
$globalStore.isDark = !$globalStore.isDark;
toggleBodyByMode($globalStore.isDark);
if (browser) {
localStorage.setItem('mode', $globalStore.isDark ? 'dark' : '');
}
}
let coursesPath = $derived(
$globalStore.isOrgSite ? '/lms/mylearning' : isCoursePage ? `${$currentOrgPath}/courses` : $currentOrgPath
);
@@ -91,13 +78,14 @@
<BellIcon class="custom text-white" size={16} />
</li>
<li>
<IconButton size="small" onClick={toggleDarkMode}>
<!-- <IconButton size="small" onClick={toggleDarkMode}>
{#if $globalStore.isDark}
<SunIcon size={16} class="custom" />
{:else}
<MoonIcon class="custom text-white" size={16} />
{/if}
</IconButton>
</IconButton> -->
<ModeSwitcher />
</li>
<li></li>
@@ -4,6 +4,7 @@
import { isCoursePage } from '$lib/utils/functions/app';
import { t } from '$lib/utils/functions/translations';
import type { TCustomLinks } from './types';
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
import Logo from './Logo.svelte';
import CustomLinks from './CustomLinks.svelte';
@@ -76,6 +77,8 @@
{:else if !isOrgSite && !$page.url.pathname?.includes('/404')}
<AuthButtons {disableSignup} {redirect} />
{/if}
<ModeSwitcher />
</ul>
{#if showLinks}
@@ -1,9 +1,10 @@
<script>
import { Popover } from 'carbon-components-svelte';
import Menu from './Menu.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { isMobile } from '$lib/utils/store/useMobile';
import { profileMenu } from '../store';
import { clickOutside } from '$lib/utils/functions/routes/dashboard';
</script>
{#if $isMobile}
@@ -16,15 +17,12 @@
>
<Menu />
</Modal>
{:else}
<Popover
bind:open={$profileMenu.open}
align="right-bottom"
caret={true}
on:click:outside={({ detail }) => {
$profileMenu.open = !!$profileMenu.ref?.contains(detail.target);
}}
{:else if $profileMenu.open}
<div
use:clickOutside
on:click:outside={() => ($profileMenu.open = false)}
class="absolute bottom-28 left-0 z-[300] w-full border bg-black p-2 dark:bg-white"
>
<Menu />
</Popover>
</div>
{/if}
@@ -76,10 +76,11 @@
if (error) throw error;
} catch (error) {
let message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
message = $t('snackbar.lms.error.username_exists');
}
snackbar.error(`${$t('snackbar.lms.error.update')}: ${message}`);
loading = false;
@@ -5,7 +5,7 @@
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import UsersIcon from '@lucide/svelte/icons/users';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import SettingsIcon from '@lucide/svelte/icons/settings';
import Settings2 from '@lucide/svelte/icons/settings-2';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
import Dice6Icon from '@lucide/svelte/icons/dice-6';
@@ -98,24 +98,24 @@
{#if menuItem.show}
<a href="{$currentOrgPath}{menuItem.path}" class="text-black no-underline" onclick={toggleSidebar}>
<li
class="mb-1 flex items-center gap-2.5 px-2.5 py-2 {NavClasses.item} {menuItem.isActive
class="group mb-1 flex items-center gap-2.5 px-2.5 py-2 {NavClasses.item} {menuItem.isActive
? NavClasses.active
: 'dark:text-white'}"
>
{#if menuItem.path === ''}
<LayoutDashboardIcon size={16} />
<LayoutDashboardIcon size={16} class="group-hover:animate-gather" />
{:else if menuItem.path === '/courses'}
<LibraryBigIcon size={16} />
<LibraryBigIcon size={16} class="group-hover:animate-library-expand" />
{:else if menuItem.path === '/site'}
<LayoutTemplateIcon size={16} />
<LayoutTemplateIcon size={16} class="group-hover:animate-template-morph" />
{:else if menuItem.path === '/community'}
<MessageSquareMoreIcon size={16} />
<MessageSquareMoreIcon size={16} class="group-hover:animate-ripple" />
{:else if menuItem.path === '/quiz'}
<Dice6Icon size={16} />
<Dice6Icon size={16} class="group-hover:animate-roll" />
{:else if menuItem.path === '/audience'}
<UsersIcon size={16} />
<UsersIcon size={16} class="group-hover:animate-gather" />
{:else if menuItem.path === '/setup'}
<SettingsIcon size={16} />
<Settings2 size={16} class="group-hover:animate-setup-configure" />
{/if}
<p class="text-sm font-medium">{menuItem.label}</p>
</li>
@@ -52,3 +52,19 @@ export const formatDate = (_selectedDate: string | undefined): string => {
return selectedDate.format('ddd, MMM D');
};
export function clickOutside(node) {
const handleClick = (event) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('click:outside', { detail: event.target }));
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
@@ -158,9 +158,7 @@ export const orgLandingpageValidation = (fields = {}) => {
export const onboardingValidation = (fields = {}, step) => {
const onboardingValidationSchema = {
stepOne: z.object({
fullname: z
.string()
.min(5, { message: `${t.get('validations.onboarding.step_one.full_name.min_char')}` }),
fullname: z.string().min(5, { message: `${t.get('validations.onboarding.step_one.full_name.min_char')}` }),
orgName: getOrgNameValidation(),
siteName: getSiteNameValidation()
}),
@@ -178,8 +176,7 @@ export const onboardingValidation = (fields = {}, step) => {
})
};
const schema =
step === 1 ? onboardingValidationSchema.stepOne : onboardingValidationSchema.stepTwo;
const schema = step === 1 ? onboardingValidationSchema.stepOne : onboardingValidationSchema.stepTwo;
const { error } = schema.safeParse(fields);
return processErrors(error);
+15 -5
View File
@@ -13,7 +13,13 @@
// import PageLoadProgressBar from '$lib/components/Progress/PageLoadProgressBar.svelte';
import Snackbar from '$lib/components/Snackbar/index.svelte';
import UpgradeModal from '$lib/components/Upgrade/Modal.svelte';
import { isCoursesPage, isLMSPage, isOrgPage, toggleBodyByMode } from '$lib/utils/functions/app';
import {
isCoursesPage,
isLMSPage,
isOrgPage
// toggleBodyByMode
} from '$lib/utils/functions/app';
import { getProfile, setupAnalytics } from '$lib/utils/functions/appSetup';
import hideNavByRoute from '$lib/utils/functions/routes/hideNavByRoute';
import { getSupabase } from '$lib/utils/functions/supabase';
@@ -28,7 +34,9 @@
import { onMount } from 'svelte';
import { MetaTags } from 'svelte-meta-tags';
import '../app.postcss';
import { ModeWatcher } from '@cio/ui/base/dark-mode';
import '../app.css';
let { data, children } = $props();
@@ -52,8 +60,8 @@
if (browser) {
// Update theme - dark or light mode
$globalStore.isDark = localStorage.getItem('mode') === 'dark';
toggleBodyByMode($globalStore.isDark);
// $globalStore.isDark = localStorage.getItem('mode') === 'dark';
// toggleBodyByMode($globalStore.isDark);
if (data.isOrgSite && data.org?.theme) {
setTheme(data.org?.theme);
@@ -116,9 +124,11 @@
<svelte:window onresize={handleResize} />
<ModeWatcher />
<MetaTags {...metaTags} />
<Theme bind:theme={carbonTheme} />
<!-- <Theme bind:theme={carbonTheme} /> -->
<UpgradeModal />
+8 -6
View File
@@ -9,6 +9,7 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import { validateEmail } from '$lib/utils/functions/validateEmail';
import Edra from '$lib/components/Edra/EdraRoot.svelte';
let email = $state('');
let isAdding = $state(false);
@@ -57,7 +58,7 @@
<title>ClassroomIO - The classroom software</title>
</svelte:head>
<div class="m-2 flex w-screen flex-col items-center justify-center font-sans sm:m-0 md:h-[93vh]">
<div class="m-2 flex w-screen flex-col items-center justify-center font-sans sm:m-0 md:min-h-[93vh] dark:bg-black">
<!--
<img
src="/logo-192.png"
@@ -73,8 +74,9 @@
<p class="text-center text-lg dark:text-white">The operating system for classroooms of the future 🚀🚀🚀.</p>
</div>
<Edra />
<form onsubmit={preventDefault(handleSubmit)} class="my-4 hidden">
<div class="flex flex-col items-center sm:flex-row">
<div class="flex hidden flex-col items-center sm:flex-row">
{#if success}
<p class="dark:text-white">You have been added successfully. Thanks for joining.</p>
{:else}
@@ -91,18 +93,18 @@
</div>
</form>
<div class="mt-4 flex flex-col md:flex-row">
<div class="mt-4 flex md:flex-row">
{#each areas as area, index}
<div
class="active hover:border-primary-700 m-3 max-w-[350px] rounded-md border-2 bg-white px-12 py-3 shadow-xl dark:bg-black {animate}"
>
<h3 class="text-3xl dark:text-white">
{#if index === 0}
<DoorOpenIcon size={16} />
<DoorOpenIcon size={30} />
{:else if index === 1}
<ChartAreaIcon size={16} />
<ChartAreaIcon size={30} />
{:else if index === 2}
<BotIcon size={16} />
<BotIcon size={30} />
{/if}
{area.title}
</h3>
@@ -29,8 +29,8 @@
import { LANGUAGES } from '$lib/utils/constants/translation';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types';
import { Dropdown } from 'carbon-components-svelte';
import { COURSE_VERSION, LOCALE, type Lesson, type LessonCompletion } from '$lib/utils/types';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
let { data = $bindable() } = $props();
@@ -248,7 +248,20 @@
</IconButton>
</div>
<Dropdown items={LANGUAGES} bind:selectedId={$lesson.locale} class="h-full" />
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>Open</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
{#each LANGUAGES as lang}
<DropdownMenu.Item onclick={() => ($lesson.locale = lang.id as LOCALE)}>
{lang.text}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</RoleBasedSecurity>
</div>
@@ -137,7 +137,7 @@
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600">
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white">
<h3 class="mt-0 text-2xl font-bold">
{$t('dashboard.top_courses')}
</h3>
@@ -189,7 +189,7 @@
</div>
</div>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600">
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white">
<h3 class="mt-0 text-2xl font-bold">
{$t('dashboard.recent_enrollments')}
</h3>
+7 -2
View File
@@ -12,12 +12,17 @@ const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [vitePreprocess({})],
kit: {
adapter: useNodeAdapter ? adapterNode() : adapterVercel(),
alias: {
$lib: path.resolve('./src/lib'),
$mail: path.resolve('./src/mail')
$mail: path.resolve('./src/mail'),
'$src/tools': path.resolve('../../packages/ui/src/tools/index.ts'),
'$src/base/*': path.resolve('../../packages/ui/src/base/*'),
'@cio/ui': path.resolve('../../packages/ui/src'),
'@cio/ui/*': path.resolve('../../packages/ui/src/*'),
'@cio/api': path.resolve('../api/dist'),
'@cio/api/*': path.resolve('../api/dist/*')
}
}
};
-9
View File
@@ -1,9 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')]
};
+2 -6
View File
@@ -1,7 +1,6 @@
import { defineConfig, loadEnv } from 'vite';
import fs from 'fs';
import path from 'path';
import { sveltekit } from '@sveltejs/kit/vite';
export default ({ mode }) => {
@@ -25,15 +24,12 @@ export default ({ mode }) => {
include: ['@cio/api/rpc-types']
},
resolve: {
mainFields: ['browser'],
alias: {
'@cio/api': path.resolve(__dirname, '../api/dist')
}
mainFields: ['browser']
}
});
};
function getServer(params: any) {
function getServer(params) {
const { VITE_USE_HTTPS_ON_LOCALHOST } = params || {};
if (VITE_USE_HTTPS_ON_LOCALHOST === 'true') {
return {
+6 -3
View File
@@ -11,9 +11,11 @@
"format": "prettier . --write",
"release": "standard-version",
"ci": "cypress run",
"dashboard:dev": "pnpm dev --filter=@cio/dashboard --filter=@cio/ui",
"dashboard:start": "pnpm start --filter @cio/dashboard",
"api:start": "pnpm start --filter @cio/api",
"supabase:push": "pnpx supabase link --project-ref $PROJECT_ID && pnpx supabase db push --include-all"
"supabase:push": "pnpx supabase link --project-ref $PROJECT_ID && pnpx supabase db push --include-all",
"storybook:dev": "pnpm dev --filter=@cio/storybook --filter=@cio/ui"
},
"license": "MIT",
"dependencies": {
@@ -34,7 +36,8 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
"esbuild",
"@tailwindcss/oxide"
]
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/sveltekit';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-svelte-csf'
],
framework: {
name: '@storybook/sveltekit',
options: {}
}
};
export default config;
+16
View File
@@ -0,0 +1,16 @@
import type { Preview } from '@storybook/svelte';
import '../src/index.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
}
}
};
export default preview;
+19
View File
@@ -0,0 +1,19 @@
# @cio/storybook
Storybook for ClassroomIO UI components.
## Development
To start the Storybook development server:
```bash
pnpm dev
```
## Build
To build the Storybook:
```bash
pnpm build
```
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@cio/storybook",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"dev": "storybook dev -p 6006",
"build": "storybook build",
"preview": "storybook preview",
"build-storybook": "storybook build"
},
"dependencies": {
"@cio/ui": "workspace:*"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.4.2",
"@storybook/addon-interactions": "^8.4.2",
"@storybook/addon-links": "^8.4.2",
"@storybook/addon-svelte-csf": "^5.0.6",
"@storybook/blocks": "^8.4.2",
"@storybook/svelte": "^8.4.2",
"@storybook/svelte-vite": "^10.0.2",
"@storybook/sveltekit": "^8.4.2",
"@storybook/test": "^8.4.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
"storybook": "^8.4.2",
"svelte": "^5.39.11",
"tailwindcss": "^4.1.14",
"@cio/tsconfig": "workspace:*",
"typescript-svelte-plugin": "^0.3.50",
"vite": "^7.1.4"
}
}
+12
View File
@@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
@@ -0,0 +1,100 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Button } from '@cio/ui/base/button';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Button',
component: Button,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Button>Default Button</Button>
{/snippet}
</Story>
<Story name="Secondary">
{#snippet template(args)}
<Button {...args} variant="secondary">Secondary Button</Button>
{/snippet}
</Story>
<Story name="Destructive">
{#snippet template(args)}
<Button {...args} variant="destructive">Destructive Button</Button>
{/snippet}
</Story>
<Story name="Outline">
{#snippet template(args)}
<Button {...args} variant="outline">Outline Button</Button>
{/snippet}
</Story>
<Story name="Ghost">
{#snippet template(args)}
<Button {...args} variant="ghost">Ghost Button</Button>
{/snippet}
</Story>
<Story name="Link">
{#snippet template(args)}
<Button {...args} variant="link">Link Button</Button>
{/snippet}
</Story>
<Story name="Small">
{#snippet template(args)}
<Button {...args} size="sm">Small Button</Button>
{/snippet}
</Story>
<Story name="Large">
{#snippet template(args)}
<Button {...args} size="lg">Large Button</Button>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template(args)}
<Button {...args} disabled>Disabled Button</Button>
{/snippet}
</Story>
<Story name="As Link">
{#snippet template(args)}
<Button {...args} href="#">Link Button</Button>
{/snippet}
</Story>
<Story name="All Variants">
{#snippet template()}
<div class="flex flex-wrap gap-4">
<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
{/snippet}
</Story>
<Story name="All Sizes">
{#snippet template()}
<div class="flex items-center gap-4">
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>
</div>
{/snippet}
</Story>
@@ -0,0 +1,4 @@
import { type ComponentProps } from 'svelte';
import { Button } from '@cio/ui/base/button';
export const FIELDS: (keyof ComponentProps<typeof Button>)[] = ['variant', 'size', 'disabled', 'class', 'type', 'href'];
@@ -0,0 +1,117 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { DropdownMenu, Button } from '@cio/ui';
const { Story } = defineMeta({
title: 'Atom/DropdownMenu',
component: DropdownMenu.Root,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Label>My Account</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item>Profile</DropdownMenu.Item>
<DropdownMenu.Item>Billing</DropdownMenu.Item>
<DropdownMenu.Item>Settings</DropdownMenu.Item>
<DropdownMenu.Item>Keyboard shortcuts</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>Team</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Invite users</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Email</DropdownMenu.Item>
<DropdownMenu.Item>Message</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>More...</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item>New Team</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item>GitHub</DropdownMenu.Item>
<DropdownMenu.Item>Support</DropdownMenu.Item>
<DropdownMenu.Item disabled>API</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>Log out</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
<Story name="With Icons">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>
<span>👤 Profile</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<span>💳 Billing</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<span>⚙️ Settings</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<span>🚪 Logout</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
<Story name="Simple Menu">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>Edit</DropdownMenu.Item>
<DropdownMenu.Item>Duplicate</DropdownMenu.Item>
<DropdownMenu.Item>Archive</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600">Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
<Story name="With Checkboxes">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>View Options</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem checked>Status Bar</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem>Activity Bar</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem checked>Panel</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup value="single">
<DropdownMenu.RadioItem value="single">Single</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="group">Group</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
@@ -0,0 +1,14 @@
import { type ComponentProps } from 'svelte';
import { Input } from '@cio/ui/base/input';
export const FIELDS: (keyof ComponentProps<typeof Input>)[] = [
'value',
'type',
'placeholder',
'disabled',
'class',
'id',
'name',
'required',
'readonly'
];
@@ -0,0 +1,66 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Input } from '@cio/ui/base/input';
import { Label } from '@cio/ui/base/label';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Input',
component: Input,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default" args={{ placeholder: 'Enter text...' }} />
<Story name="With Label">
{#snippet template()}
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="email">Email</Label>
<Input type="email" id="email" placeholder="Email" />
</div>
{/snippet}
</Story>
<Story name="Password">
{#snippet template()}
<Input type="password" placeholder="Password" />
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<Input disabled placeholder="Disabled input" />
{/snippet}
</Story>
<Story name="With Error">
{#snippet template()}
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="email-error">Email</Label>
<Input type="email" id="email-error" placeholder="Email" class="border-red-500" />
<p class="text-sm text-red-500">This field is required</p>
</div>
{/snippet}
</Story>
<Story name="File Input">
{#snippet template()}
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="picture">Picture</Label>
<Input id="picture" type="file" />
</div>
{/snippet}
</Story>
<Story name="Search">
{#snippet template()}
<Input type="search" placeholder="Search..." />
{/snippet}
</Story>
@@ -0,0 +1,109 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Popover, Button, Input, Label } from '@cio/ui';
const { Story } = defineMeta({
title: 'Atom/Popover',
component: Popover.Root,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root variant="outline">Open popover</Button.Root>
</Popover.Trigger>
<Popover.Content class="w-80">
<div class="grid gap-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Dimensions</h4>
<p class="text-muted-foreground text-sm">Set the dimensions for the layer.</p>
</div>
<div class="grid gap-2">
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="width">Width</Label.Root>
<Input.Root id="width" value="100%" class="col-span-2 h-8" />
</div>
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="maxWidth">Max. width</Label.Root>
<Input.Root id="maxWidth" value="300px" class="col-span-2 h-8" />
</div>
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="height">Height</Label.Root>
<Input.Root id="height" value="25px" class="col-span-2 h-8" />
</div>
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="maxHeight">Max. height</Label.Root>
<Input.Root id="maxHeight" value="none" class="col-span-2 h-8" />
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
<Story name="Simple">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root>Click me</Button.Root>
</Popover.Trigger>
<Popover.Content>
<p class="text-sm">This is a simple popover with some text content.</p>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
<Story name="With Form">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root variant="outline">Settings</Button.Root>
</Popover.Trigger>
<Popover.Content class="w-80">
<div class="grid gap-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Settings</h4>
<p class="text-muted-foreground text-sm">Manage your account settings.</p>
</div>
<div class="grid gap-2">
<div class="grid gap-1">
<Label.Root for="name">Display name</Label.Root>
<Input.Root id="name" value="Pedro Duarte" />
</div>
<div class="grid gap-1">
<Label.Root for="username">Username</Label.Root>
<Input.Root id="username" value="@peduarte" />
</div>
</div>
<Button.Root size="sm" class="w-full">Save changes</Button.Root>
</div>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
<Story name="Information">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root variant="ghost" size="sm">ℹ️ Info</Button.Root>
</Popover.Trigger>
<Popover.Content class="w-56">
<div class="space-y-2">
<h4 class="font-medium">About this feature</h4>
<p class="text-muted-foreground text-sm">
This feature allows you to quickly access important information without leaving the current page.
</p>
</div>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
@@ -0,0 +1,4 @@
import { type ComponentProps } from 'svelte';
import { Skeleton } from '@cio/ui/base/skeleton';
export const FIELDS: (keyof ComponentProps<typeof Skeleton>)[] = ['class'];
@@ -0,0 +1,77 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Skeleton } from '@cio/ui/base/skeleton';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Skeleton',
component: Skeleton,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template(args)}
<Skeleton {...args} class="h-4 w-[250px]" />
{/snippet}
</Story>
<Story name="Circle">
{#snippet template(args)}
<Skeleton {...args} class="h-12 w-12 rounded-full" />
{/snippet}
</Story>
<Story name="Card">
{#snippet template()}
<div class="flex items-center space-x-4">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
{/snippet}
</Story>
<Story name="Multiple Lines">
{#snippet template()}
<div class="space-y-2">
<Skeleton class="h-4 w-[350px]" />
<Skeleton class="h-4 w-[300px]" />
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
{/snippet}
</Story>
<Story name="Button">
{#snippet template(args)}
<Skeleton {...args} class="h-10 w-20" />
{/snippet}
</Story>
<Story name="Loading State">
{#snippet template()}
<div class="space-y-4 rounded-lg border p-4">
<div class="flex items-center space-x-4">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[200px]" />
<Skeleton class="h-4 w-40" />
</div>
</div>
<Skeleton class="h-[200px] w-full" />
<div class="flex space-x-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</div>
{/snippet}
</Story>
@@ -0,0 +1,14 @@
<script>
import { Tooltip, Button } from '@cio/ui';
</script>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button.Root variant="outline">Hover me</Button.Root>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Add to library</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,130 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Tooltip, Provider, Trigger, Content } from '@cio/ui/base/tooltip';
import { Button } from '@cio/ui/base/button';
// import { Arro}
const { Story } = defineMeta({
title: 'Atom/Tooltip',
component: Provider,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="outline">Hover me</Button>
</Trigger>
<Content>
<p>Add to library</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="With Arrow">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button>Hover for tooltip</Button>
</Trigger>
<Content>
<p>This tooltip has an arrow pointing to the trigger</p>
<!-- <Arrow /> -->
=>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="Simple Text">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="ghost" size="sm">❓</Button>
</Trigger>
<Content>
<p>Help information</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="Multiple Tooltips">
{#snippet template()}
<Provider>
<div class="flex gap-4">
<Tooltip>
<Trigger>
<Button variant="outline">Save</Button>
</Trigger>
<Content>
<p>Save your work (Ctrl+S)</p>
</Content>
</Tooltip>
<Tooltip>
<Trigger>
<Button variant="outline">Edit</Button>
</Trigger>
<Content>
<p>Edit this item</p>
</Content>
</Tooltip>
<Tooltip>
<Trigger>
<Button variant="destructive">Delete</Button>
</Trigger>
<Content>
<p>Permanently delete this item</p>
</Content>
</Tooltip>
</div>
</Provider>
{/snippet}
</Story>
<Story name="Icon Button">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="ghost" size="sm">🔧</Button>
</Trigger>
<Content>
<p>Settings</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="Long Content">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="outline">Info</Button>
</Trigger>
<Content class="max-w-xs">
<p>
This is a longer tooltip with more detailed information that might span multiple lines to provide
comprehensive context about the feature or action.
</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import '@cio/ui/output.css';
@@ -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!' }]
}
]
}
]
}
]
};
@@ -0,0 +1,82 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Editor } from '@cio/ui/custom/editor';
import { FIELDS, DEFAULT_CONTENT } from './constants';
const { Story } = defineMeta({
title: 'Molecules/Editor',
component: Editor,
parameters: {
layout: 'padded',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Editor />
{/snippet}
</Story>
<Story name="With Sample Content">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} />
{/snippet}
</Story>
<Story name="Read Only">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} editable={false} />
{/snippet}
</Story>
<Story name="Without Toolbar">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} showToolBar={false} />
{/snippet}
</Story>
<Story name="With Persistence">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} enablePersistence={true} contentStorageKey="storybook-editor-content" />
{/snippet}
</Story>
<Story name="Custom Styling">
{#snippet template()}
<Editor
content={DEFAULT_CONTENT}
class="mx-auto max-w-4xl rounded-lg border-2 border-blue-300"
editorClass="h-64 p-4 bg-gray-50"
/>
{/snippet}
</Story>
<Story name="With Callbacks">
{#snippet template()}
<Editor
content={DEFAULT_CONTENT}
onContentChange={(content) => console.log('Content changed:', content)}
onEditorReady={(editor) => console.log('Editor ready:', editor)}
/>
{/snippet}
</Story>
<Story name="Multiple Editors">
{#snippet template()}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<h3 class="mb-2 text-lg font-semibold">Article</h3>
<Editor content={DEFAULT_CONTENT} contentStorageKey="article-content" enablePersistence={true} />
</div>
<div>
<h3 class="mb-2 text-lg font-semibold">Notes</h3>
<Editor showToolBar={false} editorClass="h-48" contentStorageKey="notes-content" enablePersistence={true} />
</div>
</div>
{/snippet}
</Story>
+16
View File
@@ -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;
+36
View File
@@ -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"]
}
+22
View File
@@ -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']
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cio/tsconfig",
"version": "0.0.0",
"version": "0.0.1",
"private": true,
"license": "MIT",
"publishConfig": {
+15
View File
@@ -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"
}
+56
View File
@@ -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"
}
}
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -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,
};
@@ -0,0 +1,21 @@
<script lang="ts">
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { resetMode, setMode } from 'mode-watcher';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { buttonVariants } from '$src/base/button';
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'outline', size: 'icon' })}>
<SunIcon class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 !transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 !transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => setMode('light')}>Light</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('dark')}>Dark</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => resetMode()}>System</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
+3
View File
@@ -0,0 +1,3 @@
export { default as ModeSwitcher } from './dark-mode.svelte';
export * from 'mode-watcher';
@@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '../../tools';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '../../tools';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none',
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { cn } from '../../tools';
import type { ComponentProps } from 'svelte';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '../../tools';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChild } from '../../tools';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,13 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let { ref = $bindable(null), class: className, ...restProps }: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let { ref = $bindable(null), class: className, ...restProps }: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '../../tools';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -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,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, 'type'> & ({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
'data-slot': dataSlot = 'input',
...restProps
}: Props = $props();
</script>
{#if type === 'file'}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$src/tools/index.js';
let { ref = $bindable(null), class: className, ...restProps }: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
'flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
className
)}
{...restProps}
/>
+17
View File
@@ -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,
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '../../tools';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
@@ -0,0 +1,8 @@
<script lang="ts">
import { cn } from '../../tools';
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), class: className, ...restProps }: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger bind:ref data-slot="popover-trigger" class={cn('', className)} {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn('bg-accent animate-pulse rounded-md', className)}
{...restProps}
></div>
+21
View File
@@ -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,
};
@@ -0,0 +1,47 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = 'top',
children,
arrowClasses,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
} = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs',
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
+242
View File
@@ -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
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
let content: Content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }]
}
]
};
</script>
<Editor bind:content />
```
## Using HTML Content
You can also initialize the editor with HTML content by converting it to the appropriate Content format:
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
// Example with HTML content
let htmlContent = `
<h1>Welcome to the Editor</h1>
<p>This is a <strong>paragraph</strong> with <em>formatting</em>.</p>
<ul>
<li>First item</li>
<li>Second item with <a href="https://example.com">a link</a></li>
</ul>
<blockquote>
<p>This is a quote block</p>
</blockquote>
`;
// The editor will automatically parse HTML content
let content: Content = htmlContent;
</script>
<Editor bind:content />
```
You can also set HTML content programmatically:
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, Editor as TiptapEditor } from '@tiptap/core';
let content: Content = { type: 'doc', content: [] };
let editor: TiptapEditor;
function loadHtmlContent() {
const htmlString = `
<h2>Dynamically Loaded Content</h2>
<p>This content was loaded after the editor was ready.</p>
<code>console.log('Hello, world!');</code>
`;
if (editor) {
editor.commands.setContent(htmlString);
}
}
function handleEditorReady(editorInstance: TiptapEditor) {
editor = editorInstance;
}
</script>
<Editor
bind:content
onEditorReady={handleEditorReady}
/>
<button on:click={loadHtmlContent}>
Load HTML Content
</button>
```
## Advanced Usage with All Props
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, Editor as TiptapEditor } from '@tiptap/core';
let content: Content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }]
}
]
};
let editable = true;
let showToolBar = true;
function handleContentChange(newContent: Content) {
console.log('Content changed:', newContent);
// Handle content changes
}
function handleEditorReady(editor: TiptapEditor) {
console.log('Editor ready:', editor);
// Handle editor ready
}
</script>
<Editor
bind:content
bind:editable
{showToolBar}
enablePersistence={true}
contentStorageKey="my-app-content"
editableStorageKey="my-app-editable"
class="custom-editor-wrapper"
editorClass="custom-editor h-96"
onContentChange={handleContentChange}
onEditorReady={handleEditorReady}
/>
```
## Multiple Editors
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
let article: Content = { type: 'doc', content: [] };
let notes: Content = { type: 'doc', content: [] };
</script>
<div class="grid grid-cols-2 gap-4">
<div>
<h2>Article</h2>
<Editor
bind:content={article}
contentStorageKey="article-content"
enablePersistence={true}
/>
</div>
<div>
<h2>Notes</h2>
<Editor
bind:content={notes}
showToolBar={false}
contentStorageKey="notes-content"
enablePersistence={true}
editorClass="h-48"
/>
</div>
</div>
```
## 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
<Editor
content={myContent}
onContentChange={(content) => {
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
<script>
import { Editor } from './editor.svelte';
// No props needed, everything was internal
</script>
<Editor />
```
### After (prop-based)
```svelte
<script>
import { Editor } from './editor.svelte';
import type { Content } from '@tiptap/core';
let content: Content = { type: 'doc', content: [] };
</script>
<Editor bind:content />
```
## 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
@@ -0,0 +1 @@
export { default as ToolBarCommands } from './toolbar-commands';
@@ -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<string, EdraToolBarCommands[]> = {
'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;
@@ -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;
}
@@ -0,0 +1,67 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { BubbleMenuPlugin, type BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu';
import type { Editor } from '@tiptap/core';
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
interface Props
extends Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> {
editor?: Editor;
children?: Snippet<[]>;
class?: string;
style?: string;
pluginKey?: string;
updateDelay?: number;
resizeDelay?: number;
}
let {
editor,
shouldShow = null,
class: className = '',
style = '',
children,
updateDelay,
resizeDelay,
pluginKey = 'bubbleMenu',
options,
...restProps
}: Props = $props();
let element = $state<HTMLElement>();
onMount(() => {
if (!element) return;
element.style.position = 'absolute';
element.style.visibility = 'hidden';
if (!editor || editor.isDestroyed) {
console.warn('BubbleMenu component does not have editor prop or editor is destroyed.');
return;
}
const plugin = BubbleMenuPlugin({
pluginKey,
editor,
element,
shouldShow,
updateDelay,
resizeDelay,
options
});
editor.registerPlugin(plugin);
return () => {
if (editor && !editor.isDestroyed) {
editor.unregisterPlugin(pluginKey);
}
};
});
</script>
<div bind:this={element} class={`bubble-menu-wrapper ${className}`} {style} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import GripVertical from '@lucide/svelte/icons/grip-vertical';
import { DragHandlePlugin } from '../extensions/drag-handle';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const pluginKey = 'globalDragHandle';
onMount(() => {
const plugin = DragHandlePlugin({
pluginKey: pluginKey,
dragHandleWidth: 20,
scrollTreshold: 100,
dragHandleSelector: '.drag-handle',
excludedTags: ['pre', 'code', 'table p'],
customNodes: []
});
editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
});
</script>
<div class="drag-handle">
<GripVertical />
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Icon } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
interface Props {
icon?: typeof Icon;
title?: string;
onClick?: () => void;
class?: string;
children?: Snippet<[]>;
}
const { icon, title, onClick, class: className = '', children }: Props = $props();
</script>
<NodeViewWrapper
as="div"
contenteditable="false"
class={`media-placeholder ${className}`}
onclick={onClick}
style="user-select: none;"
>
{#if !children && icon && title}
{@const Icon = icon}
<Icon />
<div contenteditable="false">{title}</div>
{/if}
{@render children?.()}
</NodeViewWrapper>
+454
View File
@@ -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;
}
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import type { Content, Editor } from '@tiptap/core';
import type { Transaction } from '@tiptap/pm/state';
import { EdraEditor, EdraToolBar, EdraBubbleMenu, EdraDragHandleExtended } from './ui';
import { slide } from 'svelte/transition';
import { cn } from '$src/tools';
interface Props {
// Content of the editor
content?: Content;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onContentChange?: (content: Content) => void;
onEditorReady?: (editor: Editor) => void;
onEditorDestroy?: () => void;
}
let {
content = $bindable(getDefaultContent()),
showToolBar = true,
editable = true,
enablePersistence = false,
contentStorageKey = 'edra-content',
editableStorageKey = 'edra-editable',
class: className = '',
editorClass = '',
onContentChange,
onEditorReady,
onEditorDestroy
}: Props = $props();
let editor = $state<Editor>();
// Browser detection
const browser = typeof window !== 'undefined';
// Default content fallback
function getDefaultContent(): Content {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Start typing...'
}
]
}
]
};
}
// Handle content persistence
$effect(() => {
if (enablePersistence && browser && content) {
localStorage.setItem(contentStorageKey, JSON.stringify(content));
}
});
// Handle editable state persistence
$effect(() => {
if (enablePersistence && browser) {
localStorage.setItem(editableStorageKey, editable.toString());
}
});
// Load persisted content and editable state on mount
$effect(() => {
if (enablePersistence && browser) {
try {
// Load content
const rawContentString = localStorage.getItem(contentStorageKey);
if (rawContentString !== null) {
const persistedContent: Content = JSON.parse(rawContentString);
console.log('persistedContent', persistedContent);
content = persistedContent;
}
// Load editable state
const rawEditableString = localStorage.getItem(editableStorageKey);
if (rawEditableString !== null) {
editable = rawEditableString === 'true';
}
} catch (error) {
console.warn('Failed to load persisted state:', error);
}
}
});
// Handle editor ready
$effect(() => {
if (editor) {
onEditorReady?.(editor);
}
});
function onUpdate(props: { editor: Editor; transaction: Transaction }) {
if (props?.editor && !props.editor.isDestroyed) {
const newContent = props.editor.getJSON();
content = newContent;
onContentChange?.(newContent);
}
}
</script>
{#if browser}
<div
class={cn(
'bg-background z-50 mx-auto mt-12 flex size-full w-[95%] max-w-5xl flex-col rounded-md border border-dashed sm:w-[85%]',
className
)}
>
{#if editor && !editor.isDestroyed}
{#if showToolBar}
<div transition:slide>
<EdraToolBar
class="bg-secondary/50 flex w-full items-center overflow-x-auto border-b border-dashed p-0.5"
{editor}
/>
</div>
{/if}
<EdraBubbleMenu {editor} />
<EdraDragHandleExtended {editor} />
{/if}
<EdraEditor
class={cn('h-[32rem] overflow-auto p-4', editorClass)}
bind:editor
{editable}
content={content || getDefaultContent()}
{onUpdate}
/>
</div>
{/if}
+110
View File
@@ -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<EditorOptions>
) => {
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;
};
@@ -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);
}
}
})
];
}
});
@@ -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<ReturnType> {
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<SearchAndReplaceOptions, SearchAndReplaceStorage>({
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;
@@ -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<NodeViewProps>) =>
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);
}
});
@@ -0,0 +1,147 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
export interface AudioOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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<AudioOptions>({
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(
`<audio controls autoplay="false" style="width: 100%;" src="${src}"></audio>`
),
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;
}
}
}
})
];
}
});
@@ -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<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
audioPlaceholder: {
/**
* Inserts an audio placeholder
*/
insertAudioPlaceholder: () => ReturnType;
};
}
}
export const AudioPlaceholder = (
component: Component<NodeViewProps>
): Node<AudioPlaceholderOptions> =>
Node.create<AudioPlaceholderOptions>({
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'
});
}
};
}
});

Some files were not shown because too many files have changed in this diff Show More