import { setBlockType, splitBlock } from '@tiptap/pm/commands' import type { EditorView } from '@tiptap/pm/view' import type { ResolvedPos } from '@tiptap/pm/model' /** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */ export type BlockFormat = 'paragraph' | 1 | 2 | 3 type LineBounds = { /** ابتدای بلوک جاری */ start: number /** پایان بلوک جاری */ end: number /** ابتدای خط جاری (پس از نزدیک‌ترین شکست نرم قبل از نشانگر) */ lineStart: number /** پایان خط جاری (نزدیک‌ترین شکست نرم پس از نشانگر) */ lineEnd: number /** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */ before: number | null /** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */ after: number | null } /** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (
) از هم جدا شده‌اند */ function lineBounds($from: ResolvedPos): LineBounds { const start = $from.start() const end = $from.end() let lineStart = start let lineEnd = end let before: number | null = null let after: number | null = null $from.parent.forEach((node, offset) => { if (node.type.name !== 'hardBreak') return const pos = start + offset if (pos < $from.pos) { lineStart = pos + 1 before = pos } else if (after === null) { lineEnd = pos after = pos } }) return { start, end, lineStart, lineEnd, before, after } } /** * جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسبانده‌شده از Word). * بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض می‌کند. * اگر گزینش از خط عبور کند (قالب‌بندی چندخطیِ عمدی) دست‌نخورده می‌ماند. */ function isolateCurrentLine(view: EditorView): boolean { const { state } = view const { selection } = state const bounds = lineBounds(selection.$from) if (bounds.before === null && bounds.after === null) return false const selectionInsideLine = selection.empty || (selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd) if (!selectionInsideLine) return false const { tr } = state if (bounds.after !== null) { tr.delete(bounds.after, bounds.after + 1) tr.split(bounds.after) } if (bounds.before !== null) { tr.delete(bounds.before, bounds.before + 1) tr.split(bounds.before) } if (!tr.docChanged) return false view.dispatch(tr) return true } /** * قالب‌بندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است * و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به * پاراگراف برمی‌گرداند (رفتار toggle). */ export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean { view.focus() isolateCurrentLine(view) const { state } = view const paragraph = state.schema.nodes.paragraph const heading = state.schema.nodes.heading if (!paragraph || !heading) return false if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view) const current = state.selection.$from.parent const sameHeading = current.type === heading && current.attrs.level === format return sameHeading ? setBlockType(paragraph)(state, view.dispatch, view) : setBlockType(heading, { level: format })(state, view.dispatch, view) } /** * Enter داخل تیتر: بلوک تازه پاراگراف می‌شود (نه تیتر تازه) تا قالب تیتر به خط * بعدی سرایت نکند و مرز بلوک‌ها تمیز بماند. * این هندلر پیش از keymap پیش‌فرض اجرا می‌شود و فقط همین حالت را می‌گیرد. */ export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean { if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { return false } const { state } = view const { selection } = state const paragraph = state.schema.nodes.paragraph if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false // خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل می‌شود if (!splitBlock(state, view.dispatch, view)) return false setBlockType(paragraph)(view.state, view.dispatch, view) return true }