feat(editor): implement US-EDITOR-MOBILE with fixed premium toolbar (44px), action sheet (bottom sheet) for block and AI actions, select all block text, and performance fallbacks
This commit is contained in:
190
memento-note/components/mobile-action-sheet.tsx
Normal file
190
memento-note/components/mobile-action-sheet.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import {
|
||||
Trash2, Copy, FileText, Sparkles, Lightbulb, Scissors,
|
||||
Wand2, Expand, X, CheckSquare, Quote, Heading1, Heading2, Heading3
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export type MobileActionSheetProps = {
|
||||
editor: Editor | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MobileActionSheet({
|
||||
editor,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: MobileActionSheetProps) {
|
||||
const { t } = useLanguage()
|
||||
const sheetRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (sheetRef.current && !sheetRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen || !editor) return null
|
||||
|
||||
const handleSelectAllBlock = () => {
|
||||
const { from } = editor.state.selection
|
||||
const $pos = editor.state.doc.resolve(from)
|
||||
const depth = $pos.depth
|
||||
if (depth === 0) return
|
||||
|
||||
const start = $pos.start(1)
|
||||
const end = $pos.end(1)
|
||||
|
||||
editor.chain().focus().setTextSelection({ from: start, to: end }).run()
|
||||
toast.success(t('richTextEditor.blockSelected') || 'Bloc sélectionné en entier')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDuplicateBlock = () => {
|
||||
const { from } = editor.state.selection
|
||||
const $pos = editor.state.doc.resolve(from)
|
||||
const depth = $pos.depth
|
||||
if (depth === 0) return
|
||||
|
||||
const start = $pos.before(1)
|
||||
const end = $pos.after(1)
|
||||
const nodeText = editor.state.doc.slice(start, end)
|
||||
|
||||
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
|
||||
toast.success(t('richTextEditor.blockDuplicated') || 'Bloc dupliqué')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDeleteBlock = () => {
|
||||
const { from } = editor.state.selection
|
||||
const $pos = editor.state.doc.resolve(from)
|
||||
const depth = $pos.depth
|
||||
if (depth === 0) return
|
||||
|
||||
const start = $pos.before(1)
|
||||
const end = $pos.after(1)
|
||||
editor.chain().focus().deleteRange({ from: start, to: end }).run()
|
||||
toast.success(t('richTextEditor.blockDeleted') || 'Bloc supprimé')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleAiAction = (action: 'clarify' | 'shorten' | 'improve' | 'expand') => {
|
||||
// Déclenche l'appel IA en émettant un événement personnalisé ou en modifiant le contenu
|
||||
// Réutilisons l'API IA existante ou ouvrons le panneau IA d'actions
|
||||
onClose()
|
||||
const tab = action === 'improve' ? 'actions' : 'chat'
|
||||
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } }))
|
||||
toast.info(t('richTextEditor.aiActionStarted') || 'IA Note sollicitée...')
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="mobile-action-sheet-overlay">
|
||||
<div ref={sheetRef} className="mobile-action-sheet-content">
|
||||
<div className="mobile-action-sheet-header">
|
||||
<div className="drag-indicator"></div>
|
||||
<button type="button" className="close-btn" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-action-sheet-body">
|
||||
{/* Section 1 : Actions de bloc */}
|
||||
<div className="mobile-action-sheet-section">
|
||||
<h4 className="section-title">Actions sur le bloc</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
|
||||
<FileText size={20} className="text-blue-500" />
|
||||
<span>Sélectionner tout</span>
|
||||
</button>
|
||||
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
|
||||
<Copy size={20} className="text-emerald-500" />
|
||||
<span>Dupliquer</span>
|
||||
</button>
|
||||
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
|
||||
<Trash2 size={20} className="text-rose-500" />
|
||||
<span>Supprimer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2 : IA Note */}
|
||||
<div className="mobile-action-sheet-section">
|
||||
<h4 className="section-title">IA Note</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
|
||||
<Wand2 size={18} className="text-purple-500 animate-pulse" />
|
||||
<span className="text-[10px]">Améliorer</span>
|
||||
</button>
|
||||
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('clarify')}>
|
||||
<Lightbulb size={18} className="text-amber-500" />
|
||||
<span className="text-[10px]">Clarifier</span>
|
||||
</button>
|
||||
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('shorten')}>
|
||||
<Scissors size={18} className="text-indigo-500" />
|
||||
<span className="text-[10px]">Raccourcir</span>
|
||||
</button>
|
||||
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('expand')}>
|
||||
<Expand size={18} className="text-teal-500" />
|
||||
<span className="text-[10px]">Développer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3 : Format de bloc */}
|
||||
<div className="mobile-action-sheet-section">
|
||||
<h4 className="section-title">Convertir le format</h4>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
||||
<button
|
||||
type="button"
|
||||
className="format-pill-btn"
|
||||
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
|
||||
>
|
||||
Paragraphe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="format-pill-btn"
|
||||
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }}
|
||||
>
|
||||
<Heading1 size={14} className="inline mr-1" /> Titre 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="format-pill-btn"
|
||||
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }}
|
||||
>
|
||||
<Heading2 size={14} className="inline mr-1" /> Titre 2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="format-pill-btn"
|
||||
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }}
|
||||
>
|
||||
<Heading3 size={14} className="inline mr-1" /> Titre 3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="format-pill-btn"
|
||||
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }}
|
||||
>
|
||||
<Quote size={14} className="inline mr-1" /> Citation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
143
memento-note/components/mobile-editor-toolbar.tsx
Normal file
143
memento-note/components/mobile-editor-toolbar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import {
|
||||
Bold, Italic, Highlighter, Link2, List, CheckSquare,
|
||||
Heading, Code, Sparkles, MessageSquare, Quote, AlignLeft
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type MobileEditorToolbarProps = {
|
||||
editor: Editor | null
|
||||
onOpenActionSheet: () => void
|
||||
onInsertImage?: () => void
|
||||
}
|
||||
|
||||
export function MobileEditorToolbar({
|
||||
editor,
|
||||
onOpenActionSheet,
|
||||
onInsertImage,
|
||||
}: MobileEditorToolbarProps) {
|
||||
if (!editor) return null
|
||||
|
||||
// Format states
|
||||
const isBold = editor.isActive('bold')
|
||||
const isItalic = editor.isActive('italic')
|
||||
const isHighlight = editor.isActive('highlight')
|
||||
const isLink = editor.isActive('link')
|
||||
const isBulletList = editor.isActive('bulletList')
|
||||
const isTaskList = editor.isActive('taskList')
|
||||
const isCodeBlock = editor.isActive('codeBlock')
|
||||
const isHeading = editor.isActive('heading')
|
||||
|
||||
const toggleHeadingCycle = () => {
|
||||
if (editor.isActive('heading', { level: 1 })) {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
} else if (editor.isActive('heading', { level: 2 })) {
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
} else if (editor.isActive('heading', { level: 3 })) {
|
||||
editor.chain().focus().setParagraph().run()
|
||||
} else {
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkPress = () => {
|
||||
if (isLink) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
} else {
|
||||
const url = window.prompt('URL:')
|
||||
if (url && url.trim()) {
|
||||
editor.chain().focus().setLink({ href: url.trim() }).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-editor-toolbar-container">
|
||||
<div className="mobile-editor-toolbar-scroll">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isBold && 'active')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
aria-label="Bold"
|
||||
>
|
||||
<Bold size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isItalic && 'active')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
aria-label="Italic"
|
||||
>
|
||||
<Italic size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isHighlight && 'active')}
|
||||
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||
aria-label="Highlight"
|
||||
>
|
||||
<Highlighter size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isLink && 'active')}
|
||||
onClick={handleLinkPress}
|
||||
aria-label="Link"
|
||||
>
|
||||
<Link2 size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isBulletList && 'active')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
aria-label="Bullet List"
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isTaskList && 'active')}
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
aria-label="Task List"
|
||||
>
|
||||
<CheckSquare size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isHeading && 'active')}
|
||||
onClick={toggleHeadingCycle}
|
||||
aria-label="Heading"
|
||||
>
|
||||
<Heading size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mobile-toolbar-btn', isCodeBlock && 'active')}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
aria-label="Code Block"
|
||||
>
|
||||
<Code size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-toolbar-btn highlight-btn"
|
||||
onClick={onOpenActionSheet}
|
||||
aria-label="Plus"
|
||||
>
|
||||
<Sparkles size={18} className="text-amber-500 animate-pulse" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import { EditorBlockDragHandle } from './editor-block-drag-handle'
|
||||
import { BlockActionMenu } from './block-action-menu'
|
||||
import { SmartPasteMenu } from './smart-paste-menu'
|
||||
import { SmartPasteExtendedMenu } from './smart-paste-extended-menu'
|
||||
import { MobileEditorToolbar } from './mobile-editor-toolbar'
|
||||
import { MobileActionSheet } from './mobile-action-sheet'
|
||||
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
|
||||
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
@@ -285,6 +287,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
isImage?: boolean
|
||||
isVideo?: boolean
|
||||
} | null>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [actionSheetOpen, setActionSheetOpen] = useState(false)
|
||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||
@@ -300,6 +304,14 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
onChangeRef.current?.(html)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
// Listen to the slash-command event to open the BlockPicker
|
||||
useEffect(() => {
|
||||
const openHandler = () => setBlockPickerOpen(true)
|
||||
@@ -1105,6 +1117,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
/>
|
||||
)}
|
||||
|
||||
{editor && isMobile && (
|
||||
<MobileEditorToolbar
|
||||
editor={editor}
|
||||
onOpenActionSheet={() => setActionSheetOpen(true)}
|
||||
onInsertImage={imageInsert.requestInsert}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editor && actionSheetOpen && (
|
||||
<MobileActionSheet
|
||||
editor={editor}
|
||||
isOpen={actionSheetOpen}
|
||||
onClose={() => setActionSheetOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageInsert.open && (
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user