nilfam-editor
Version:
A powerful, customizable rich-text editor built with TipTap, React, and Tailwind CSS. Supports RTL/LTR text, resizable media (images/videos), tables, code blocks, font styling, and more for an enhanced content creation experience.
249 lines (224 loc) • 10.4 kB
JSX
import { Node, mergeAttributes } from '@tiptap/core'
export default Node.create({
name: 'video',
group: 'block',
atom: true,
draggable: true,
selectable: true,
/* ─────────── Attributes ─────────── */
addAttributes() {
return {
src: { default: null },
controls: { default: true },
alt: { default: null },
align: { default: 'left' }, // left | center | right
style: { default: 'width:560px;height:auto;cursor:pointer;' },
}
},
/* ─────────── Schema ─────────── */
parseHTML() {
return [
{
tag: 'video[src]',
getAttrs: (dom) => {
const domStyle = dom.getAttribute('style') || '';
// استخراج align از استایل (برای حفظ بعد از لود)
let align = 'left';
if (domStyle.includes('margin-left:auto') && domStyle.includes('margin-right:auto')) {
align = 'center';
} else if (domStyle.includes('float:right')) {
align = 'right';
}
// تمیز کردن style: حذف قسمتهای ترازبندی، نگه داشتن width/height/cursor
const cleanStyle = domStyle
.replace(/float:\s*\w+;\s*/g, '')
.replace(/display:\s*block;\s*/g, '')
.replace(/margin-left:\s*auto;\s*/g, '')
.replace(/margin-right:\s*auto;\s*/g, '')
.trim();
return {
src: dom.getAttribute('src'),
alt: dom.getAttribute('alt'),
controls: dom.hasAttribute('controls'),
align,
style: cleanStyle || 'width:560px;height:auto;cursor:pointer;',
};
},
},
];
},
/*
* خروجی HTMLِ ذخیرهشده
* ↩︎ align را به استایل بلاکی تبدیل میکنیم
*/
renderHTML({ HTMLAttributes }) {
const { align = 'left', style = '' } = HTMLAttributes
// استایلِ لازم برای ترازبندی
const extra =
align === 'center'
? 'display:block;margin-left:auto;margin-right:auto;'
: align === 'right'
? 'float:right;margin-left:auto;'
: 'float:left;'
return [
'video',
mergeAttributes(
HTMLAttributes,
{
controls: true, // اطمینان از وجود controls
style: style + extra, // استایل قبلی + استایل ترازبندی
align: null, // حذف attr منسوخ
// ← اگر دوست دارید کلاس بدهید:
// class: `tiptap-video tiptap-video--${align}`,
},
),
]
},
/* ─────────── Commands ─────────── */
addCommands() {
return {
insertVideo:
attrs =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
}
},
/* ─────────── Node-View (داخل ادیتور) ─────────── */
addNodeView() {
return ({ node, editor, getPos }) => {
const { view } = editor
const { style, align } = node.attrs
/* ➊ wrapper flex برای نمایش چپ/وسط/راست در ادیتور */
const wrap = document.createElement('div')
wrap.style.cssText =
'display:flex;width:100%;justify-content:' +
(align === 'center'
? 'center'
: align === 'right'
? 'flex-end'
: 'flex-start')
/* ➋ container اصلی */
const box = document.createElement('div')
box.style.cssText = `position:relative;display:inline-block;${style}`
/* عنصر ویدئو */
const video = document.createElement('video')
video.setAttribute('controls', 'true')
Object.entries(node.attrs).forEach(([k, v]) => v && video.setAttribute(k, v))
/* ➌ نوار ابزار کوچک */
const bar = document.createElement('div')
bar.style.cssText =
'position:absolute;top:0;left:50%;transform:translate(-50%,-100%);' +
'display:flex;gap:4px;padding:2px 4px;background:#fff;border:1px solid #888;' +
'border-radius:4px;z-index:10;font-size:0;'
const mkBtn = (title, svg, fn) => {
const btn = document.createElement('span')
btn.title = title
btn.style.cssText =
'width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;' +
'cursor:pointer;border-radius:4px;'
btn.innerHTML = `<img src="${svg}" width="20" height="20">`
btn.onclick = fn
bar.appendChild(btn)
}
/* helpers برای ذخیرهٔ تغییرات */
const dispatch = attrs =>
view.dispatch(
view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, ...attrs }),
)
const saveSize = ex => dispatch({ style: box.style.cssText, ...ex })
const saveAlign = a => dispatch({ align: a })
/* ───── دکمههای align ───── */
mkBtn(
'چپ',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/format_align_left/default/20px.svg',
() => {
wrap.style.justifyContent = 'flex-start'
saveAlign('left')
},
)
mkBtn(
'وسط',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/format_align_center/default/20px.svg',
() => {
wrap.style.justifyContent = 'center'
saveAlign('center')
},
)
mkBtn(
'راست',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/format_align_right/default/20px.svg',
() => {
wrap.style.justifyContent = 'flex-end'
saveAlign('right')
},
)
/* ALT */
mkBtn(
'alt',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/edit/default/20px.svg',
() => {
const cur = video.getAttribute('alt') || ''
const txt = prompt('متن alt را وارد کنید:', cur)
if (txt !== null) {
video.setAttribute('alt', txt)
saveSize({ alt: txt })
}
},
)
/* حذف */
mkBtn(
'حذف',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/delete/default/20px.svg',
() => {
const from = getPos()
view.dispatch(view.state.tr.delete(from, from + node.nodeSize))
},
)
/* ───── Drag-handle ───── */
const drag = document.createElement('span')
drag.title = 'جابجایی'
drag.setAttribute('draggable', 'true')
drag.classList.add('ProseMirror-drag-handle')
drag.style.cssText =
'width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;' +
'cursor:grab;border-radius:4px;'
drag.innerHTML =
'<img src="https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/open_with/default/20px.svg" width="20" height="20">'
bar.appendChild(drag)
/* ───── نقطهٔ resize ───── */
const dot = document.createElement('div')
dot.style.cssText =
'position:absolute;width:12px;height:12px;right:-6px;bottom:-6px;' +
'background:#ddd;border:1px solid #666;border-radius:50%;cursor:nwse-resize;'
box.append(dot)
let sx = 0,
sw = 0,
aspect = 1
video.onloadedmetadata = () => (aspect = video.videoWidth / video.videoHeight)
dot.onmousedown = e => {
e.preventDefault()
sx = e.clientX
sw = video.offsetWidth
const mm = mv => {
const w = Math.max(sw + (mv.clientX - sx), 80)
const h = w / aspect
video.style.width = `${w}px`
video.style.height = `${h}px`
box.style.width = `${w}px`
box.style.height = `${h}px`
}
const mu = () => {
saveSize()
document.removeEventListener('mousemove', mm)
document.removeEventListener('mouseup', mu)
}
document.addEventListener('mousemove', mm)
document.addEventListener('mouseup', mu)
}
/* مونتاژ نهایی */
wrap.appendChild(box)
box.append(video, bar, dot)
return { dom: wrap }
}
},
})