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:
parent
1d00d86709
commit
c6d1554215
@ -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() }}
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user