Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
052f39b
feat(examples): shadcn and headless basic examples
SoRobby Mar 22, 2025
49f8dc4
feat(editor): add allowedCommands for EdraToolbar and typo fix
SoRobby Mar 22, 2025
1081e6f
feat(editor): add showSlashCommands property for Edra component
SoRobby Mar 22, 2025
5d3a297
refactor(editor): improved parameter naming convention to match tiptap
SoRobby Mar 22, 2025
70836a7
Merge branch 'Tsuzat:main' into main
SoRobby Mar 22, 2025
004c471
feat(bubble-menu): add allowedBubbleMenuCommands parameter to be pass…
SoRobby Mar 22, 2025
cdaf19a
feat(toolbar): allow for ordered commands based
SoRobby Mar 22, 2025
c625a4d
fix(QuickColor): shadcn QuickColor now closes upon color selection
SoRobby Mar 22, 2025
2cb79c7
docs: new editor features documented
SoRobby Mar 22, 2025
dda430b
refactor(toolbar): simplified logic
SoRobby Mar 22, 2025
4dfdd0f
feat(toolbar): add ability to pass children into toolbar
SoRobby Mar 22, 2025
cd9e6bf
fix(toolbar): let children be optional
SoRobby Mar 22, 2025
6e08dee
feat(editor): focus editor and set cursor to click position, defaulti…
SoRobby Mar 22, 2025
86aaf17
docs: updated docs to reflect recent changes
SoRobby Mar 22, 2025
00d86ca
fix(editor-focus): fix editor focus and bubble menu not showing
SoRobby Mar 22, 2025
32f238f
refactor(focusEditor): moved to utils.ts and removed repetitive code
SoRobby Mar 23, 2025
dee5015
refactor(getOrderedToolbarItems): moved to utils file
SoRobby Mar 23, 2025
001d1cb
Merge remote-tracking branch 'upstream/main'
SoRobby Mar 24, 2025
be99b3a
refactor: clean up names, remove unused util, align with tiptap conve…
SoRobby Mar 24, 2025
5d48fd2
feat(editor): update docs and add bubble menu toggles
SoRobby Mar 25, 2025
d98b6bd
fix: corrected naming, change PlaceHolder to Placeholder
SoRobby Mar 25, 2025
ff17643
fix: correct spelling of HighLighter to Highlighter
SoRobby Mar 25, 2025
315aa23
style: updated style of example pages
SoRobby Mar 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions src/lib/edra/headless/editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@
let {
class: className = '',
content = undefined,
editable = true,
showMenu = true,
showBubbleMenu = true,
allowedBubbleMenuCommands = [],
showSlashCommands = true,
limit = undefined,
editable = true,
editor = $bindable<Editor | undefined>(),
onUpdate
}: EdraProps = $props();
Expand Down Expand Up @@ -74,7 +76,7 @@
AudioExtended(AudioExtendedComponent),
ImageExtended(ImageExtendedComponent),
VideoExtended(VideoExtendedComponent),
slashcommand(SlashCommandList)
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
],
{
editable,
Expand All @@ -88,33 +90,77 @@
});

onDestroy(() => {
console.log('Destroying editor');
editor?.destroy();
});

// 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.
function focusEditor(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) {
// Just focus the editor without modifying the 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();
}
}
</script>

{#if editor}
<DragHandle {editor} />
{/if}

<div class={`edra ${className}`}>
{#if editor && showMenu}
{#if editor && showBubbleMenu}
<LinkMenu {editor} />
<TableRowMenu {editor} />
<TableColMenu {editor} />
<BubbleMenu {editor} />
<BubbleMenu {editor} {allowedBubbleMenuCommands} />
{/if}
{#if !editor}
<div class="edra-loading">
<LoaderCircle class="animate-spin" /> Loading...
</div>
{/if}
<div bind:this={element} class="edra-editor"></div>
<div
bind:this={element}
role="button"
tabindex="0"
onclick={focusEditor}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
focusEditor(event);
}
}}
class="edra-editor"
></div>
</div>

<style>
:global(.ProseMirror) {
all: unset;
min-height: 100%;
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
cursor: auto;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
&:focus {
outline: none;
}
Expand Down
103 changes: 66 additions & 37 deletions src/lib/edra/headless/menus/bubble-menu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@

interface Props {
editor: Editor;
allowedBubbleMenuCommands?: string[];
}
const { editor }: Props = $props();
const { editor, allowedBubbleMenuCommands }: Props = $props();

const bubbleMenuCommands = [
...commands['text-formatting'].commands,
...commands.alignment.commands,
...commands.lists.commands
];

const excludeCommands = ['undo-redo', 'media', 'table', 'colors', 'fonts', 'lists'];

const colorCommands = commands.colors.commands;
const fontCommands = commands.fonts.commands;

Expand Down Expand Up @@ -95,45 +98,71 @@
maxWidth: 'calc(100vw - 16px)'
}}
>
{#each bubbleMenuCommands as command}
<EdraToolBarIcon {command} {editor} />
{#each Object.keys(commands) as groupKey}
{#if allowedBubbleMenuCommands && allowedBubbleMenuCommands.length > 0}
<!-- If allowedCommands has top-level name, show all commands within that group, else filter by individual commands -->
{@const groupCommands = commands[groupKey].commands}
{@const filteredCommands = allowedBubbleMenuCommands.includes(groupKey)
? groupCommands
: groupCommands.filter((command) => allowedBubbleMenuCommands.includes(command.name))}
{#if filteredCommands.length > 0}
{#each filteredCommands as command}
<EdraToolBarIcon {command} {editor} />
{/each}
{/if}
{:else}
<!-- If no allowedCommands are passed, use default exclusions -->
{#if !excludeCommands.includes(groupKey)}
{#each commands[groupKey].commands as command}
<EdraToolBarIcon {command} {editor} />
{/each}
{/if}
{/if}
{/each}


<EdraToolBarIcon command={fontCommands[0]} {editor} />
<span>{editor.getAttributes('textStyle').fontSize ?? '16px'}</span>
<EdraToolBarIcon command={fontCommands[1]} {editor} />
{#if !allowedBubbleMenuCommands || allowedBubbleMenuCommands.length === 0 || allowedBubbleMenuCommands.includes('fontSize')}
<EdraToolBarIcon command={fontCommands[0]} {editor} />
<span>{editor.getAttributes('textStyle').fontSize ?? '16px'}</span>
<EdraToolBarIcon command={fontCommands[1]} {editor} />
{/if}

<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
if (hasColor) {
editor.chain().focus().unsetColor().run();
} else {
const color = prompt('Enter the color of the text:');
if (color !== null) {
editor.chain().focus().setColor(color).run();
{#if !allowedBubbleMenuCommands || allowedBubbleMenuCommands.length === 0 || allowedBubbleMenuCommands.includes('color')}
<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
if (hasColor) {
editor.chain().focus().unsetColor().run();
} else {
const color = prompt('Enter the color of the text:');
if (color !== null) {
editor.chain().focus().setColor(color).run();
}
}
}
}}
/>
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
} else {
const color = prompt('Enter the color of the highlight:');
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
}}
/>
{/if}

{#if !allowedBubbleMenuCommands || allowedBubbleMenuCommands.length === 0 || allowedBubbleMenuCommands.includes('highlight')}
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
} else {
const color = prompt('Enter the color of the highlight:');
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
}
}
}
}}
/>
}}
/>
{/if}
</BubbleMenu>
1 change: 0 additions & 1 deletion src/lib/edra/headless/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
flex-direction: row;
align-items: center;
gap: var(--edra-gap);
border-bottom: 1px solid var(--edra-border-color);
padding: var(--edra-padding);
width: fit-content;
overflow: auto;
Expand Down
137 changes: 87 additions & 50 deletions src/lib/edra/headless/toolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,107 @@
import type { Editor } from '@tiptap/core';
import { commands } from '../commands/commands.js';
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte';
import SearcnAndReplace from './components/SearcnAndReplace.svelte';
import SearchAndReplace from './components/SearchAndReplace.svelte';
import type { Snippet } from 'svelte';

