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 <tvnji01@gmail.com>

* 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 <tvnji01@gmail.com>
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
+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'
});
}
};
}
});
@@ -0,0 +1,28 @@
import { Slice } from '@tiptap/pm/model';
import { EditorView } from '@tiptap/pm/view';
import * as pmView from '@tiptap/pm/view';
function getPmView() {
try {
return pmView;
} catch (error) {
console.error(error);
return null;
}
}
export function serializeForClipboard(view: EditorView, slice: Slice) {
// Newer Tiptap/ProseMirror
if (view && typeof view.serializeForClipboard === 'function') {
return view.serializeForClipboard(slice);
}
// Older version fallback
const proseMirrorView = getPmView();
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
return proseMirrorView.__serializeForClipboard(view, slice);
}
throw new Error('No supported clipboard serialization method found.');
}
@@ -0,0 +1,374 @@
import { Fragment, Node, Slice } from '@tiptap/pm/model';
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import { Extension } from '@tiptap/core';
import { serializeForClipboard } from './ClipboardSerializer';
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollTreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
/**
* Tags to be excluded for drag handle
*/
excludedTags: string[];
/**
* Custom nodes to be included for drag handle
*/
customNodes: string[];
/**
* onNodeChange callback for drag handle
* @param data
* @returns
*/
onMouseMove?: (data: { node: Node; pos: number }) => void;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== 'none') {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width
};
}
return {
top: data.top,
left: data.left,
width: data.width
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
const selectors = [
'li',
'p:not(:first-child)',
'pre',
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
...options.customNodes.map((node) => `[data-type=${node}]`)
].join(', ');
return document
.elementsFromPoint(coords.x, coords.y)
.find((elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors));
}
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
let listType = '';
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY
},
options
);
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
let selection = view.state.selection;
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === 'tableRow'
) {
const $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
}
}
view.dispatch(view.state.tr.setSelection(selection));
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === 'listItem') {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/html', dom.innerHTML);
event.dataTransfer.setData('text/plain', text);
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add('hide');
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove('hide');
}
}
function hideHandleOnEditorOut(event: MouseEvent) {
if (event.target instanceof Element) {
// Check if the relatedTarget class is still inside the editor
const relatedTarget = event.relatedTarget as HTMLElement;
const isInsideEditor =
relatedTarget?.classList.contains('tiptap') || relatedTarget?.classList.contains('drag-handle');
if (isInsideEditor) return;
}
hideDragHandle();
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement('div');
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = '';
dragHandleElement.classList.add('drag-handle');
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
const scrollY = window.scrollY;
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
}
}
dragHandleElement.addEventListener('drag', onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
dragHandleElement = null;
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
}
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY
},
options
);
const notDragging = node?.closest('.not-draggable');
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
hideDragHandle();
return;
}
const nodePos = nodePosAtDOM(node, view, options);
if (nodePos !== undefined) {
const currentNode = view.state.doc.nodeAt(nodePos);
if (currentNode !== null) {
options.onMouseMove?.({ node: currentNode, pos: nodePos });
}
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight) ? parseInt(compStyle.fontSize) * 1.2 : parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add('dragging');
},
drop: (view, event) => {
view.dom.classList.remove('dragging');
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === 'listItem' &&
!isDroppedInsideList &&
listType == 'OL'
) {
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove('dragging');
}
}
}
});
}
const GlobalDragHandle = Extension.create({
name: 'globalDragHandle',
addOptions() {
return {
dragHandleWidth: 20,
scrollTreshold: 100,
excludedTags: [],
customNodes: []
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: 'globalDragHandle',
dragHandleWidth: this.options.dragHandleWidth,
scrollTreshold: this.options.scrollTreshold,
dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags,
customNodes: this.options.customNodes,
onMouseMove: this.options.onMouseMove
})
];
}
});
export default GlobalDragHandle;
@@ -0,0 +1,85 @@
import { Node } from '@tiptap/core';
export interface IframeOptions {
allowFullscreen: boolean;
HTMLAttributes: {
[key: string]: unknown;
};
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
/**
* Add an iframe with src
*/
setIframe: (options: { src: string }) => ReturnType;
removeIframe: () => ReturnType;
};
}
}
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper'
}
};
},
addAttributes() {
return {
src: {
default: null
},
frameborder: {
default: 0
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
}
};
},
parseHTML() {
return [
{
tag: 'iframe'
}
];
},
renderHTML({ HTMLAttributes }) {
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create(options);
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
},
removeIframe:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
}
});
@@ -0,0 +1,34 @@
import type { Component } from 'svelte';
import IFrame from './IFrame';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export const IFrameExtended = (content: Component<NodeViewProps>) =>
IFrame.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});
@@ -0,0 +1,56 @@
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface IFramePlaceholderOptions {
HTMLAttributes: Record<string, object>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframePlaceholder: {
/**
* Inserts a IFrame placeholder
*/
insertIFramePlaceholder: () => ReturnType;
};
}
}
export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
Node.create<IFramePlaceholderOptions>({
name: 'iframe-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
},
addCommands() {
return {
insertIFramePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'iframe-placeholder'
});
}
};
}
});
@@ -0,0 +1,36 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import Image, { type ImageOptions } from '@tiptap/extension-image';
import type { Component } from 'svelte';
import type { NodeViewProps, Node } from '@tiptap/core';
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
return Image.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(component);
}
}).configure({
allowBase64: true
});
};
@@ -0,0 +1,64 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface ImagePlaceholderOptions {
HTMLAttributes: Record<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> {
imagePlaceholder: {
/**
* Inserts an image placeholder
*/
insertImagePlaceholder: () => ReturnType;
};
}
}
export const ImagePlaceholder = (
component: Component<NodeViewProps>
): Node<ImagePlaceholderOptions> =>
Node.create<ImagePlaceholderOptions>({
name: 'image-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
},
addCommands() {
return {
insertImagePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'image-placeholder'
});
}
};
}
});
@@ -0,0 +1,57 @@
import type { Editor } from '@tiptap/core';
import type { EdraToolBarCommands } from '../../commands/types';
import Minus from '@lucide/svelte/icons/minus';
import Quote from '@lucide/svelte/icons/quote';
import SquareCode from '@lucide/svelte/icons/square-code';
import commands from '../../commands/toolbar-commands';
export interface Group {
name: string;
title: string;
actions: EdraToolBarCommands[];
}
export const GROUPS: Group[] = [
{
name: 'format',
title: 'Format',
actions: [
...commands.headings,
{
icon: Quote,
name: 'blockquote',
tooltip: 'Blockquote',
onClick: (editor: Editor) => {
editor.chain().focus().setBlockquote().run();
}
},
{
icon: SquareCode,
name: 'codeBlock',
tooltip: 'Code Block',
onClick: (editor: Editor) => {
editor.chain().focus().setCodeBlock().run();
}
},
...commands.lists
]
},
{
name: 'insert',
title: 'Insert',
actions: [
...commands.media,
...commands.table,
{
icon: Minus,
name: 'horizontalRule',
tooltip: 'Horizontal Rule',
onClick: (editor: Editor) => {
editor.chain().focus().setHorizontalRule().run();
}
}
]
}
];
export default GROUPS;
@@ -0,0 +1,255 @@
import { Editor, Extension } from '@tiptap/core';
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
import { PluginKey } from '@tiptap/pm/state';
import { computePosition, flip, offset, autoUpdate, type Placement } from '@floating-ui/dom';
import { GROUPS } from './groups';
import SvelteRenderer from '../../svelte-renderer';
import type { Component } from 'svelte';
const extensionName = 'slashCommand';
interface PopupState {
element: HTMLElement | null;
cleanup: (() => void) | null;
isVisible: boolean;
}
const popup: PopupState = {
element: null,
cleanup: null,
isVisible: false
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default (menuList: Component<any, any, ''>): Extension =>
Extension.create({
name: extensionName,
priority: 200,
onCreate() {
// Create popup container
popup.element = document.createElement('div');
popup.element.style.position = 'fixed';
popup.element.style.zIndex = '9999';
popup.element.style.maxWidth = '16rem';
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.element.className = 'slash-command-popup';
document.body.appendChild(popup.element);
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '/',
allowSpaces: true,
pluginKey: new PluginKey(extensionName),
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf('/'));
const isValidAfterContent = !afterContent?.endsWith(' ');
return isValidAfterContent;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
command: ({ editor, props }: { editor: Editor; props: any }) => {
const { view, state } = editor;
const { $head, $from } = view.state.selection;
try {
const end = $from.pos;
const from = $head?.nodeBefore
? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ?? 0)
: $from.start();
const tr = state.tr.deleteRange(from, end);
view.dispatch(tr);
} catch (error) {
console.error(error);
}
props.onClick(editor);
view.focus();
},
items: ({ query }: { query: string }) => {
const withFilteredCommands = GROUPS.map((group) => ({
...group,
commands: group.actions.filter((item) => {
const labelNormalized = item.tooltip!.toLowerCase().trim();
const queryNormalized = query.toLowerCase().trim();
return labelNormalized.includes(queryNormalized);
})
}));
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
if (group.commands.length > 0) {
return true;
}
return false;
});
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
...group,
commands: group.commands.map((command) => ({
...command,
isEnabled: true
}))
}));
return withEnabledSettings;
},
render: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let component: any;
let scrollHandler: (() => void) | null = null;
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(menuList, {
props,
editor: props.editor
});
const { view } = props.editor;
if (popup.element) {
popup.element.appendChild(component.element);
popup.element.style.visibility = 'visible';
popup.element.style.pointerEvents = 'auto';
popup.isVisible = true;
const updatePosition = () => {
if (!popup.element || !props.clientRect) return;
const rect = props.clientRect();
if (!rect) return;
const referenceElement = {
getBoundingClientRect: () => rect
};
computePosition(referenceElement, popup.element, {
placement: 'bottom-start' as Placement,
middleware: [
offset({ mainAxis: 8, crossAxis: 16 }),
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
]
}).then(({ x, y }) => {
if (popup.element) {
popup.element.style.left = `${x}px`;
popup.element.style.top = `${y}px`;
}
});
};
updatePosition();
// Set up auto-update for scroll events
if (props.clientRect) {
const referenceElement = {
getBoundingClientRect: () => props.clientRect?.() || new DOMRect()
};
popup.cleanup = autoUpdate(referenceElement, popup.element, updatePosition);
}
scrollHandler = updatePosition;
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
}
},
onUpdate(props: SuggestionProps) {
component.updateProps(props);
if (popup.element && popup.isVisible && props.clientRect) {
const rect = props.clientRect();
if (rect) {
const referenceElement = {
getBoundingClientRect: () => rect
};
computePosition(referenceElement, popup.element, {
placement: 'bottom-start' as Placement,
middleware: [
offset({ mainAxis: 8, crossAxis: 16 }),
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
]
}).then(({ x, y }) => {
if (popup.element) {
popup.element.style.left = `${x}px`;
popup.element.style.top = `${y}px`;
}
});
props.editor.storage[extensionName].rect = rect;
}
}
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
if (popup.element) {
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.isVisible = false;
}
return true;
}
if (!popup.isVisible && popup.element) {
popup.element.style.visibility = 'visible';
popup.element.style.pointerEvents = 'auto';
popup.isVisible = true;
}
if (props.event.key === 'Enter') return true;
// return component.ref?.onKeyDown(props);
return false;
},
onExit(props) {
if (popup.element) {
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.element.innerHTML = '';
popup.isVisible = false;
}
if (popup.cleanup) {
popup.cleanup();
popup.cleanup = null;
}
if (scrollHandler) {
const { view } = props.editor;
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
scrollHandler = null;
}
component.destroy();
}
};
}
})
];
},
addStorage() {
return {
rect: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0
}
};
}
});
@@ -0,0 +1,4 @@
export { Table } from './table';
export { TableCell } from './table-cell';
export { TableRow } from './table-row';
export { TableHeader } from './table-header';
@@ -0,0 +1,124 @@
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node, mergeAttributes } from '@tiptap/core';
import { getCellsInColumn, isRowSelected, selectRow } from './utils';
import { Plugin } from '@tiptap/pm/state';
export interface TableCellOptions {
HTMLAttributes: Record<string, unknown>;
}
export const TableCell = Node.create<TableCellOptions>({
name: 'tableCell',
content: 'block+',
tableRole: 'cell',
isolating: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
parseHTML() {
return [{ tag: 'td' }];
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addAttributes() {
return {
colspan: {
default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute('colspan');
const value = colspan ? parseInt(colspan, 10) : 1;
return value;
}
},
rowspan: {
default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute('rowspan');
const value = rowspan ? parseInt(rowspan, 10) : 1;
return value;
}
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value;
}
},
style: {
default: null
}
};
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection);
let className = 'grip-row';
if (rowSelected) {
className += ' selected';
}
if (index === 0) {
className += ' first';
}
if (index === cells.length - 1) {
className += ' last';
}
const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
}
}
})
];
}
});
@@ -0,0 +1,89 @@
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { getCellsInRow, isColumnSelected, selectColumn } from './utils';
import { Plugin } from '@tiptap/pm/state';
import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table';
export const TableHeader = TiptapTableHeader.extend({
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
return value;
}
},
style: {
default: null
}
};
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(selection);
let className = 'grip-column';
if (colSelected) {
className += ' selected';
}
if (index === 0) {
className += ' first';
}
if (index === cells.length - 1) {
className += ' last';
}
const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
}
}
})
];
}
});
export default TableHeader;
@@ -0,0 +1,8 @@
import { TableRow as TiptapTableRow } from '@tiptap/extension-table';
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: '(tableCell | tableHeader)*'
});
export default TableRow;
@@ -0,0 +1,9 @@
import { Table as TiptapTable } from '@tiptap/extension-table';
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true
});
export default Table;
@@ -0,0 +1,313 @@
import { Editor, findParentNode } from '@tiptap/core';
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
import { Node, ResolvedPos } from '@tiptap/pm/model';
import type { EditorView } from '@tiptap/pm/view';
import Table from './table';
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
);
for (let i = 0, count = cells.length; i < count; i += 1) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
}
}
return true;
};
export const findTable = (selection: Selection) =>
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(selection);
export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection;
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height
})(selection);
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1
})(selection);
}
return false;
};
export const isTableSelected = (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height
})(selection);
}
return false;
};
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height
});
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
});
}
return null;
};
export const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: Node) => boolean) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node
};
}
}
return null;
};
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === 'row';
if (table) {
const map = TableMap.get(table.node);
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom
});
const cellsInLastRow =
bottom - top === 1
? cellsInFirstRow
: map.cellsInRect({
left: isRowSelection ? left : right - 1,
top: isRowSelection ? bottom - 1 : top,
right,
bottom
});
const head = table.start + cellsInFirstRow[0];
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select('column');
export const selectRow = select('row');
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const isColumnGripSelected = ({
editor,
view,
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected');
return !!gripColumn;
};
export const isRowGripSelected = ({
editor,
view,
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected');
return !!gripRow;
};
@@ -0,0 +1,34 @@
import type { Component } from 'svelte';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Video } from './VideoExtension';
export const VideoExtended = (content: Component<NodeViewProps>) =>
Video.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});
@@ -0,0 +1,147 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
export interface VideoOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
/**
* Set a video node
*/
setVideo: (src: string) => ReturnType;
/**
* Toggle a video
*/
toggleVideo: (src: string) => ReturnType;
/**
* Remove a video
*/
removeVideo: () => ReturnType;
};
}
}
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
content: 'inline*',
draggable: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
},
parseHTML() {
return [
{
tag: 'video',
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
}
];
},
renderHTML({ HTMLAttributes }) {
return [
'video',
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
['source', HTMLAttributes]
];
},
addCommands() {
return {
setVideo:
(src: string) =>
({ commands }) =>
commands.insertContent(
`<video controls="true" autoplay="false" style="width: fit-content" src="${src}" />`
),
toggleVideo:
() =>
({ commands }) =>
commands.toggleNode(this.name, 'paragraph'),
removeVideo:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
},
addInputRules() {
return [
nodeInputRule({
find: VIDEO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
return { src };
}
})
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('videoDropPlugin'),
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 videos = Array.from(event.dataTransfer.files).filter((file) =>
/video/i.test(file.type)
);
if (videos.length === 0) return false;
event.preventDefault();
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
videos.forEach((video) => {
const reader = new FileReader();
reader.onload = (readerEvent) => {
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
dispatch(transaction);
}
};
reader.readAsDataURL(video);
});
return true;
}
}
}
})
];
}
});
@@ -0,0 +1,62 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface VideoPlaceholderOptions {
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> {
videoPlaceholder: {
/**
* Inserts a video placeholder
*/
insertVideoPlaceholder: () => ReturnType;
};
}
}
export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
Node.create<VideoPlaceholderOptions>({
name: 'video-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
},
addCommands() {
return {
insertVideoPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'video-placeholder'
});
}
};
}
});
+1
View File
@@ -0,0 +1 @@
export { default as Editor } from './editor.svelte';
+176
View File
@@ -0,0 +1,176 @@
/* One Dark and Light Theme for Highlight.js using Pure CSS */
/* Light Theme (Default) */
.tiptap pre code {
color: #383a42;
}
/* Comment */
.hljs-comment,
.hljs-quote {
font-style: italic;
color: #a0a1a7;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #e45649;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #986801;
}
/* Yellow */
.hljs-attribute {
color: #c18401;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #50a14f;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #4078f2;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #a626a4;
}
/* Cyan */
.hljs-emphasis {
font-style: italic;
color: #0184bc;
}
.hljs-strong {
font-weight: bold;
}
/* Base styles */
.hljs-doctag,
.hljs-formula {
color: #a626a4;
}
.hljs-attr,
.hljs-subst {
color: #383a42;
}
/* Line highlights */
.hljs-addition {
background-color: #e6ffed;
}
.hljs-deletion {
background-color: #ffeef0;
}
/* Dark Theme (All dark styles consolidated in one media query) */
html.dark {
.tiptap pre code {
color: #abb2bf;
}
/* Comment */
.hljs-comment,
.hljs-quote {
color: #5c6370;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #e06c75;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #d19a66;
}
/* Yellow */
.hljs-attribute {
color: #e5c07b;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #98c379;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #61afef;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #c678dd;
}
/* Cyan */
.hljs-emphasis {
color: #56b6c2;
}
/* Base styles */
.hljs-doctag,
.hljs-formula {
color: #c678dd;
}
.hljs-attr,
.hljs-subst {
color: #abb2bf;
}
/* Line highlights */
.hljs-addition {
background-color: #283428;
}
.hljs-deletion {
background-color: #342828;
}
}
@@ -0,0 +1,75 @@
import { flushSync, mount, unmount } from 'svelte';
import type { Editor, NodeViewProps } from '@tiptap/core';
interface RendererOptions<P extends Record<string, unknown>> {
editor: Editor;
props: P;
}
type App = ReturnType<typeof mount>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SvelteRenderer<R = unknown, P extends Record<string, any> = object> {
id: string;
component: App;
editor: Editor;
props: P;
element: HTMLElement;
ref: R | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mnt: Record<any, any> | null = null;
constructor(component: App, { props, editor }: RendererOptions<P>) {
this.id = Math.floor(Math.random() * 0xffffffff).toString();
this.component = component;
this.props = props;
this.editor = editor;
this.element = document.createElement('div');
this.element.classList.add('svelte-renderer');
if (this.editor.isInitialized) {
// On first render, we need to flush the render synchronously
// Renders afterwards can be async, but this fixes a cursor positioning issue
flushSync(() => {
this.render();
});
} else {
this.render();
}
}
render(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.mnt = mount(this.component as any, {
target: this.element,
props: {
props: this.props
}
});
}
updateProps(props: Partial<NodeViewProps>): void {
Object.assign(this.props, props);
this.destroy();
this.render();
}
updateAttributes(attributes: Record<string, string>): void {
Object.keys(attributes).forEach((key) => {
this.element.setAttribute(key, attributes[key]);
});
this.destroy();
this.render();
}
destroy(): void {
if (this.mnt) {
unmount(this.mnt);
} else {
unmount(this.component);
}
}
}
export default SvelteRenderer;
+54
View File
@@ -0,0 +1,54 @@
import type { Content, Editor } from '@tiptap/core';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Snippet } from 'svelte';
export interface EdraEditorProps {
content?: Content;
editable?: boolean;
editor?: Editor;
autofocus?: boolean;
onUpdate?: (args: { editor: Editor; transaction: Transaction }) => void;
class?: string;
}
export interface EditorProps {
// 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;
}
export interface EdraToolbarProps {
editor: Editor;
class?: string;
excludedCommands?: string[];
children?: Snippet<[]>;
}
export interface ShouldShowProps {
editor: Editor;
element: HTMLElement;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<audio
bind:this={mediaRef}
src={node.attrs.src}
controls
title={node.attrs.title}
class="m-0 w-full"
>
</audio>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Audio from '@lucide/svelte/icons/audio-lines';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const audioUrl = prompt('Please enter the audio URL');
if (audioUrl) {
editor.chain().focus().setAudio(audioUrl).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Audio}
title="Insert an audio"
onClick={handleClick}
/>
@@ -0,0 +1,73 @@
<script lang="ts">
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { Button, buttonVariants } from '$src/base/button';
const { node, updateAttributes, extension }: NodeViewProps = $props();
import * as DropdownMenu from '$src/base/dropdown-menu';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Check from '@lucide/svelte/icons/check';
import Copy from '@lucide/svelte/icons/copy';
let preRef = $state<HTMLPreElement>();
let isCopying = $state(false);
const languages: string[] = extension.options.lowlight.listLanguages().sort();
let defaultLanguage = $state(node.attrs.language);
function copyCode() {
if (!preRef) return;
isCopying = true;
navigator.clipboard.writeText(preRef.innerText);
setTimeout(() => {
isCopying = false;
}, 1000);
}
</script>
<NodeViewWrapper class="code-wrapper" draggable={false} spellcheck={false}>
<div class="code-wrapper-tile" contenteditable="false">
<DropdownMenu.Root>
<DropdownMenu.Trigger
contenteditable="false"
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'text-muted-foreground h-4 rounded px-1 py-2 text-xs'
})}
>{defaultLanguage}
<ChevronDown class="!size-2" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="h-96 w-40 overflow-auto" contenteditable="false">
{#each languages as language (language)}
<DropdownMenu.Item
contenteditable="false"
data-current={defaultLanguage === language}
class="data-[current=true]:bg-muted"
textValue={language}
onclick={() => {
defaultLanguage = language;
updateAttributes({ language: defaultLanguage });
}}
>
<span>{language}</span>
{#if defaultLanguage === language}
<Check class="ml-auto" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="ghost" class="text-muted-foreground size-4 p-0" onclick={copyCode}>
{#if isCopying}
<Check class="size-3 text-green-500" />
{:else}
<Copy class="size-3" />
{/if}
</Button>
</div>
<pre bind:this={preRef} draggable={false}>
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
</pre>
</NodeViewWrapper>
@@ -0,0 +1,23 @@
<script lang="ts">
import * as Tooltip from '$src/base/tooltip';
import type { Snippet } from 'svelte';
interface Props {
tooltip: string;
children: Snippet<[]>;
shortCut?: string;
}
const { tooltip, children, shortCut }: Props = $props();
</script>
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content>
<span>{tooltip}</span>
<span class="text-muted-foreground">{shortCut}</span>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<iframe bind:this={mediaRef} class="m-0 w-full" {...node.attrs}> </iframe>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Audio from '@lucide/svelte/icons/code-xml';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const iframUrl = prompt('Please enter the IFrame URL');
if (iframUrl) {
editor.chain().focus().setIframe({ src: iframUrl }).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Audio}
title="Insert an iframe"
onClick={handleClick}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<img
bind:this={mediaRef}
src={node.attrs.src}
alt={node.attrs.alt}
title={node.attrs.title}
class="m-0 object-cover"
/>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Image from '@lucide/svelte/icons/image';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const imageUrl = prompt('Please enter the image URL');
if (imageUrl) {
editor.chain().focus().setImage({ src: imageUrl }).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Image}
title="Insert an image"
onClick={handleClick}
/>
@@ -0,0 +1,270 @@
<script lang="ts">
import { onDestroy, onMount, type Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { cn } from '$src/tools';
import { Button, buttonVariants } from '$src/base/button';
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 EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
import CopyIcon from '@lucide/svelte/icons/copy';
import Fullscreen from '@lucide/svelte/icons/fullscreen';
import Trash from '@lucide/svelte/icons/trash';
import Captions from '@lucide/svelte/icons/captions';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { duplicateContent } from '../../utils';
interface MediaExtendedProps extends NodeViewProps {
children: Snippet<[]>;
mediaRef?: HTMLElement;
}
const {
node,
editor,
selected,
deleteNode,
updateAttributes,
children,
mediaRef = $bindable()
}: MediaExtendedProps = $props();
const minWidthPercent = 15;
const maxWidthPercent = 100;
let nodeRef = $state<HTMLElement>();
let resizing = $state(false);
let resizingInitialWidthPercent = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let openedMore = $state(false);
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (mediaRef && nodeRef?.parentElement) {
const currentWidth = mediaRef.offsetWidth;
const parentWidth = nodeRef.parentElement.offsetWidth;
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
}
}
function resize(e: MouseEvent) {
if (!resizing || !nodeRef?.parentElement) return;
let dx = e.clientX - resizingInitialMouseX;
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
}
const parentWidth = nodeRef.parentElement.offsetWidth;
const deltaPercent = (dx / parentWidth) * 100;
const newWidthPercent = Math.max(
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
minWidthPercent
);
updateAttributes({ width: `${newWidthPercent}%` });
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidthPercent = 0;
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (mediaRef && nodeRef?.parentElement) {
const currentWidth = mediaRef.offsetWidth;
const parentWidth = nodeRef.parentElement.offsetWidth;
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
}
}
function handleTouchMove(e: TouchEvent) {
if (!resizing || !nodeRef?.parentElement) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
}
const parentWidth = nodeRef.parentElement.offsetWidth;
const deltaPercent = (dx / parentWidth) * 100;
const newWidthPercent = Math.max(
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
minWidthPercent
);
updateAttributes({ width: `${newWidthPercent}%` });
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidthPercent = 0;
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
</script>
<!-- !FIXME: For some wierd reason, the change in `node.attrs.align` is not reactive to NodeViewWrapper
So for now, we'll re-render it when the align changes.
-->
{#key node.attrs.align}
<NodeViewWrapper
id="resizable-container-media"
class={cn(
'relative my-2 flex flex-col rounded-md border-2 border-transparent',
selected ? 'border-muted-foreground' : '',
node.attrs.align === 'left' && 'left-0 -translate-x-0',
node.attrs.align === 'center' && 'left-1/2 -translate-x-1/2',
node.attrs.align === 'right' && 'left-full -translate-x-full'
)}
style={`width: ${node.attrs.width}`}
>
<div class={cn('group relative flex flex-col rounded-md', resizing && '')}>
{@render children()}
{#if editor.isEditable}
<div
role="button"
tabindex="0"
aria-label="Back"
class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-start p-2"
style="left: 0px"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
}}
>
<div
class="bg-muted z-20 h-[70px] w-1 rounded-xl border opacity-0 transition-all group-hover:opacity-100"
></div>
</div>
<div
role="button"
tabindex="0"
aria-label="Back"
class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-end p-2"
style="right: 0px"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
}}
>
<div
class="bg-muted z-20 h-[70px] w-1 rounded-xl border opacity-0 transition-all group-hover:opacity-100"
></div>
</div>
<div
class={cn(
'bg-background/50 absolute -top-2 left-[calc(50%-3rem)] flex items-center gap-1 rounded border p-1 opacity-0 backdrop-blur-sm transition-opacity',
!resizing && 'group-hover:opacity-100',
openedMore && 'opacity-100'
)}
>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'left' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'left' })}
title="Align Left"
>
<AlignLeft class="size-4" />
</Button>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'center' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'center' })}
title="Align Center"
>
<AlignCenter class="size-4" />
</Button>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'right' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'right' })}
title="Align Right"
>
<AlignRight class="size-4" />
</Button>
<DropdownMenu.Root bind:open={openedMore} onOpenChange={(value) => (openedMore = value)}>
<DropdownMenu.Trigger
class={buttonVariants({ variant: 'ghost', class: 'size-6 p-0' })}
title="More Options"
>
<EllipsisVertical class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" alignOffset={-90} class="mt-1 overflow-auto text-sm">
<DropdownMenu.Item
onclick={() => {
if (node.attrs.title === null || node.attrs.title.trim() === '')
updateAttributes({
title: 'Image Caption'
});
}}
>
<Captions class="mr-1 size-4" /> Caption
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
duplicateContent(editor, node);
}}
>
<CopyIcon class="mr-1 size-4" /> Duplicate
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
updateAttributes({
width: '100%'
});
}}
>
<Fullscreen class="mr-1 size-4" /> Full Screen
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
deleteNode();
}}
class="text-destructive"
>
<Trash class="mr-1 size-4" /> Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</NodeViewWrapper>
{/key}
@@ -0,0 +1,119 @@
<script lang="ts">
import { Button } from '$src/base/button';
import { cn } from '$src/tools';
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: Record<string, any>;
}
const { props }: Props = $props();
let scrollContainer = $state<HTMLElement | null>(null);
let selectedGroupIndex = $state<number>(0);
let selectedCommandIndex = $state<number>(0);
const items = $derived.by(() => props.items);
$effect(() => {
if (items) {
selectedGroupIndex = 0;
selectedCommandIndex = 0;
}
});
$effect(() => {
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
if (activeItem !== null && scrollContainer !== null) {
const offsetTop = activeItem.offsetTop;
const offsetHeight = activeItem.offsetHeight;
scrollContainer.scrollTop = offsetTop - offsetHeight;
}
});
const selectItem = (groupIndex: number, commandIndex: number) => {
const command = props.items[groupIndex].commands[commandIndex];
props.command(command);
};
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
e.preventDefault();
if (!props.items.length) {
return false;
}
const commands = props.items[selectedGroupIndex].commands;
let newCommandIndex = selectedCommandIndex + 1;
let newGroupIndex = selectedGroupIndex;
if (commands.length - 1 < newCommandIndex) {
newCommandIndex = 0;
newGroupIndex = selectedGroupIndex + 1;
}
if (props.items.length - 1 < newGroupIndex) {
newGroupIndex = 0;
}
selectedCommandIndex = newCommandIndex;
selectedGroupIndex = newGroupIndex;
return true;
}
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
e.preventDefault();
if (!props.items.length) {
return false;
}
let newCommandIndex = selectedCommandIndex - 1;
let newGroupIndex = selectedGroupIndex;
if (newCommandIndex < 0) {
newGroupIndex = selectedGroupIndex - 1;
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
}
if (newGroupIndex < 0) {
newGroupIndex = props.items.length - 1;
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
}
selectedCommandIndex = newCommandIndex;
selectedGroupIndex = newGroupIndex;
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
return false;
}
selectItem(selectedGroupIndex, selectedCommandIndex);
return true;
}
return false;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
{#if items.length}
<div
bind:this={scrollContainer}
class="bg-popover flex max-h-80 flex-col gap-1 overflow-auto scroll-smooth rounded border"
>
{#each items as grp, groupIndex (groupIndex)}
<span class="text-muted-foreground p-2 text-xs">{grp.title}</span>
{#each grp.commands as command, commandIndex (commandIndex)}
{@const Icon = command.icon}
{@const isActive = selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
<Button
id={`${groupIndex}-${commandIndex}`}
variant="ghost"
class={cn('h-8 w-full justify-start gap-2 rounded-sm', isActive && 'bg-muted')}
onclick={() => selectItem(groupIndex, commandIndex)}
>
<Icon />
<span>{command.tooltip}</span>
</Button>
{/each}
{/each}
</div>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import { Button } from '$src/base/button';
import type { EdraToolBarCommands } from '../../commands/types';
import type { Editor } from '@tiptap/core';
import EdraToolTip from './EdraToolTip.svelte';
import { cn } from '$src/tools';
interface Props {
editor: Editor;
command: EdraToolBarCommands;
}
const { editor, command }: Props = $props();
</script>
<EdraToolTip tooltip={command.tooltip ?? ''} shortCut={command.shortCut ?? ''}>
<Button
variant="ghost"
size="icon"
class={cn(command.isActive?.(editor) && 'bg-muted')}
onclick={() => command.onClick?.(editor)}
disabled={command.clickable ? !command.clickable(editor) : false}
>
{@const Icon = command.icon}
<Icon />
</Button>
</EdraToolTip>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<video
bind:this={mediaRef}
src={node.attrs.src}
controls
title={node.attrs.title}
class="m-0 object-cover"
>
<track kind="captions" />
</video>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Video from '@lucide/svelte/icons/video';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const videoUrl = prompt('Please enter the video URL');
if (videoUrl) {
editor.chain().focus().setVideo(videoUrl).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Video}
title="Insert a video"
onClick={handleClick}
/>
@@ -0,0 +1,57 @@
<script lang="ts">
import * as DropdownMenu from '$src/base/dropdown-menu';
import commands from '../../../commands/toolbar-commands';
import type { Editor } from '@tiptap/core';
import AlignLeft from '@lucide/svelte/icons/align-left';
import EdraToolTip from '../EdraToolTip.svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { buttonVariants } from '$src/base/button';
import { cn } from '$src/tools';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const alignments = commands['alignment'];
const isActive = $derived.by(() => {
return alignments.find((alignment) => alignment.isActive?.(editor)) !== undefined;
});
const AlignMentIcon = $derived.by(() => {
const a = alignments.find((alignment) => alignment.isActive?.(editor));
if (a) return a.icon;
else return AlignLeft;
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EdraToolTip tooltip="Alignment">
<div
class={buttonVariants({
variant: 'ghost',
size: 'icon',
class: cn('gap-0')
})}
class:bg-muted={isActive}
>
<AlignMentIcon />
<ChevronDown class="text-muted-foreground !size-2" />
</div>
</EdraToolTip>
</DropdownMenu.Trigger>
<DropdownMenu.Content portalProps={{ disabled: true, to: undefined }}>
{#each alignments as alignment (alignment)}
{@const Icon = alignment.icon}
<DropdownMenu.Item onclick={() => alignment.onClick?.(editor)}>
<Icon />
<span>{alignment.tooltip}</span>
<DropdownMenu.Shortcut>
{alignment.shortCut}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,53 @@
<script lang="ts">
import { Button } from '$src/base/button';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { Editor } from '@tiptap/core';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { cn } from '$src/tools';
import EdraToolTip from '../EdraToolTip.svelte';
interface Props {
class?: string;
editor: Editor;
}
const { class: className = '', editor }: Props = $props();
const FONT_SIZE = [
{ label: 'Tiny', value: '0.7rem' },
{ label: 'Smaller', value: '0.75rem' },
{ label: 'Small', value: '0.9rem' },
{ label: 'Default', value: '' },
{ label: 'Large', value: '1.25rem' },
{ label: 'Extra Large', value: '1.5rem' }
];
let currentSize = $derived.by(() => editor.getAttributes('textStyle').fontSize || '');
const currentLabel = $derived.by(() => {
const l = FONT_SIZE.find((f) => f.value === currentSize);
if (l) return l.label.split(' ')[0];
return 'Medium';
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EdraToolTip tooltip="Font Size">
<Button variant="ghost" class={cn('gap-0.5 !px-2', className)}>
<span>{currentLabel}</span>
<ChevronDown class="text-muted-foreground !size-2" />
</Button>
</EdraToolTip>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="h-fit w-fit" portalProps={{ disabled: true, to: undefined }}>
{#each FONT_SIZE as fontSize (fontSize)}
<DropdownMenu.Item
onclick={() => {
editor.chain().focus().setFontSize(fontSize.value).run();
}}
style={`font-size: ${fontSize.value}`}>{fontSize.label}</DropdownMenu.Item
>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import * as DropdownMenu from '$src/base/dropdown-menu';
import Heading from '@lucide/svelte/icons/heading';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import commands from '../../../commands/toolbar-commands';
import { cn } from '$src/tools';
import EdraToolTip from '../EdraToolTip.svelte';
import Paragraph from '@lucide/svelte/icons/pilcrow';
import { buttonVariants } from '$src/base/button';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const headings = commands['headings'];
const isActive = $derived.by(() => {
return headings.find((h) => h.isActive?.(editor)) !== undefined;
});
const HeadingIcon = $derived.by(() => {
const h = headings.find((h) => h.isActive?.(editor));
return h ? h.icon : Heading;
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EdraToolTip tooltip="Headings">
<div
class={buttonVariants({
variant: 'ghost',
size: 'icon',
class: cn('gap-0')
})}
class:bg-muted={isActive}
>
<HeadingIcon />
<ChevronDown class="text-muted-foreground !size-2" />
</div>
</EdraToolTip>
</DropdownMenu.Trigger>
<DropdownMenu.Content portalProps={{ to: undefined, disabled: true }}>
<DropdownMenu.Item onclick={() => editor.chain().focus().setParagraph().run()}>
<Paragraph />
<span>Paragraph</span>
</DropdownMenu.Item>
{#each headings as heading (heading)}
{@const Icon = heading.icon}
<DropdownMenu.Item onclick={() => heading.onClick?.(editor)}>
<Icon />
<span>{heading.tooltip}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,95 @@
<script lang="ts">
import { Button, buttonVariants } from '$src/base/button';
import * as Popover from '$src/base/popover';
import type { Editor } from '@tiptap/core';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { cn } from '$src/tools';
import EdraToolTip from '../EdraToolTip.svelte';
interface Props {
class?: string;
editor: Editor;
}
const { class: className = '', editor }: Props = $props();
const colors = [
{ label: 'Default', value: '' },
{ label: 'Blue', value: '#0000FF' },
{ label: 'Brown', value: '#A52A2A' },
{ label: 'Green', value: '#008000' },
{ label: 'Grey', value: '#808080' },
{ label: 'Orange', value: '#FFA500' },
{ label: 'Pink', value: '#FFC0CB' },
{ label: 'Purple', value: '#800080' },
{ label: 'Red', value: '#FF0000' },
{ label: 'Yellow', value: '#FFFF00' }
];
const currentColor = $derived.by(() => editor.getAttributes('textStyle').color);
const currentHighlight = $derived.by(() => editor.getAttributes('highlight').color);
</script>
<Popover.Root>
<Popover.Trigger>
<EdraToolTip tooltip="Quick Colors">
<div
class={buttonVariants({
variant: 'ghost',
size: 'icon',
class: cn('gap-0.5', className)
})}
style={`color: ${currentColor}; background-color: ${currentHighlight}50;`}
>
<span>A</span>
<ChevronDown class="text-muted-foreground !size-2" />
</div>
</EdraToolTip>
</Popover.Trigger>
<Popover.Content class="size-fit shadow-lg" portalProps={{ disabled: true, to: undefined }}>
<div class="text-muted-foreground my-2 text-xs">Text Colors</div>
<div class="grid grid-cols-5 gap-2">
{#each colors as color (color)}
<Button
variant="ghost"
class={cn(
`size-6 border-0 p-0 font-normal`,
editor.isActive('textStyle', { color: color.value }) && 'border-2 font-extrabold',
color.value === '' && 'border'
)}
style={`color: ${color.value}; background-color: ${color.value}50; border-color: ${color.value};`}
title={color.label}
onclick={() => {
if (color.value === '' || color.label === 'Default') editor.chain().focus().unsetColor().run();
else
editor
.chain()
.focus()
.setColor(currentColor === color.value ? '' : color.value)
.run();
}}
>
A
</Button>
{/each}
</div>
<div class="text-muted-foreground my-2 text-xs">Highlight Colors</div>
<div class="grid grid-cols-5 gap-2">
{#each colors as color (color)}
<Button
variant="ghost"
class={cn(
`size-6 border-0 p-0 font-normal`,
editor.isActive('highlight', { color: color.value }) && 'border-2',
color.value === '' && 'border'
)}
style={`background-color: ${color.value}50; border-color: ${color.value};`}
title={color.label}
onclick={() => {
if (color.value === '' || color.label === 'Default') editor.chain().focus().unsetHighlight().run();
else editor.chain().focus().toggleHighlight({ color: color.value }).run();
}}>A</Button
>
{/each}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,148 @@
<script lang="ts">
import { Button } from '$src/base/button';
import type { Editor } from '@tiptap/core';
import Search from '@lucide/svelte/icons/search';
import * as Popover from '$src/base/popover';
import { Input } from '$src/base/input';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import CaseSensitive from '@lucide/svelte/icons/case-sensitive';
import Replace from '@lucide/svelte/icons/replace';
import ReplaceAll from '@lucide/svelte/icons/replace-all';
import { cn } from '$src/tools';
import { slide } from 'svelte/transition';
import EdraToolTip from '../EdraToolTip.svelte';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
let open = $state(false);
let showMore = $state(false);
let searchText = $state('');
let replaceText = $state('');
let caseSensitive = $state(false);
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
function updateSearchTerm(clearIndex: boolean = false) {
if (clearIndex) editor.commands.resetIndex();
editor.commands.setSearchTerm(searchText);
editor.commands.setReplaceTerm(replaceText);
editor.commands.setCaseSensitive(caseSensitive);
}
function goToSelection() {
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
if (!position) return;
editor.commands.setTextSelection(position);
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function replace() {
editor.commands.replace();
goToSelection();
}
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
const clear = () => {
searchText = '';
replaceText = '';
caseSensitive = false;
editor.commands.resetIndex();
};
const replaceAll = () => editor.commands.replaceAll();
</script>
<Popover.Root
bind:open
onOpenChange={(value) => {
if (value === false) {
clear();
updateSearchTerm();
}
}}
>
<Popover.Trigger>
<EdraToolTip tooltip="Search and Replace">
<Button variant="ghost" size="icon" class="gap-0.5">
<Search />
<ChevronDown class="text-muted-foreground !size-2" />
</Button>
</EdraToolTip>
</Popover.Trigger>
<Popover.Content class="flex w-fit items-center gap-1 p-2" portalProps={{ disabled: true, to: undefined }}>
<Button
variant="ghost"
size="icon"
class={cn('transition-transform', showMore && 'bg-muted rotate-90')}
onclick={() => (showMore = !showMore)}
title="Show More"
>
<ChevronRight />
</Button>
<div class="flex size-full flex-col gap-1">
<div class="flex w-full items-center gap-1">
<Input placeholder="Search..." bind:value={searchText} oninput={() => updateSearchTerm()} class="w-40" />
<span class="text-muted-foreground text-sm">{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount} </span>
<EdraToolTip tooltip="Case Sensitive">
<Button
variant="ghost"
size="icon"
class={cn(caseSensitive && 'bg-muted')}
onclick={() => {
caseSensitive = !caseSensitive;
updateSearchTerm();
}}
>
<CaseSensitive />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Go to previous">
<Button variant="ghost" size="icon" onclick={previous} title="Previous">
<ArrowLeft />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Go to next">
<Button variant="ghost" size="icon" onclick={next} title="Next">
<ArrowRight />
</Button>
</EdraToolTip>
</div>
{#if showMore}
<div transition:slide class="flex w-full items-center gap-1">
<Input placeholder="Replace..." bind:value={replaceText} oninput={() => updateSearchTerm()} class="w-40" />
<EdraToolTip tooltip="Replace">
<Button variant="ghost" size="icon" onclick={replace}>
<Replace />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Replace All">
<Button variant="ghost" size="icon" onclick={replaceAll}>
<ReplaceAll />
</Button>
</EdraToolTip>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,103 @@
<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';
import { Button } from '$src/base/button';
import type { Node } from '@tiptap/pm/model';
import * as DropdownMenu from '$src/base/dropdown-menu';
import RemoveFormatting from '@lucide/svelte/icons/remove-formatting';
import Duplicate from '@lucide/svelte/icons/copy';
import Clipboard from '@lucide/svelte/icons/clipboard';
import Delete from '@lucide/svelte/icons/trash-2';
import { NodeSelection } from '@tiptap/pm/state';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
let currentNode: Node | null = $state(null);
let currentNodePos: number = $state(-1);
let open = $state(false);
const pluginKey = 'globalDragHandle';
onMount(() => {
const plugin = DragHandlePlugin({
pluginKey: pluginKey,
dragHandleWidth: 20,
scrollTreshold: 100,
dragHandleSelector: '.drag-handle',
excludedTags: ['pre', 'code', 'table p'],
customNodes: [],
onMouseMove: onMouseMove
});
editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
});
const onMouseMove = (data: { node: Node; pos: number }) => {
if (data.node) currentNode = data.node;
currentNodePos = data.pos;
};
const handleRemoveFormatting = () => {
const chain = editor.chain();
chain.setNodeSelection(currentNodePos).unsetAllMarks();
chain.setParagraph();
chain.run();
};
const handleDuplicate = () => {
editor.commands.setNodeSelection(currentNodePos);
const selectedNode = editor.state.selection.$anchor.node(1) || (editor.state.selection as NodeSelection).node;
editor
.chain()
.setMeta('hideDragHandle', true)
.insertContentAt(currentNodePos + (currentNode?.nodeSize || 0), selectedNode.toJSON())
.run();
};
const handleCopyToClipboard = () => {
editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).run();
/**
* !FIXME: document.execCommand is deprecated, use navigator.clipboard.writeText instead
*/
document.execCommand('copy');
};
const handleDelete = () => {
editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).deleteSelection().run();
};
</script>
<div class="drag-handle">
<Button variant="ghost" class="!size-6 rounded-sm p-0" onclick={() => (open = true)}>
<GripVertical />
</Button>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="sr-only">
<span>Drag Handle</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onclick={handleRemoveFormatting}>
<RemoveFormatting />
Remove Formatting
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDuplicate}>
<Duplicate />
Duplicate Node
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyToClipboard}>
<Clipboard />
Copy to clipboard
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete}>
<Delete class="text-destructive" />
Delete Node
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,107 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { EdraEditorProps } from '../types';
import initEditor from '../editor';
import { focusEditor } from '../utils';
import { cn } from '$src/tools';
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder';
import ImagePlaceholderComp from './components/ImagePlaceholder.svelte';
import { ImageExtended } from '../extensions/image/ImageExtended';
import ImageExtendedComp from './components/ImageExtended.svelte';
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder';
import VideoPlaceHolderComp from './components/VideoPlaceholder.svelte';
import { VideoExtended } from '../extensions/video/VideoExtended';
import VideoExtendedComp from './components/VideoExtended.svelte';
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder';
import { AudioExtended } from '../extensions/audio/AudiExtended';
import AudioPlaceHolderComp from './components/AudioPlaceHolder.svelte';
import AudioExtendedComp from './components/AudioExtended.svelte';
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder';
import { IFrameExtended } from '../extensions/iframe/IFrameExtended';
import IFramePlaceHolderComp from './components/IFramePlaceHolder.svelte';
import IFrameExtendedComp from './components/IFrameExtended.svelte';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { all, createLowlight } from 'lowlight';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import CodeBlock from './components/CodeBlock.svelte';
import TableCol from './menus/TableCol.svelte';
import TableRow from './menus/TableRow.svelte';
import Link from './menus/Link.svelte';
import slashcommand from '../extensions/slash-command/slashcommand';
import SlashCommandList from './components/SlashCommandList.svelte';
import '../editor.css';
import './style.css';
import '../onedark.css';
const lowlight = createLowlight(all);
/**
* Bind the element to the editor
*/
let element = $state<HTMLElement>();
let {
editor = $bindable(),
editable = true,
content,
onUpdate,
autofocus = false,
class: className
}: EdraEditorProps = $props();
onMount(() => {
editor = initEditor(
element,
content,
[
CodeBlockLowlight.configure({
lowlight
}).extend({
addNodeView() {
return SvelteNodeViewRenderer(CodeBlock);
}
}),
ImagePlaceholder(ImagePlaceholderComp),
ImageExtended(ImageExtendedComp),
VideoPlaceholder(VideoPlaceHolderComp),
VideoExtended(VideoExtendedComp),
AudioPlaceholder(AudioPlaceHolderComp),
AudioExtended(AudioExtendedComp),
IFramePlaceholder(IFramePlaceHolderComp),
IFrameExtended(IFrameExtendedComp),
slashcommand(SlashCommandList)
],
{
onUpdate,
onTransaction(props) {
editor = undefined;
editor = props.editor;
},
editable,
autofocus
}
);
});
onDestroy(() => {
if (editor) editor.destroy();
});
</script>
{#if editor && !editor.isDestroyed}
<Link {editor} />
<TableCol {editor} />
<TableRow {editor} />
{/if}
<div
bind:this={element}
role="button"
tabindex="0"
onclick={(event) => focusEditor(editor, event)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
focusEditor(editor, event);
}
}}
class={cn('edra-editor h-full w-full cursor-auto px-4 *:outline-none', className)}
></div>
@@ -0,0 +1,4 @@
export { default as EdraEditor } from './editor.svelte';
export { default as EdraToolBar } from './toolbar.svelte';
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
export { default as EdraDragHandleExtended } from './drag-handle-extended.svelte';
@@ -0,0 +1,4 @@
export { default as EdraEditor } from './editor.svelte';
export { default as EdraToolBar } from './toolbar.svelte';
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
export { default as EdraDragHandleExtended } from './drag-handle-extended.svelte';
@@ -0,0 +1,54 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types';
import BubbleMenu from '../../components/BubbleMenu.svelte';
import type { Editor } from '@tiptap/core';
import { Button } from '$src/base/button';
import Copy from '@lucide/svelte/icons/copy';
import Trash from '@lucide/svelte/icons/trash';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
let link = $derived.by(() => editor.getAttributes('link').href);
</script>
<BubbleMenu
{editor}
pluginKey="link-bubble-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
return props.editor.isActive('link');
}}
options={{
strategy: 'fixed'
}}
class="bg-popover flex h-fit w-fit items-center gap-1 rounded border p-1 shadow-lg"
>
<Button variant="link" href={link} class="max-w-80 p-1" target="_blank">
<span class="w-full overflow-hidden text-ellipsis">
{link}
</span>
</Button>
<Button
variant="ghost"
title="Copy Link"
size="icon"
class="z-50"
onclick={() => {
navigator.clipboard.writeText(link);
}}
>
<Copy />
</Button>
<Button
variant="ghost"
title="Remove Link"
size="icon"
onclick={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
>
<Trash />
</Button>
</BubbleMenu>
@@ -0,0 +1,113 @@
<script lang="ts">
import commands from '../../commands/toolbar-commands';
import BubbleMenu from '../../components/BubbleMenu.svelte';
import type { EdraToolbarProps, ShouldShowProps } from '../../types';
import { cn } from '$src/tools';
import { isTextSelection } from '@tiptap/core';
import Alignment from '../components/toolbar/Alignment.svelte';
import FontSize from '../components/toolbar/FontSize.svelte';
import Headings from '../components/toolbar/Headings.svelte';
import QuickColors from '../components/toolbar/QuickColors.svelte';
import ToolBarIcon from '../components/ToolBarIcon.svelte';
const {
editor,
class: className,
excludedCommands = ['undo-redo', 'media', 'lists', 'table'],
children
}: EdraToolbarProps = $props();
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
let isDragging = $state(false);
editor.view.dom.addEventListener('dragstart', () => {
isDragging = true;
});
editor.view.dom.addEventListener('drop', () => {
isDragging = true;
// Allow some time for the drop action to complete before re-enabling
setTimeout(() => {
isDragging = false;
}, 100); // Adjust delay if needed
});
function shouldShow(props: ShouldShowProps) {
if (!props.editor.isEditable) return false;
const { view, editor } = props;
if (!view || editor.view.dragging) {
return false;
}
if (editor.isActive('link')) return false;
if (editor.isActive('codeBlock')) return false;
if (editor.isActive('image-placeholder')) return false;
if (editor.isActive('video-placeholder')) return false;
if (editor.isActive('audio-placeholder')) return false;
if (editor.isActive('iframe-placeholder')) return false;
const {
state: {
doc,
selection,
selection: { empty, from, to }
}
} = editor;
// check if the selection is a table grip
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
const node = nodeDOM || domAtPos;
if (isTableGripSelected(node)) {
return false;
}
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return !isDragging && !editor.state.selection.empty;
}
const isTableGripSelected = (node: HTMLElement) => {
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected');
const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected');
if (gripColumn || gripRow) {
return true;
}
return false;
};
</script>
<BubbleMenu
{editor}
pluginKey="link-bubble-menu"
{shouldShow}
class={cn('edra-bubble-menu bg-popover flex h-fit w-fit items-center rounded-lg p-0.5', className)}
>
{#if children}
{@render children()}
{:else}
{#each toolbarCommands.filter((c) => !excludedCommands?.includes(c)) as cmd (cmd)}
{#if cmd === 'headings'}
<Headings {editor} />
{:else if cmd === 'alignment'}
<Alignment {editor} />
{:else}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{/each}
{/if}
{/each}
<FontSize {editor} />
<QuickColors {editor} />
{/if}
</BubbleMenu>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types';
import { type Editor } from '@tiptap/core';
import ArrowLeftFromLine from '@lucide/svelte/icons/arrow-left-from-line';
import ArrowRightFromLine from '@lucide/svelte/icons/arrow-right-from-line';
import Trash from '@lucide/svelte/icons/trash';
import { Button } from '$src/base/button';
import { isColumnGripSelected } from '../../extensions/table/utils';
import EdraToolTip from '../components/EdraToolTip.svelte';
import BubbleMenu from '../../components/BubbleMenu.svelte';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
</script>
<BubbleMenu
{editor}
pluginKey="table-col-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.state) {
return false;
}
return isColumnGripSelected({
editor: props.editor,
view: props.view,
state: props.state,
from: props.from
});
}}
class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg"
>
<EdraToolTip tooltip="Add Column After">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addColumnAfter().run()}>
<ArrowRightFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Add Column Before">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addColumnBefore().run()}>
<ArrowLeftFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Delete This Column">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().deleteColumn().run()}>
<Trash />
</Button>
</EdraToolTip>
</BubbleMenu>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types';
import { type Editor } from '@tiptap/core';
import ArrowDownFromLine from '@lucide/svelte/icons/arrow-down-from-line';
import ArrowUpFromLine from '@lucide/svelte/icons/arrow-up-from-line';
import Trash from '@lucide/svelte/icons/trash';
import { Button } from '$src/base/button';
import { isRowGripSelected } from '../../extensions/table/utils';
import EdraToolTip from '../components/EdraToolTip.svelte';
import BubbleMenu from '../../components/BubbleMenu.svelte';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
</script>
<BubbleMenu
{editor}
pluginKey="table-row-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.state) {
return false;
}
return isRowGripSelected({
editor: props.editor,
view: props.view,
state: props.state,
from: props.from
});
}}
class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg"
>
<EdraToolTip tooltip="Add Row After">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addRowAfter().run()}>
<ArrowDownFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Add Row Before">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addRowBefore().run()}>
<ArrowUpFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Delete This Row">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().deleteRow().run()}>
<Trash />
</Button>
</EdraToolTip>
</BubbleMenu>
@@ -0,0 +1,25 @@
:root {
--border-color: var(--muted);
--border-color-hover: var(--muted-foreground);
--blockquote-border: var(--border);
--blockquote-color: var(--muted-foreground);
--codeblock-bg: var(--muted);
--code-bg: var(--muted);
--table-border: var(--border);
--table-bg-selected: color-mix(in oklch, var(--muted) 60%, transparent);
--table-bg-hover: color-mix(in oklch, var(--muted) 30%, transparent);
--task-completed-color: var(--muted-foreground);
--highlight-color: rgba(0, 255, 0, 0.1);
--highlight-border: green;
--search-result-bg: yellow;
--search-result-current-bg: orange;
}
.dark {
--codeblock-bg: color-mix(in oklch, var(--muted) 30%, transparent);
}
.tiptap a {
color: oklch(62.3% 0.214 259.815);
text-decoration: underline;
}
@@ -0,0 +1,37 @@
<script lang="ts">
import { cn } from '$src/tools';
import commands from '../commands/toolbar-commands';
import type { EdraToolbarProps } from '../types';
import Alignment from './components/toolbar/Alignment.svelte';
import FontSize from './components/toolbar/FontSize.svelte';
import Headings from './components/toolbar/Headings.svelte';
import QuickColors from './components/toolbar/QuickColors.svelte';
import SearchAndReplace from './components/toolbar/SearchAndReplace.svelte';
import ToolBarIcon from './components/ToolBarIcon.svelte';
const { editor, class: className, excludedCommands, children }: EdraToolbarProps = $props();
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
</script>
<div class={cn('edra-toolbar', className)}>
{#if children}
{@render children()}
{:else}
{#each toolbarCommands as cmd (cmd)}
{#if cmd === 'headings'}
<Headings {editor} />
{:else if cmd === 'alignment'}
<Alignment {editor} />
{:else}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{/each}
{/if}
{/each}
<FontSize {editor} />
<QuickColors {editor} />
<SearchAndReplace {editor} />
{/if}
</div>
+119
View File
@@ -0,0 +1,119 @@
import type { Editor } from '@tiptap/core';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import { Node } from '@tiptap/pm/model';
/**
* Check if the current browser is in mac or not
*/
export const isMac =
typeof window !== 'undefined' && typeof navigator !== 'undefined'
? navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X')
: false;
/**
* Function to handle paste event of an image
* @param editor Editor - editor instance
* @param maxSize number - max size of the image to be pasted in MB, default is 2MB
*/
export function getHandlePaste(editor: Editor, maxSize: number = 2) {
return (view: EditorView, event: ClipboardEvent) => {
const item = event.clipboardData?.items[0];
if (item?.type.indexOf('image') !== 0) {
return;
}
const file = item.getAsFile();
if (file === null || file.size === undefined) return;
const filesize = (file?.size / 1024 / 1024).toFixed(4);
if (filesize && Number(filesize) > maxSize) {
window.alert(`too large image! filesize: ${filesize} mb`);
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
// reader.onload = (e) => {
// if (e.target?.result) {
// editor.commands.setImage({ src: e.target.result as string });
// }
// };
};
}
export const findColors = (doc: Node) => {
const hexColor = /(#[0-9a-f]{3,6})\b/gi;
const decorations: Decoration[] = [];
doc.descendants((node, position) => {
if (!node.text) {
return;
}
Array.from(node.text.matchAll(hexColor)).forEach((match) => {
const color = match[0];
const index = match.index || 0;
const from = position + index;
const to = from + color.length;
const decoration = Decoration.inline(from, to, {
class: 'color',
style: `--color: ${color}`
});
decorations.push(decoration);
});
});
return DecorationSet.create(doc, decorations);
};
/**
* Dupilcate content at the current selection
* @param editor Editor instance
* @param node Node to be duplicated
*/
export const duplicateContent = (editor: Editor, node: Node) => {
const { view } = editor;
const { state } = view;
const { selection } = state;
editor
.chain()
.insertContentAt(selection.to, node.toJSON(), {
updateSelection: true
})
.focus(selection.to)
.run();
};
/**
* Sets focus on the editor and moves the cursor to the clicked text position,
* defaulting to the end of the document if the click is outside any text.
*
* @param editor - Editor instance
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
*/
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
if (!editor) return;
// Check if there is a text selection already (i.e. a non-empty selection)
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
// Focus the editor without modifying selection
editor.chain().focus().run();
return;
}
if (event instanceof MouseEvent) {
const { clientX, clientY } = event;
const pos = editor.view.posAtCoords({ left: clientX, top: clientY })?.pos;
if (pos == null) {
// If not a valid position, move cursor to the end of the document
const endPos = editor.state.doc.content.size;
editor.chain().focus().setTextSelection(endPos).run();
} else {
editor.chain().focus().setTextSelection(pos).run();
}
} else {
editor.chain().focus().run();
}
}
+122
View File
@@ -0,0 +1,122 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:where(.dark, .dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+13
View File
@@ -0,0 +1,13 @@
import './index.css';
export * as Button from './base/button';
export * as DropdownMenu from './base/dropdown-menu';
export * as Input from './base/input';
export * as Label from './base/label';
export * as Popover from './base/popover';
export * as Skeleton from './base/skeleton';
export * as Tooltip from './base/tooltip';
export * from './tools';
export * from './custom/editor/ui';
File diff suppressed because one or more lines are too long

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