Typewriter mode, SVG alignment icons, highlight readability fix

Typewriter mode (⌨ toolbar button):
- Registers TipTap update/selectionUpdate listeners via editor.on()
- On each change, uses coordsAtPos + window.scrollBy to keep cursor
  at 50% of viewport height — instant, no scroll-lag
- Adds 50vh padding-bottom so the last line can reach screen centre
- Toggles .typewriter class on <html>; cleans up on unmount

Alignment buttons:
- Replaced L/C/R text labels with inline SVG stacked-line icons
  (left, centre, right, justify) — standard text-editor appearance
- Added Justify as a fourth option

Highlight readability:
- Added color: #111 !important to .editor-body .ProseMirror mark
  so highlighted text is always dark-on-light regardless of theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-24 21:00:14 -04:00
parent 1d00d86709
commit c6d1554215
2 changed files with 91 additions and 5 deletions

View File

@ -1,10 +1,64 @@
import { useRef, useState } from 'react'
import { useRef, useState, useEffect } from 'react'
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
const fileRef = useRef()
const [isReading, setIsReading] = useState(false)
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
// Typewriter mode
const [typewriterMode, setTypewriterMode] = useState(false)
const typewriterRef = useRef(false)
const twRafRef = useRef(null)
// Keep ref in sync so the editor event closure always reads the latest value
useEffect(() => { typewriterRef.current = typewriterMode }, [typewriterMode])
// Remove the class when the component unmounts (navigating away)
useEffect(() => () => document.documentElement.classList.remove('typewriter'), [])
// Register update / selectionUpdate listeners once editor is ready
useEffect(() => {
if (!editor) return
function scrollToCursor() {
if (!typewriterRef.current) return
if (twRafRef.current) cancelAnimationFrame(twRafRef.current)
twRafRef.current = requestAnimationFrame(() => {
try {
const coords = editor.view.coordsAtPos(editor.state.selection.$head.pos)
const delta = Math.round(coords.top - window.innerHeight * 0.5)
if (Math.abs(delta) > 2) window.scrollBy(0, delta)
} catch { /* ignore if selection is temporarily invalid */ }
})
}
editor.on('update', scrollToCursor)
editor.on('selectionUpdate', scrollToCursor)
return () => {
editor.off('update', scrollToCursor)
editor.off('selectionUpdate', scrollToCursor)
if (twRafRef.current) cancelAnimationFrame(twRafRef.current)
}
}, [editor])
function toggleTypewriter() {
const next = !typewriterMode
setTypewriterMode(next)
typewriterRef.current = next
document.documentElement.classList.toggle('typewriter', next)
// Immediately centre the cursor when switching on
if (next) {
requestAnimationFrame(() => {
try {
const coords = editor.view.coordsAtPos(editor.state.selection.$head.pos)
window.scrollBy(0, Math.round(coords.top - window.innerHeight * 0.5))
} catch {}
})
}
}
//
function startReading() {
if (!('speechSynthesis' in window)) return
@ -48,6 +102,13 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
if (!editor) return null
// Alignment SVG icons (standard stacked-lines look)
const AlignLeft = () => <svg width="13" height="11" viewBox="0 0 13 11" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="13" height="1.8"/><rect x="0" y="3.1" width="9" height="1.8"/><rect x="0" y="6.2" width="11" height="1.8"/><rect x="0" y="9.3" width="7" height="1.8"/></svg>
const AlignCenter = () => <svg width="13" height="11" viewBox="0 0 13 11" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="13" height="1.8"/><rect x="2" y="3.1" width="9" height="1.8"/><rect x="1" y="6.2" width="11" height="1.8"/><rect x="3" y="9.3" width="7" height="1.8"/></svg>
const AlignRight = () => <svg width="13" height="11" viewBox="0 0 13 11" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="13" height="1.8"/><rect x="4" y="3.1" width="9" height="1.8"/><rect x="2" y="6.2" width="11" height="1.8"/><rect x="6" y="9.3" width="7" height="1.8"/></svg>
const AlignJustify = () => <svg width="13" height="11" viewBox="0 0 13 11" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="13" height="1.8"/><rect x="0" y="3.1" width="13" height="1.8"/><rect x="0" y="6.2" width="13" height="1.8"/><rect x="0" y="9.3" width="9" height="1.8"/></svg>
//
async function handleImageFile(e) {
const file = e.target.files[0]
if (!file) return
@ -91,9 +152,18 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
<span className="toolbar-sep" />
{tb('L', () => editor.chain().focus().setTextAlign('left').run(), editor.isActive({ textAlign: 'left' }), 'Align left')}
{tb('C', () => editor.chain().focus().setTextAlign('center').run(), editor.isActive({ textAlign: 'center' }), 'Align centre')}
{tb('R', () => editor.chain().focus().setTextAlign('right').run(), editor.isActive({ textAlign: 'right' }), 'Align right')}
{[
{ Icon: AlignLeft, align: 'left', label: 'Align left' },
{ Icon: AlignCenter, align: 'center', label: 'Align centre' },
{ Icon: AlignRight, align: 'right', label: 'Align right' },
{ Icon: AlignJustify, align: 'justify', label: 'Justify' },
].map(({ Icon, align, label }) => (
<button key={align}
className={`toolbar-btn${editor.isActive({ textAlign: align }) ? ' active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setTextAlign(align).run() }}
title={label}
><Icon /></button>
))}
<span className="toolbar-sep" />
@ -129,6 +199,14 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
<span className="toolbar-sep" />
<button
className={`toolbar-btn${typewriterMode ? ' active' : ''}`}
onMouseDown={e => { e.preventDefault(); toggleTypewriter() }}
title="Typewriter mode — keeps cursor centred on screen"
>
</button>
<button
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}

View File

@ -1069,7 +1069,8 @@ button { cursor: pointer; font-family: inherit; }
.editor-body .ProseMirror mark {
border-radius: 2px;
padding: 0.05em 0.15em;
color: #111;
/* Keep text readable on coloured backgrounds regardless of theme */
color: #111 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@ -1478,6 +1479,13 @@ button { cursor: pointer; font-family: inherit; }
50% { opacity: 0.45; }
}
/* ── Typewriter Mode ──────────────────────────────────── */
/* Extra bottom padding so the last line can scroll to screen centre */
.typewriter .editor-body .ProseMirror {
padding-bottom: 50vh;
}
/* ── Writing Prompt ───────────────────────────────────── */
.prompt-wrap { position: relative; }