122 lines
4.7 KiB
TypeScript
122 lines
4.7 KiB
TypeScript
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
|
|
}
|
|
|
|
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
|
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
|
|
}
|