pdeditor-basic
v0.2.2
Published
Basic visual math editor — lightweight formula input without advanced formatting or templates
Maintainers
Readme
pdeditor-basic
Lightweight Visual Math Editor — Basic Formula Input
Widget React untuk membuat soal & jawaban matematika secara visual — tanpa template lanjutan atau formatting berlebihan. Cocok untuk CMS soal, bank pertanyaan, dan platform ujian.
Versi saat ini: 0.2.0
Daftar Isi
- Instalasi
- Quick Start
- Panduan Implementasi Lengkap
- Upload & Paste Gambar
- Math, LaTeX & KaTeX
- Props API
- Exports & Import Paths
- Troubleshooting
- Tech Stack
📦 Instalasi
Requirements
| Dependency | Versi |
|------------|-------|
| react | ^18.0.0 atau ^19.0.0 |
| react-dom | ^18.0.0 atau ^19.0.0 |
Install
npm install pdeditor-basicPackage ini bundled — TipTap, MathLive, KaTeX, dan DOMPurify sudah termasuk. Anda hanya perlu meng-import CSS secara eksplisit (lihat di bawah).
🚀 Quick Start
Editor (tulis soal)
import { MathTextXEditor } from 'pdeditor-basic'
import 'pdeditor-basic/styles'
export default function App() {
return (
<MathTextXEditor
placeholder="Tulis soal di sini..."
onChange={(html) => console.log(html)}
minHeight="300px"
/>
)
}Viewer (tampilkan soal — read-only)
import { ContentViewer } from 'pdeditor-basic/viewer'
import 'pdeditor-basic/viewer/styles'
export default function SoalCard({ html }: { html: string }) {
return <ContentViewer content={html} />
}Penting: Selalu import CSS editor (
pdeditor-basic/styles) dan viewer (pdeditor-basic/viewer/styles) terpisah. Viewer CSS sudah menyertakan KaTeX stylesheet.
🛠️ Panduan Implementasi Lengkap
1. Vite + React
// src/App.tsx
import { useState } from 'react'
import { MathTextXEditor } from 'pdeditor-basic'
import { ContentViewer } from 'pdeditor-basic/viewer'
import 'pdeditor-basic/styles'
import 'pdeditor-basic/viewer/styles'
export default function App() {
const [html, setHtml] = useState('')
return (
<div>
<MathTextXEditor
content={html}
onChange={setHtml}
placeholder="Tulis soal..."
minHeight="320px"
/>
<h3>Preview</h3>
<ContentViewer content={html} />
</div>
)
}2. Next.js (App Router)
Editor dan MathLive harus client-only (tidak support SSR).
// app/soal/editor/page.tsx
'use client'
import dynamic from 'next/dynamic'
import 'pdeditor-basic/styles'
const MathTextXEditor = dynamic(
() => import('pdeditor-basic').then((m) => m.MathTextXEditor),
{ ssr: false, loading: () => <p>Memuat editor...</p> }
)
export default function SoalEditorPage() {
return (
<MathTextXEditor
placeholder="Tulis soal matematika..."
onChange={(html) => console.log(html)}
/>
)
}// app/soal/[id]/page.tsx — Viewer bisa SSR (tanpa MathLive)
import { ContentViewer } from 'pdeditor-basic/viewer'
import 'pdeditor-basic/viewer/styles'
async function getSoal(id: string) {
const res = await fetch(`https://api.example.com/soal/${id}`, { cache: 'no-store' })
return res.json()
}
export default async function SoalPage({ params }: { params: { id: string } }) {
const soal = await getSoal(params.id)
return <ContentViewer content={soal.html} />
}3. Simpan & Muat dari API
import { useState, useEffect } from 'react'
import { MathTextXEditor } from 'pdeditor-basic'
import 'pdeditor-basic/styles'
export function SoalForm({ soalId }: { soalId: string }) {
const [html, setHtml] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`/api/soal/${soalId}`)
.then((r) => r.json())
.then((data) => setHtml(data.content))
.finally(() => setLoading(false))
}, [soalId])
const handleSave = async () => {
await fetch(`/api/soal/${soalId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: html }),
})
}
if (loading) return <p>Memuat...</p>
return (
<div>
<MathTextXEditor
content={html}
onChange={setHtml}
placeholder="Tulis pertanyaan..."
minHeight="280px"
/>
<button type="button" onClick={handleSave}>Simpan</button>
</div>
)
}onChange di-debounce 150 ms — HTML yang dikirim sudah siap disimpan ke database.
4. Multi-Instance (Soal + Pilihan Jawaban)
import { useState } from 'react'
import { MathTextXEditor } from 'pdeditor-basic'
import 'pdeditor-basic/styles'
type Option = { id: string; content: string }
export function MultipleChoiceForm() {
const [question, setQuestion] = useState('')
const [options, setOptions] = useState<Option[]>(
['A', 'B', 'C', 'D'].map((id) => ({ id, content: '' }))
)
const updateOption = (id: string, content: string) => {
setOptions((prev) => prev.map((o) => (o.id === id ? { ...o, content } : o)))
}
return (
<div className="space-y-4">
<section>
<label>Pertanyaan</label>
<MathTextXEditor
content={question}
onChange={setQuestion}
placeholder="Tulis pertanyaan..."
minHeight="160px"
/>
</section>
{options.map((opt) => (
<section key={opt.id}>
<label>Opsi {opt.id}</label>
<MathTextXEditor
content={opt.content}
onChange={(html) => updateOption(opt.id, html)}
placeholder={`Jawaban ${opt.id}...`}
minHeight="72px"
/>
</section>
))}
</div>
)
}5. Mode Read-Only di Editor
<MathTextXEditor
content={savedHtml}
editable={false}
minHeight="200px"
/>6. Komponen Terpisah (Custom Layout)
import {
MathTextXEditor,
MainToolbar,
MathToolbar,
MathTypeDialog,
} from 'pdeditor-basic'
import 'pdeditor-basic/styles'
// MathTextXEditor sudah menyertakan toolbar bawaan.
// Export di atas berguna jika Anda membangun layout editor custom
// dengan TipTap instance sendiri via createExtensions().🖼️ Upload & Paste Gambar
Editor mendukung: tombol Image, drag-drop, paste screenshot, dan paste HTML (Word / Google Docs / website).
Upload ke Server
import { MathTextXEditor } from 'pdeditor-basic'
import 'pdeditor-basic/styles'
function EditorWithUpload() {
const handleImageUpload = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('image', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!res.ok) throw new Error('Upload gagal')
const data = await res.json()
return data.url // contoh: "https://cdn.example.com/soal/img-123.jpg"
}
return (
<MathTextXEditor
onImageUpload={handleImageUpload}
placeholder="Tulis soal..."
/>
)
}Base64 Fallback (tanpa server)
Jika onImageUpload tidak disediakan, paste/drop gambar otomatis dikonversi ke data URL base64.
const handleImageUpload = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}Base64 cocok untuk demo/prototipe. Untuk produksi, gunakan URL CDN agar HTML tidak membengkak.
Re-upload Gambar dari Paste HTML
Saat paste dari Google Docs / website, gambar sering memakai URL eksternal. Gunakan onBeforePasteHTML:
<MathTextXEditor
onImageUpload={handleImageUpload}
onBeforePasteHTML={async (html) => {
const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/gi
let result = html
let match
while ((match = imgRegex.exec(html)) !== null) {
const src = match[1]
if (src.startsWith('data:')) continue
try {
const blob = await (await fetch(src)).blob()
const ext = blob.type.split('/')[1] || 'png'
const file = new File([blob], `paste.${ext}`, { type: blob.type })
const newUrl = await handleImageUpload(file)
result = result.replace(src, newUrl)
} catch {
console.warn('Skip image:', src)
}
}
return result
}}
/>Interaksi Gambar di Editor
| Aksi | Cara |
|------|------|
| Select | Klik gambar |
| Resize | Tarik titik biru di sudut/sisi |
| Geser posisi | Klik & tahan area tengah gambar, lalu drag |
| Paste | Ctrl+V screenshot atau gambar dari clipboard |
Posisi disimpan sebagai offsetX / offsetY pada tag <figure>.
Format HTML Gambar (disimpan ke DB)
<figure
class="mtx-image-figure mtx-image--center"
data-type="image"
data-offset-x="48"
data-offset-y="-12"
style="width: 420px; transform: translate3d(48px, -12px, 0)"
>
<img
src="https://cdn.example.com/gambar.png"
alt=""
style="display: block; max-width: 100%; height: auto"
/>
</figure>ContentViewer membaca data-offset-x / data-offset-y dan menampilkan posisi yang sama dengan editor.
🧮 Math, LaTeX & KaTeX
Alur Data Math
┌─────────────┐ getHTML / onChange ┌──────────────────┐
│ Editor │ ────────────────────────► │ Database / API │
│ (MathLive) │ HTML + data-latex │ (simpan string) │
└─────────────┘ └────────┬─────────┘
│
▼
┌──────────────────┐
│ ContentViewer │
│ (KaTeX render) │
└──────────────────┘| Tahap | Teknologi | Keterangan |
|-------|-----------|------------|
| Input | MathLive | User mengetik rumus secara visual di editor |
| Penyimpanan | HTML + data-latex | LaTeX disimpan sebagai atribut, bukan sebagai gambar |
| Tampilan | KaTeX | ContentViewer merender LaTeX menjadi HTML matematika |
User tidak wajib tahu LaTeX saat mengetik — MathLive menangani input visual. LaTeX tetap tersimpan sehingga bisa di-render ulang dengan KaTeX.
Cara Insert Math di Editor
| Cara | Keterangan |
|------|------------|
| Ctrl+M | Insert inline math langsung |
| Toolbar Math Formula | Insert inline math |
| Math Toolbar (baris kedua) | Sisipkan simbol: pecahan, akar, pangkat, dll. |
| Klik field math → Edit | Buka MathTypeDialog untuk edit rumus |
Format HTML Math (yang disimpan)
Inline math (sejajar teks):
<p>
Nilai
<span
class="mtx-math-inline"
data-type="math-inline"
data-latex="x^2 + y^2 = r^2"
data-display="inline"
></span>
adalah konstanta.
</p>Block math (baris terpisah, centered):
<div
class="mtx-math-block"
data-type="math-block"
data-latex="\int_0^1 x^2 \, dx = \frac{1}{3}"
data-display="block"
></div>Elemen math disimpan sebagai tag kosong dengan atribut
data-latex. Isi visual dihasilkan oleh KaTeX saat dibaca di viewer.
Implementasi Viewer (KaTeX) — Direkomendasikan
import { ContentViewer } from 'pdeditor-basic/viewer'
import 'pdeditor-basic/viewer/styles'
function HalamanUjian({ soalHtml }: { soalHtml: string }) {
return (
<article className="soal-card">
<ContentViewer content={soalHtml} />
</article>
)
}ContentViewer otomatis:
- Sanitasi HTML dengan DOMPurify (anti-XSS)
- Mencari
.mtx-math-inline,.mtx-math-block,[data-type="math-inline"],[data-type="math-block"] - Membaca
data-latexdan merender dengan KaTeX (throwOnError: false) - Mendukung mhchem untuk rumus kimia (mis.
\ce{H2O})
Implementasi KaTeX Manual (tanpa ContentViewer)
Jika Anda punya HTML sendiri dan hanya perlu merender math:
import { useEffect, useRef } from 'react'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import 'katex/contrib/mhchem' // opsional: rumus kimia
function renderMathInContainer(container: HTMLElement) {
const nodes = container.querySelectorAll(
'.mtx-math-inline, .mtx-math-block, [data-type="math-inline"], [data-type="math-block"]'
)
nodes.forEach((el) => {
if (el.querySelector('.katex')) return // sudah di-render
const latex = el.getAttribute('data-latex') || el.getAttribute('latex')
if (!latex) return
const isBlock = el.classList.contains('mtx-math-block')
|| el.getAttribute('data-display') === 'block'
katex.render(latex, el as HTMLElement, {
throwOnError: false,
displayMode: isBlock,
output: 'htmlAndMathml',
})
})
}
export function CustomViewer({ html }: { html: string }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) renderMathInContainer(ref.current)
}, [html])
return (
<div
ref={ref}
className="soal-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}Untuk produksi, tetap gunakan DOMPurify sebelum
dangerouslySetInnerHTML.ContentViewersudah melakukan ini.
Render LaTeX Murni (string → HTML)
Jika Anda punya string LaTeX terpisah (bukan dari editor):
import katex from 'katex'
import 'katex/dist/katex.min.css'
// Inline
const inlineHtml = katex.renderToString('E = mc^2', {
throwOnError: false,
displayMode: false,
})
// Block / display
const blockHtml = katex.renderToString('\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}', {
throwOnError: false,
displayMode: true,
})
// Di JSX
<div dangerouslySetInnerHTML={{ __html: blockHtml }} />Contoh LaTeX yang Didukung
| Jenis | LaTeX (data-latex) |
|-------|----------------------|
| Pecahan | \frac{a}{b} |
| Akar | \sqrt{x}, \sqrt[3]{x} |
| Pangkat / subskrip | x^2, a_{n} |
| Integral | \int_0^1 f(x)\,dx |
| Sigma | \sum_{k=1}^{n} k |
| Matriks | \begin{pmatrix} a & b \\ c & d \end{pmatrix} |
| Kimia (mhchem) | \ce{H2SO4} |
| Greek | \alpha, \beta, \pi |
Migrasi Konten Lama
Dari CKEditor / MathType (WIRIS):
<!-- Lama -->
<img class="Wirisformula" data-mathml="..." />
<!-- Baru (setelah edit di pdeditor-basic) -->
<span class="mtx-math-inline" data-latex="x^2" data-display="inline"></span>Gunakan sanitizeCKEditorHTML() saat memuat HTML lama ke editor:
import { MathTextXEditor, sanitizeCKEditorHTML } from 'pdeditor-basic'
<MathTextXEditor content={sanitizeCKEditorHTML(legacyHtml)} onChange={...} />Dari raw LaTeX di database:
Jika database menyimpan LaTeX string saja, bungkus sebelum ditampilkan:
function wrapLatex(latex: string, mode: 'inline' | 'block' = 'inline') {
const cls = mode === 'block' ? 'mtx-math-block' : 'mtx-math-inline'
const type = mode === 'block' ? 'math-block' : 'math-inline'
const tag = mode === 'block' ? 'div' : 'span'
return `<${tag} class="${cls}" data-type="${type}" data-latex="${latex.replace(/"/g, '"')}" data-display="${mode}"></${tag}>`
}
// Contoh
const html = `<p>Hasil: ${wrapLatex('\\frac{1}{2}', 'inline')}</p>`
return <ContentViewer content={html} />Utility Serializer
import { getHTML, sanitizeCKEditorHTML, toCompatibleHTML } from 'pdeditor-basic'
// Ambil HTML bersih dari instance TipTap (jika extend editor sendiri)
const html = getHTML(editor)
// Bersihkan HTML dari CKEditor sebelum dimuat
const safe = sanitizeCKEditorHTML(rawHtml)
// Konversi ke format kompatibel (fallback text untuk math)
const compat = toCompatibleHTML(html)✨ Fitur
Rich Text
- Bold, Italic, Underline, Strikethrough
- Heading 1–4, Paragraph
- Bullet / Ordered list, Indent / Outdent
- Link, Table, Code block (syntax highlight)
- Task list, Blockquote, Horizontal rule
Visual Math
- Inline & block math (MathLive)
- Math toolbar: Basic, Relation, Set, Calc, Structure
- Equation Editor dialog (
MathTypeDialog)
Image
- Insert, paste, drag-drop
- Resize (8 handles) & drag-to-position
- Editor ↔ Viewer posisi konsisten
Keamanan
- DOMPurify pada paste & viewer
- HTML math disimpan sebagai
data-latex, bukan<script>
⌨️ Keyboard Shortcuts
| Shortcut | Aksi |
|----------|------|
| Ctrl+B / Ctrl+I / Ctrl+U | Bold / Italic / Underline |
| Ctrl+Z / Ctrl+Y | Undo / Redo |
| Ctrl+M | Insert inline math |
| Shift+Ctrl+V | Paste as plain text |
📋 Props API
MathTextXEditor
| Prop | Type | Default | Deskripsi |
|------|------|---------|-----------|
| content | string | '' | HTML awal |
| onChange | (html: string) => void | — | Callback saat konten berubah (debounce 150ms) |
| onSave | (html: string) => void | — | Callback saat Ctrl+S |
| placeholder | string | 'Tulis soal di sini...' | Placeholder editor kosong |
| editable | boolean | true | Mode read-only jika false |
| minHeight | string | '200px' | Tinggi minimum area edit |
| maxHeight | string | — | Tinggi maksimum (scroll) |
| autoFocus | boolean | false | Fokus otomatis saat mount |
| className | string | — | Class tambahan pada wrapper |
| onImageUpload | (file: File) => Promise<string> | — | Upload gambar, return URL |
| onBeforePasteHTML | (html: string) => Promise<string> | — | Transform HTML sebelum paste |
ContentViewer
| Prop | Type | Deskripsi |
|------|------|-----------|
| content | string | HTML dari database (wajib) |
| className | string | Class tambahan pada wrapper |
📦 Exports & Import Paths
Main package
import {
MathTextXEditor,
ContentViewer,
MainToolbar,
MathToolbar,
MathTypeDialog,
MathInlineNode,
MathBlockNode,
createExtensions,
getHTML,
sanitizeCKEditorHTML,
toCompatibleHTML,
} from 'pdeditor-basic'
import type {
MathTextXEditorProps,
ContentViewerProps,
MathTypeDialogProps,
} from 'pdeditor-basic'
import 'pdeditor-basic/styles'Viewer (subpath — lebih ringan)
import { ContentViewer } from 'pdeditor-basic/viewer'
import type { ContentViewerProps } from 'pdeditor-basic/viewer'
import 'pdeditor-basic/viewer/styles'Tabel import path
| Import | File |
|--------|------|
| pdeditor-basic | dist/pdeditor-basic.js (ESM) / dist/pdeditor-basic.umd.cjs (UMD) |
| pdeditor-basic/styles | dist/assets/pdeditor-basic.css |
| pdeditor-basic/viewer | dist/viewer.js |
| pdeditor-basic/viewer/styles | dist/viewer-styles.js (+ KaTeX CSS) |
UMD (script tag / legacy bundler)
<link rel="stylesheet" href="/node_modules/pdeditor-basic/dist/assets/pdeditor-basic.css" />
<script src="/node_modules/pdeditor-basic/dist/pdeditor-basic.umd.cjs"></script>
<script>
const { MathTextXEditor } = PDEditorBasic
</script>⚠️ Troubleshooting
| Masalah | Solusi |
|---------|--------|
| Can't resolve 'pdeditor-basic' | Jalankan npm install pdeditor-basic |
| window is not defined (Next.js) | Gunakan dynamic(..., { ssr: false }) untuk editor |
| Editor area kosong / tanpa placeholder | Pastikan import 'pdeditor-basic/styles' |
| Math tidak tampil di viewer | Import pdeditor-basic/viewer/styles (sudah include KaTeX CSS) |
| Rumus kimia tidak render | KaTeX mhchem sudah di-load di ContentViewer; untuk manual KaTeX import katex/contrib/mhchem |
| MathLive font error | Set sebelum mount editor: (window as any).MATHLIVE_FONTS_PATH = '/fonts' dan salin font MathLive ke public/fonts |
| Gambar tidak bisa di-drag | Klik area tengah gambar (bukan titik resize biru) |
| Posisi gambar beda di viewer | Pastikan HTML punya data-offset-x / data-offset-y pada <figure class="mtx-image-figure"> |
| HTML math hilang setelah sanitize | Pastikan allowlist DOMPurify menyertakan data-latex, data-display, data-type |
MathLive fonts (opsional)
// main.tsx — sebelum render editor
if (typeof window !== 'undefined') {
(window as Window & { MATHLIVE_FONTS_PATH?: string }).MATHLIVE_FONTS_PATH = '/fonts'
}Salin folder fonts dari node_modules/mathlive/dist/fonts ke public/fonts proyek Anda.
🛠️ Tech Stack
| Layer | Library | |-------|---------| | UI | React 18+ | | Editor | TipTap / ProseMirror | | Math input | MathLive | | Math render | KaTeX (+ mhchem) | | XSS | DOMPurify | | Build | Vite (ESM + UMD) |
📄 License
🔗 Links
- NPM: pdeditor-basic
- Changelog: CHANGELOG.md
