diff --git a/frontend/README.md b/frontend/README.md index c556c82..f025e0f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -8,15 +8,16 @@ The **Pinar** font is installed and served locally. The application no longer re ## CMS, SEO & Editor Features -The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities: +The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX: -* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility. -* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags. -* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title. +* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting. +* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema. +* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title. * **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links. * **Custom Tiptap WYSIWYG Editor:** - * Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px). - * A custom feature to wrap English text blocks in LTR styling (`` at 12px) for perfect bi-directional text alignment. + * **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) with specific font scaling (RTL Base: 14px, LTR Base: 12px) mapped to a fixed toolbar that maintains layout stability. + * **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `
`. Features strictly isolated UI deletion controls (separate 'X' buttons to delete either the image entirely or just the caption text). + * **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`). * **Full Jalali (Shamsi) Calendar Integration:** * Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. * Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian. @@ -50,4 +51,3 @@ npm run build * `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD). * `src/data/site.ts` — Shared TypeScript data models and interfaces. - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 73c9e6d..195dfd0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "lucide-react": "0.522.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.88.0", "react-multi-date-picker": "^4.5.2", "react-router-dom": "^6.26.2", "react-select": "^5.10.2", @@ -4484,6 +4485,22 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-hook-form": { + "version": "7.88.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz", + "integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d1c832f..d771d65 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "lucide-react": "0.522.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.88.0", "react-multi-date-picker": "^4.5.2", "react-router-dom": "^6.26.2", "react-select": "^5.10.2", diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index a3f4c9f..76b16e9 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { Controller, useController, useForm } from 'react-hook-form' import { Link, Navigate, useNavigate, useParams } from 'react-router-dom' import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react' import Swal from 'sweetalert2' @@ -9,8 +10,11 @@ import { ArticleEditor } from '../components/admin/ArticleEditor' import { SeoChecklist } from '../components/admin/SeoChecklist' import { ImageField } from '../components/admin/ImageField' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' -import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' +import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' +import type { CategoryOption } from '../components/admin/LuxeSelect' import { ARTICLE_CATEGORIES } from './entities' +import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' +import { formatReadingTime } from '../utils/readingTime' import type { Article, ArticleBodyDoc } from '../data/site' const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc @@ -18,6 +22,30 @@ const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown a /** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */ type Draft = Omit & { body: ArticleBodyDoc; body_en: ArticleBodyDoc } +/** + * فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند: + * عنوان (مبنای Alt پویا)، دسته‌بندی و متادیتای تصویر شاخص. + * `category` در فرم یک شیء تک‌مقداری {value,label} یا null است + * که هنگام ذخیره به رشته (ستون category در بک‌اند) نگاشت می‌شود. + */ +type ArticleMetaForm = { + title: string + category: CategoryOption | null + image_alt: string + image_title: string +} + +/** مقدار رشته‌ای ذخیره‌شده → گزینه react-select (یا null وقتی خالی است) */ +function toCategoryOption(value: string): CategoryOption | null { + const category = (value ?? '').trim() + return category ? { label: category, value: category } : null +} + +/** گزینه react-select → مقدار رشته‌ای ذخیره‌سازی در بک‌اند */ +function categoryValue(option: CategoryOption | null): string { + return option?.value.trim() ?? '' +} + /** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */ function normalizeTags(value: unknown): string[] { if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean) @@ -57,6 +85,7 @@ export function ArticleWritePage() { tags: [], image: '', image_alt: '', + image_title: '', author: '', meta_description: '', read: '۵ دقیقه', @@ -68,6 +97,25 @@ export function ArticleWritePage() { status: 'draft', seo_keyphrase: '', }) + + /** + * فرم متادیتا با React Hook Form — منبع نمایش فیلدها. + * هر تغییر، هم فرم و هم draft (مبنای ذخیره‌سازی خودکار) را به‌روز می‌کند. + */ + const { control, watch, reset } = useForm({ + defaultValues: { + title: '', + category: toCategoryOption(ARTICLE_CATEGORIES[0]), + image_alt: '', + image_title: '', + }, + mode: 'onChange', + }) + /** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */ + const watchedTitle = watch('title') + const { field: imageAltField } = useController({ name: 'image_alt', control }) + const { field: imageTitleField } = useController({ name: 'image_title', control }) + const [createdId, setCreatedId] = useState(null) const [deleting, setDeleting] = useState(false) const draftRef = useRef(draft) @@ -110,6 +158,13 @@ export function ArticleWritePage() { : EMPTY_DOC, }) setCreatedId(article.id) + // فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود + reset({ + title: article.title ?? '', + category: toCategoryOption(article.category ?? ''), + image_alt: article.image_alt ?? '', + image_title: article.image_title ?? '', + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [article?.id]) @@ -209,6 +264,11 @@ export function ArticleWritePage() { // در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند const suf = (name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft + // متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود + const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX] + .filter(Boolean) + .join(' - ') + return (
{/* نوار بالا */} @@ -301,31 +361,49 @@ export function ArticleWritePage() { : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}

- {/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جای‌نگهدار می‌ماند */} - ( + { + field.onChange(event.target.value) + queueSave({ [suf('title')]: event.target.value }) + }} + placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'} + aria-label="عنوان مقاله" + className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none" + /> + )} />
- {/* تصویر شاخص + متن جایگزین */} + {/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
{ queueSave({ image: url }) return true }} onSaveAlt={async (alt) => { + imageAltField.onChange(alt) queueSave({ image_alt: alt }) return true }} + onSaveTitle={async (imageTitle) => { + imageTitleField.onChange(imageTitle) + queueSave({ image_title: imageTitle }) + return true + }} />
@@ -334,15 +412,12 @@ export function ArticleWritePage() { } onChange={(json) => { - // محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن — به زبان جاری - const textContent = JSON.stringify(json) - const wordCount = textContent.split(/\s+/).length + // زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری + const minutes = formatReadingTime(json) if (editLang === 'en') { - const minutes = Math.max(1, Math.ceil(wordCount / 200)) queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` }) } else { - const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه' - queueSave({ body: json, read: calculatedReadTime }) + queueSave({ body: json, read: `${minutes} دقیقه` }) } }} /> @@ -424,11 +499,20 @@ export function ArticleWritePage() {
- queueSave({ category })} - isClearable={false} + {/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */} + ( + { + field.onChange(option) + queueSave({ category: categoryValue(option) }) + }} + /> + )} />
diff --git a/frontend/src/admin/EntityDrawer.tsx b/frontend/src/admin/EntityDrawer.tsx index d9960d4..b934b3f 100644 --- a/frontend/src/admin/EntityDrawer.tsx +++ b/frontend/src/admin/EntityDrawer.tsx @@ -9,7 +9,7 @@ import { editableFields } from './entities' import type { EntityConfig, FieldConfig } from './entities' import { ImageField } from '../components/admin/ImageField' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' -import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' +import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' import { ArticleEditor } from '../components/admin/ArticleEditor' type EntityDrawerProps = { @@ -120,13 +120,21 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
{fields.map((field) => { + const isImage = field.type === 'image' // فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt - const altKey = - field.type === 'image' - ? field.name === 'src' && 'alt' in item - ? 'alt' - : `${field.name}_alt` - : '' + const altKey = isImage + ? field.name === 'src' && 'alt' in item + ? 'alt' + : `${field.name}_alt` + : '' + const imageTitleKey = isImage ? `${field.name}_title` : '' + // پیشنهاد Alt از عنوان رکورد ساخته می‌شود (هرگز از URL) + const imageAltFallback = [ + String(item[config.titleKey] ?? '').trim(), + field.altFallbackSuffix, + ] + .filter(Boolean) + .join(' - ') return ( saveField(field.name, value)} onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined} + onSaveImageTitle={ + imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined + } registerFlush={ field.type === 'wysiwyg' ? (fn) => { @@ -174,8 +186,10 @@ function FieldControl({ altValue, altFallback, placeholder, + imageTitleValue, onSave, onSaveAlt, + onSaveImageTitle, registerFlush, }: { field: FieldConfig @@ -185,12 +199,20 @@ function FieldControl({ altFallback?: string /** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */ placeholder?: unknown + /** مقدار Title تصویر — فقط برای فیلدهای image */ + imageTitleValue?: string onSave: (value: unknown) => Promise onSaveAlt?: (alt: string) => Promise + onSaveImageTitle?: (title: string) => Promise registerFlush?: (fn: () => void) => void }) { const [text, setText] = useState(String(value ?? '')) + // همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه) + useEffect(() => { + setText(String(value ?? '')) + }, [value]) + const commitText = async () => { const next = field.type === 'number' ? Number(text) || 0 : text if (String(value ?? '') === String(next)) return @@ -204,26 +226,34 @@ function FieldControl({ value={String(value ?? '')} alt={altValue} altFallback={altFallback} + title={imageTitleValue} onSave={(url) => onSave(url)} onSaveAlt={onSaveAlt} + onSaveTitle={onSaveImageTitle} /> ) } if (field.type === 'select') { + // مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود + const selectProps = { + value: text, + options: field.options ?? [], + onChange: (next: string) => { + setText(next) + void onSave(next) + }, + } return (

{field.label}

{field.hint &&

{field.hint}

}
- { - setText(next) - void onSave(next) - }} - /> + {field.creatable ? ( + + ) : ( + + )}
) diff --git a/frontend/src/admin/entities.ts b/frontend/src/admin/entities.ts index 5ec00dc..b2690b8 100644 --- a/frontend/src/admin/entities.ts +++ b/frontend/src/admin/entities.ts @@ -1,4 +1,5 @@ import type { SiteData } from '../api' +import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date' @@ -9,10 +10,14 @@ export type FieldConfig = { hint?: string /** فقط برای type === 'select' — گزینه‌های ثابت react-select */ options?: string[] + /** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */ + creatable?: boolean /** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */ ui?: 'lines' | 'tags' /** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */ shared?: boolean + /** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */ + altFallbackSuffix?: string } export type EntityConfig = { @@ -77,8 +82,8 @@ export const ENTITIES: Record = { F('body', 'متن مقاله', 'wysiwyg'), F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'), F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true), - F('image', 'تصویر شاخص', 'image', undefined, true), - F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES, shared: true }), + F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }), + F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }), F('author', 'نویسنده', 'text', undefined, true), F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true), @@ -92,6 +97,8 @@ export const ENTITIES: Record = { category: ARTICLE_CATEGORIES[0], tags: ['مدیریت پروژه', 'ساخت‌وساز'], image: '', + image_alt: '', + image_title: '', author: '', meta_description: '', read: '۵ دقیقه', diff --git a/frontend/src/components/ArticleCard.tsx b/frontend/src/components/ArticleCard.tsx index 55fa57d..a44fea6 100644 --- a/frontend/src/components/ArticleCard.tsx +++ b/frontend/src/components/ArticleCard.tsx @@ -4,6 +4,7 @@ import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import type { Article } from '../data/site' import { formatJalali } from '../utils/date' import { useLang } from '../i18n/lang' +import { toPersianDigits } from '../utils/digits' export function ArticleCard({ article }: { article: Article }) { const { t, href } = useLang() @@ -39,7 +40,7 @@ export function ArticleCard({ article }: { article: Article }) { {formatJalali(article.date)} - {article.read} + {toPersianDigits(article.read)} diff --git a/frontend/src/components/ArticleMeta.tsx b/frontend/src/components/ArticleMeta.tsx index a95cfb8..50dec99 100644 --- a/frontend/src/components/ArticleMeta.tsx +++ b/frontend/src/components/ArticleMeta.tsx @@ -1,6 +1,7 @@ import { CalendarIcon, ClockIcon } from 'lucide-react' import type { Article } from '../data/site' import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date' +import { toPersianDigits } from '../utils/digits' type ArticleMetaProps = { article: Article @@ -41,11 +42,11 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) { {dateLabel} - {/* زمان مطالعه */} + {/* زمان مطالعه — با ارقام فارسی */} {article.read && ( )}
diff --git a/frontend/src/components/admin/ArticleEditor.tsx b/frontend/src/components/admin/ArticleEditor.tsx index 9b5955b..b1286f6 100644 --- a/frontend/src/components/admin/ArticleEditor.tsx +++ b/frontend/src/components/admin/ArticleEditor.tsx @@ -1,53 +1,63 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' -import Image from '@tiptap/extension-image' import Link from '@tiptap/extension-link' import { Mark, mergeAttributes } from '@tiptap/core' import { + AlignLeftIcon, + AlignRightIcon, BoldIcon, - ItalicIcon, Heading1Icon, Heading2Icon, Heading3Icon, ImagePlusIcon, - LanguagesIcon, + ItalicIcon, Link2Icon, ListIcon, ListOrderedIcon, + Loader2Icon, PilcrowIcon, QuoteIcon, Redo2Icon, StrikethroughIcon, UnderlineIcon, Undo2Icon, - Loader2Icon, } from 'lucide-react' -import Swal from 'sweetalert2' import { useAdmin } from '../../admin/AdminContext' import { adminApi } from '../../admin/adminClient' -import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText' +import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link' +import { ArticleImage } from './ArticleImage' +import { EnglishText } from './EnglishText' +import { LinkModal } from './LinkModal' +import type { LinkModalValue } from './LinkModal' -const SmallSizeMark = Mark.create({ +/** + * مارک‌های قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آن‌ها از نوار ابزار برداشته شده و + * فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شده‌اند. + */ +const LegacySmallSizeMark = Mark.create({ name: 'smallSize', - parseHTML() { return [{ tag: 'span[data-small]' }] }, + parseHTML() { + return [{ tag: 'span[data-small]' }] + }, renderHTML({ HTMLAttributes }) { return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0] - } + }, }) +/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */ export const ARTICLE_EXTENSIONS = [ StarterKit.configure({ link: false, heading: { levels: [1, 2, 3] }, }), - Image.configure({ allowBase64: false }), + ArticleImage.configure({ allowBase64: false }), Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, - isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri), + isAllowedUri: (uri: string) => isAllowedHref(uri), }), - SmallSizeMark, + LegacySmallSizeMark, EnglishText, ] @@ -56,6 +66,30 @@ type ArticleEditorProps = { onChange: (json: Record) => void } +type TextDirection = 'rtl' | 'ltr' + +/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */ +type LinkApplyMode = 'selection' | 'insert' | 'range' + +type LinkModalState = { + open: boolean + editing: boolean + mode: LinkApplyMode + range: { from: number; to: number } | null + initial: LinkModalValue +} + +const CLOSED_LINK_MODAL: LinkModalState = { + open: false, + editing: false, + mode: 'selection', + range: null, + initial: { href: '', newTab: true, nofollow: false }, +} + +/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */ +const URL_LOOKBACK = 300 + function ToolButton({ onClick, active = false, @@ -72,6 +106,7 @@ function ToolButton({ type="button" title={label} aria-label={label} + aria-pressed={active} onMouseDown={(e) => e.preventDefault()} onClick={onClick} className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${ @@ -87,10 +122,16 @@ function ToolButton({ const Divider = () =>