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 }) {
|
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [isReading, setIsReading] = useState(false)
|
const [isReading, setIsReading] = useState(false)
|
||||||
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
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() {
|
function startReading() {
|
||||||
if (!('speechSynthesis' in window)) return
|
if (!('speechSynthesis' in window)) return
|
||||||
|
|
||||||
@ -48,6 +102,13 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
|
|
||||||
if (!editor) return null
|
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) {
|
async function handleImageFile(e) {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@ -91,9 +152,18 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
|
|
||||||
<span className="toolbar-sep" />
|
<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')}
|
{ Icon: AlignLeft, align: 'left', label: 'Align left' },
|
||||||
{tb('R', () => editor.chain().focus().setTextAlign('right').run(), editor.isActive({ textAlign: 'right' }), 'Align right')}
|
{ 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" />
|
<span className="toolbar-sep" />
|
||||||
|
|
||||||
@ -129,6 +199,14 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
|||||||
|
|
||||||
<span className="toolbar-sep" />
|
<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
|
<button
|
||||||
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
|
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
|
||||||
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}
|
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}
|
||||||
|
|||||||
@ -1069,7 +1069,8 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
.editor-body .ProseMirror mark {
|
.editor-body .ProseMirror mark {
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0.05em 0.15em;
|
padding: 0.05em 0.15em;
|
||||||
color: #111;
|
/* Keep text readable on coloured backgrounds regardless of theme */
|
||||||
|
color: #111 !important;
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
@ -1478,6 +1479,13 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
50% { opacity: 0.45; }
|
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 ───────────────────────────────────── */
|
/* ── Writing Prompt ───────────────────────────────────── */
|
||||||
|
|
||||||
.prompt-wrap { position: relative; }
|
.prompt-wrap { position: relative; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user