Editor: font-size presets + image insert/export support

Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
  Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
  API, EditorCore wiring, and md/html/docx export support for images

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 09:05:40 -07:00
parent 58c326d0cf
commit 6e6e4edce7
16 changed files with 1301 additions and 35 deletions
+57
View File
@@ -4,6 +4,17 @@ import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import Link from '@tiptap/extension-link'
import { Color } from '@tiptap/extension-color'
import TextStyle from '@tiptap/extension-text-style'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import { FontSize } from './FontSize'
import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard'
@@ -103,6 +114,21 @@ function parseDoc(raw: string): object | undefined {
return undefined
}
// uploadImageInto sends an image file to the store and inserts the returned URL
// as an image node — at `pos` if given (a drop point), otherwise at the current
// selection (a paste). Shared by the paste/drop handlers and the toolbar button.
export async function uploadImageInto(view: EditorView, file: File, pos?: number) {
try {
const { url } = await api.uploadImage(file)
const { schema } = view.state
const node = schema.nodes.image.create({ src: url })
const at = pos ?? view.state.selection.from
view.dispatch(view.state.tr.insert(at, node))
} catch (err) {
console.error('image upload failed', err)
}
}
interface HoverState {
suggestion: Suggestion
top: number
@@ -188,6 +214,16 @@ export function EditorCore({
extensions: [
StarterKit,
Underline,
TextStyle,
Color,
FontSize,
Highlight.configure({ multicolor: true }),
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow' } }),
Image.configure({ inline: false, HTMLAttributes: { class: 'petal-image' } }),
Table.configure({ resizable: true, HTMLAttributes: { class: 'petal-table' } }),
TableRow,
TableHeader,
TableCell,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder: 'Start writing…' }),
CharacterCount,
@@ -197,6 +233,27 @@ export function EditorCore({
content: parseDoc(initialContent),
editorProps: {
attributes: { class: 'petal-prose focus:outline-none' },
// Dropping or pasting an image file uploads it and inserts it at the drop
// point (or the cursor for a paste). Returns true to consume the event so
// ProseMirror doesn't also try to handle the raw file. Non-image pastes
// fall through to the default handler.
handleDrop: (view, event) => {
const files = (event as DragEvent).dataTransfer?.files
const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
if (!image) return false
event.preventDefault()
const coords = view.posAtCoords({ left: (event as DragEvent).clientX, top: (event as DragEvent).clientY })
uploadImageInto(view, image, coords?.pos)
return true
},
handlePaste: (view, event) => {
const files = event.clipboardData?.files
const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
if (!image) return false
event.preventDefault()
uploadImageInto(view, image)
return true
},
},
onFocus: () => onFocusMode?.(),
onUpdate: ({ editor }) => {
+51
View File
@@ -0,0 +1,51 @@
import { Extension } from '@tiptap/core'
// FontSize adds a `fontSize` attribute to the textStyle mark so a writer can
// pick a size preset (Small / Normal / Large / Title) from the toolbar. It rides
// on TextStyle (already loaded) rather than introducing a new mark, so it stacks
// cleanly with color and other inline styling.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType
unsetFontSize: () => ReturnType
}
}
}
export const FontSize = Extension.create({
name: 'fontSize',
addOptions() {
return { types: ['textStyle'] }
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) => element.style.fontSize || null,
renderHTML: (attributes) =>
attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {},
},
},
},
]
},
addCommands() {
return {
setFontSize:
(size) =>
({ chain }) =>
chain().setMark('textStyle', { fontSize: size }).run(),
unsetFontSize:
() =>
({ chain }) =>
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
}
},
})