interface Props {
class?: string;
editor: Editor;
allowedCommands?: string[];
children?: Snippet;
}

const { class: className = '', editor }: Props = $props();
const { class: className = '', editor, allowedCommands, children }: Props = $props();

// Special components that are handled separately
const specialComponents = ['fontSize', 'quickColor', 'searchAndReplace'];
const toolbarItems = getOrderedToolbarItems() as Array<{ type: string; command?: any }>;
let showSearchAndReplace = $state(false);

const colorCommands = commands.colors.commands;
const fontCommands = commands.fonts.commands;

// Function to get ordered toolbar items based on allowedCommands
function getOrderedToolbarItems() {
if (!allowedCommands?.length) {
return [
...Object.values(commands).flatMap((group) =>
group.commands.map((cmd) => ({ type: 'command', command: cmd }))
),
...specialComponents.map((comp) => ({ type: comp }))
];
}

return allowedCommands
.map((cmdName) => {
if (specialComponents.includes(cmdName)) {
return { type: cmdName };
}
// Check if it's a group
if (commands[cmdName]) {
return commands[cmdName].commands.map((cmd) => ({
type: 'command',
command: cmd
}));
}
// Find individual command
const command = Object.values(commands)
.flatMap((group) => group.commands)
.find((cmd) => cmd.name === cmdName);

return command ? { type: 'command', command } : null;
})
.flat()
.filter(Boolean);
}
</script>

<div class={`edra-toolbar ${className}`}>
{#if !showSearchAndReplace}
{#each Object.keys(commands).filter((key) => key !== 'colors' && key !== 'fonts') as keys}
{@const groups = commands[keys].commands}
{#each groups as command}
<EdraToolBarIcon {command} {editor} />
{/each}
<span class="separator"></span>
{#each toolbarItems as item}
{#if item.type === 'command'}
<EdraToolBarIcon command={item.command} {editor} />
{:else if item.type === 'fontSize'}
<EdraToolBarIcon command={fontCommands[0]} {editor} />
<span>{editor.getAttributes('textStyle').fontSize ?? '16px'}</span>
<EdraToolBarIcon command={fontCommands[1]} {editor} />
{:else if item.type === 'quickColor'}
<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
if (hasColor) {
editor.chain().focus().unsetColor().run();
} else {
const color = prompt('Enter the color of the text:');
if (color !== null) {
editor.chain().focus().setColor(color).run();
}
}
}}
/>
{:else if item.type === 'searchAndReplace'}
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
} else {
const color = prompt('Enter the color of the highlight:');
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
}
}
}}
/>
{/if}
{/each}

<EdraToolBarIcon command={fontCommands[0]} {editor} />
<span>{editor.getAttributes('textStyle').fontSize ?? '16px'}</span>
<EdraToolBarIcon command={fontCommands[1]} {editor} />

<span class="separator"></span>

<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
if (hasColor) {
editor.chain().focus().unsetColor().run();
} else {
const color = prompt('Enter the color of the text:');
if (color !== null) {
editor.chain().focus().setColor(color).run();
}
}
}}
/>
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
} else {
const color = prompt('Enter the color of the highlight:');
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
}
}
}}
/>
{/if}
<SearcnAndReplace {editor} bind:show={showSearchAndReplace} />
<SearchAndReplace {editor} bind:show={showSearchAndReplace} />
{@render children?.()}
</div>
Loading
Loading