feat: add font selection to extended toolbar
This commit is contained in:
1 parent
b7ab64948d
commit
747ac44a34
5 files changed
+180
-15
No files matched your search
+3
-2
@@ -30,12 +30,13 @@
|
||||
- Dynamic page snapping (editor always shows full pages)
|
||||
- Print-ready @media print styles with @page A4 sizing
|
||||
- add bottom toolbar to show page number
|
||||
- Zoom feature
|
||||
- font size
|
||||
|
||||
### Not yet implemented
|
||||
- ODT import / open existing file
|
||||
- font size
|
||||
- undo history
|
||||
- Images, tables
|
||||
- Zoom feature
|
||||
- Custom right-click context menu (formatting, cut/copy)
|
||||
- Multi-document management
|
||||
- PWA / offline support
|
||||
|
||||
Generated
+28
@@ -12,6 +12,7 @@
|
||||
"@tiptap/extension-bold": "^3.23.6",
|
||||
"@tiptap/extension-bullet-list": "^3.23.6",
|
||||
"@tiptap/extension-document": "^3.23.6",
|
||||
"@tiptap/extension-font-family": "^3.23.6",
|
||||
"@tiptap/extension-heading": "^3.23.6",
|
||||
"@tiptap/extension-history": "^3.23.6",
|
||||
"@tiptap/extension-italic": "^3.23.6",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@tiptap/extension-paragraph": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/extension-text": "^3.23.6",
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
"@tiptap/extension-underline": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"odf-kit": "^0.13.4"
|
||||
@@ -509,6 +511,19 @@
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-font-family": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-font-family/-/extension-font-family-3.23.6.tgz",
|
||||
"integrity": "sha512-V7ttSAWf+vhXU9fACPOqkIiBjNI4XRDhVpghloDgYt1LqBczyFMey97cG8tZpkNVvWmVpy/wJpHHl46AHkLc1g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-text-style": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.6.tgz",
|
||||
@@ -628,6 +643,19 @@
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.6.tgz",
|
||||
"integrity": "sha512-Auasgj469wkQ5ip+Zi2gaRzvqxx9qKG58+1mkT8yPE3QAGnOIg/AaKyQ7pTV77UyL3FHvLnU+KsWCad+qcobww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.6.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@tiptap/extension-bold": "^3.23.6",
|
||||
"@tiptap/extension-bullet-list": "^3.23.6",
|
||||
"@tiptap/extension-document": "^3.23.6",
|
||||
"@tiptap/extension-font-family": "^3.23.6",
|
||||
"@tiptap/extension-heading": "^3.23.6",
|
||||
"@tiptap/extension-history": "^3.23.6",
|
||||
"@tiptap/extension-italic": "^3.23.6",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@tiptap/extension-paragraph": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/extension-text": "^3.23.6",
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
"@tiptap/extension-underline": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6",
|
||||
"odf-kit": "^0.13.4"
|
||||
|
||||
@@ -7,18 +7,87 @@
|
||||
let isAlignCenter = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'center' }));
|
||||
let isAlignRight = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'right' }));
|
||||
let isAlignJustify = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'justify' }));
|
||||
|
||||
const FONTS = [
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Verdana', label: 'Verdana' },
|
||||
{ value: 'Trebuchet MS', label: 'Trebuchet MS' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
{ value: 'Times New Roman', label: 'Times New Roman' },
|
||||
{ value: 'Courier New', label: 'Courier New' },
|
||||
] as const;
|
||||
|
||||
// Returns the uniform font of the selection, or '' when fonts are mixed.
|
||||
let currentFont = $derived.by(() => {
|
||||
if (tick < 0 || !editor) return '';
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) {
|
||||
const marks = editor.state.storedMarks ?? editor.state.selection.$head.marks();
|
||||
return marks.find(m => m.type.name === 'textStyle')?.attrs.fontFamily ?? '';
|
||||
}
|
||||
let font: string | undefined;
|
||||
let mixed = false;
|
||||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||
if (mixed || !node.isText) return;
|
||||
const f: string = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontFamily ?? '';
|
||||
if (font === undefined) font = f;
|
||||
else if (font !== f) mixed = true;
|
||||
});
|
||||
return mixed ? '' : (font ?? '');
|
||||
});
|
||||
|
||||
let fontOpen = $state(false);
|
||||
let savedFrom: number | null = null;
|
||||
let savedTo: number | null = null;
|
||||
|
||||
function openFontPicker() {
|
||||
if (!editor) return;
|
||||
// Save selection before the picker button steals focus.
|
||||
savedFrom = editor.state.selection.from;
|
||||
savedTo = editor.state.selection.to;
|
||||
fontOpen = !fontOpen;
|
||||
}
|
||||
|
||||
function pickFont(value: string) {
|
||||
if (!editor) return;
|
||||
fontOpen = false;
|
||||
const from = savedFrom ?? editor.state.selection.from;
|
||||
const to = savedTo ?? editor.state.selection.to;
|
||||
savedFrom = null;
|
||||
savedTo = null;
|
||||
editor.chain().focus().setTextSelection({ from, to }).setFontFamily(value).run();
|
||||
}
|
||||
|
||||
function fontPickerClickOutside(node: HTMLElement) {
|
||||
function handler(e: MouseEvent) {
|
||||
if (!node.contains(e.target as Node)) fontOpen = false;
|
||||
}
|
||||
window.addEventListener('mousedown', handler);
|
||||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar-expanded">
|
||||
{#if editor}
|
||||
<div class="toolbar-group">
|
||||
<select
|
||||
class="font-select"
|
||||
disabled
|
||||
title="Font family (coming soon)"
|
||||
>
|
||||
<option>Sans-serif</option>
|
||||
</select>
|
||||
<div class="font-picker" use:fontPickerClickOutside>
|
||||
<button class="font-trigger" onclick={openFontPicker} title="Font Name">
|
||||
<span class="font-trigger-label" style={currentFont ? `font-family: ${currentFont}` : ''}>{currentFont}</span>
|
||||
<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>
|
||||
{#if fontOpen}
|
||||
<div class="font-dropdown">
|
||||
{#each FONTS as font}
|
||||
<button
|
||||
class="font-option"
|
||||
class:active={currentFont === font.value}
|
||||
style="font-family: {font.value}"
|
||||
onclick={() => pickFont(font.value)}
|
||||
>{font.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
@@ -178,18 +247,80 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.font-select {
|
||||
.font-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.font-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
padding: 0 0.4rem 0 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-sans);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
max-width: 110px;
|
||||
cursor: pointer;
|
||||
width: 145px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.font-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.font-trigger-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.font-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.font-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s;
|
||||
min-width: unset;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.font-option:hover {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.font-option.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.font-size-select {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Text from '@tiptap/extension-text';
|
||||
import Bold from '@tiptap/extension-bold';
|
||||
import Italic from '@tiptap/extension-italic';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { TextStyle, FontFamily } from '@tiptap/extension-text-style';
|
||||
import Heading from '@tiptap/extension-heading';
|
||||
import BulletList from '@tiptap/extension-bullet-list';
|
||||
import OrderedList from '@tiptap/extension-ordered-list';
|
||||
@@ -19,6 +20,8 @@ export const extensions = [
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
TextStyle,
|
||||
FontFamily,
|
||||
Heading.configure({ levels: [1, 2, 3] }),
|
||||
BulletList,
|
||||
OrderedList,
|
||||
|
||||
Reference in new issue
Block a user