Files
edentext/src/lib/editor/Toolbar.svelte
T

359 lines
11 KiB
Svelte

<script lang="ts">
import type { Editor } from '@tiptap/core';
import HistoryButton from './HistoryButton.svelte';
import AlignButton from './AlignButton.svelte';
import { ORDERED_LIST_TYPES, DEFAULT_ORDERED_TYPE, type OrderedListType } from './orderedListTypes';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
// $derived re-evaluates whenever `tick` changes (i.e. on every TipTap transaction)
let isH1 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 1 }));
let isH2 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 2 }));
let isH3 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 3 }));
let isHeading = $derived(isH1 || isH2 || isH3);
// fontWeight:'normal' is set explicitly to override heading boldness
let hasNormalWeight = $derived(tick >= 0 && !!editor?.isActive('textStyle', { fontWeight: 'normal' }));
// Bold = explicit bold mark, OR inside a heading that hasn't been un-bolded
let isBold = $derived(tick >= 0 && (!!editor?.isActive('bold') || (isHeading && !hasNormalWeight)));
let isItalic = $derived(tick >= 0 && !!editor?.isActive('italic'));
let isUnderline = $derived(tick >= 0 && !!editor?.isActive('underline'));
let isStrike = $derived(tick >= 0 && !!editor?.isActive('strike'));
let isBulletList = $derived(tick >= 0 && !!editor?.isActive('bulletList'));
let isOrderedList= $derived(tick >= 0 && !!editor?.isActive('orderedList'));
// Numbering style of the ordered list at the cursor (null when not in one).
let currentOrderedType = $derived.by<OrderedListType | null>(() => {
if (tick < 0 || !editor || !editor.isActive('orderedList')) return null;
return (editor.getAttributes('orderedList').listStyleType ?? DEFAULT_ORDERED_TYPE) as OrderedListType;
});
let olMenuOpen = $state(false);
// Toggle a plain (decimal) ordered list — the split button's main half.
function toggleOrderedList() {
olMenuOpen = false;
editor?.chain().focus().toggleOrderedList().run();
}
// Apply a numbering style; creates the list first if the selection isn't in one.
function applyOrderedType(key: OrderedListType) {
if (!editor) return;
olMenuOpen = false;
const chain = editor.chain().focus();
if (!editor.isActive('orderedList')) chain.toggleOrderedList();
chain.updateAttributes('orderedList', { listStyleType: key }).run();
}
function olMenuClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) olMenuOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="toolbar">
{#if editor}
<div class="toolbar-group">
<button
class:active={isBold}
onclick={() => {
if (isHeading) {
if (hasNormalWeight) {
// Restore heading's natural bold
editor?.chain().focus().unsetFontWeight().run();
} else {
// Override heading bold with explicit font-weight: normal
editor?.chain().focus().setFontWeight('normal').run();
}
} else {
editor?.chain().focus().toggleBold().run();
}
}}
title="Bold (Ctrl+B)"
>
<strong>B</strong>
</button>
<button
class:active={isItalic}
onclick={() => editor?.chain().focus().toggleItalic().run()}
title="Italic (Ctrl+I)"
>
<em>I</em>
</button>
<button
class:active={isUnderline}
onclick={() => editor?.chain().focus().toggleUnderline().run()}
title="Underline (Ctrl+U)"
>
<u>U</u>
</button>
<button
class:active={isStrike}
onclick={() => editor?.chain().focus().toggleStrike().run()}
title="Strikethrough (Ctrl+Shift+S)"
>
<s>S</s>
</button>
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={isH1}
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title="Heading 1"
>
H1
</button>
<button
class:active={isH2}
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title="Heading 2"
>
H2
</button>
<button
class:active={isH3}
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title="Heading 3"
>
H3
</button>
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={isBulletList}
onclick={() => editor?.chain().focus().toggleBulletList().run()}
title="Bullet list"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="2" cy="4" r="1.5" fill="currentColor"/>
<circle cx="2" cy="8" r="1.5" fill="currentColor"/>
<circle cx="2" cy="12" r="1.5" fill="currentColor"/>
<line x1="5.5" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="ol-picker" use:olMenuClickOutside>
<div class="ol-split">
<button
class="ol-main"
class:active={isOrderedList}
onclick={toggleOrderedList}
title="Ordered list"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="0" y="5.5" font-size="5" font-family="sans-serif" fill="currentColor">1.</text>
<text x="0" y="9.5" font-size="5" font-family="sans-serif" fill="currentColor">2.</text>
<text x="0" y="13.5" font-size="5" font-family="sans-serif" fill="currentColor">3.</text>
<line x1="5.5" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class="ol-chevron"
class:active={isOrderedList}
onclick={() => (olMenuOpen = !olMenuOpen)}
title="Numbering style"
aria-haspopup="menu"
aria-expanded={olMenuOpen}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{#if olMenuOpen}
<div class="ol-dropdown" role="menu">
<div class="ol-section-label">Numbering</div>
{#each ORDERED_LIST_TYPES as t}
<button
class="ol-option"
class:active={currentOrderedType === t.key}
onclick={() => applyOrderedType(t.key)}
title={t.label}
role="menuitemradio"
aria-checked={currentOrderedType === t.key}
>
<span class="ol-option-preview">{t.preview}</span>
<span class="ol-option-label">{t.label}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<AlignButton {editor} {tick} />
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<HistoryButton {editor} {tick} direction="undo" />
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<HistoryButton {editor} {tick} direction="redo" />
</div>
{/if}
</div>
<style>
.toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 1rem;
background: var(--color-toolbar-bg);
position: sticky;
top: 0;
z-index: 200;
}
.toolbar-group {
display: flex;
gap: 2px;
}
.toolbar-separator {
width: 1px;
height: 1.5rem;
background: var(--color-border);
margin: 0 0.5rem;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-sans);
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: var(--color-btn-hover);
}
button.active {
background: var(--color-primary);
color: white;
}
button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Ordered-list split button: main toggle + chevron that opens the numbering
style menu, rendered as one joined control. */
.ol-picker {
position: relative;
}
.ol-split {
display: inline-flex;
align-items: center;
}
.ol-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.ol-chevron {
min-width: unset;
width: 1rem;
padding: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.ol-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
min-width: 170px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
padding: 2px;
display: flex;
flex-direction: column;
}
.ol-section-label {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text);
font-family: var(--font-sans);
user-select: none;
}
.ol-option {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
min-width: unset;
height: auto;
padding: 0.35rem 0.6rem;
border-radius: calc(var(--radius) - 2px);
justify-content: flex-start;
text-align: left;
}
.ol-option:hover {
background: var(--color-btn-hover);
}
.ol-option.active {
background: var(--color-primary);
color: white;
}
.ol-option-preview {
display: inline-flex;
justify-content: center;
min-width: 1.6rem;
font-family: var(--font-serif, serif);
font-size: 0.9rem;
}
.ol-option-label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.ol-option.active .ol-option-label {
color: white;
}
</style>