Package Exports
- @programinglive/zettly-editor
Readme
@programinglive/zettly-editor
Shadcn-styled TipTap editor for Zettly todo/notes apps.
Installation
npm install @programinglive/zettly-editor @tiptap/react @tiptap/starter-kit react react-domLaravel + Vite setup (consumer project)
When integrating inside a Laravel project (Jetstream, Inertia, Breeze, etc.), adjust that project's vite.config.js to point at the published bundle. The snippet below assumes you already have Laravel's default laravel-vite-plugin installed—nothing needs to be added to this library package.
Add the aliases so Laravel can compile the editor and styles:
// vite.config.js
import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import react from "@vitejs/plugin-react";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig({
plugins: [
laravel({
input: "resources/js/app.jsx",
refresh: true,
}),
react(),
],
resolve: {
alias: {
"@programinglive/zettly-editor": path.resolve(
__dirname,
"node_modules/@programinglive/zettly-editor/dist/index.mjs"
),
"zettly-editor/styles": path.resolve(
__dirname,
"node_modules/@programinglive/zettly-editor/dist/index.css"
),
},
},
});Then import the stylesheet inside resources/js/app.jsx (or your SPA entry file):
import "zettly-editor/styles";Usage
import { useState } from "react";
import { ZettlyEditor } from "@programinglive/zettly-editor";
export function MyEditor() {
const [value, setValue] = useState("<p>Hello Zettly</p>");
return (
<ZettlyEditor
value={value}
onChange={(nextValue) => setValue(nextValue)}
/>
);
}The editor ships with opinionated defaults that match the example playground. Bold, italic, strike, lists, blockquotes, and links all have styling baked in so you can see how each toolbar action behaves immediately.
The editable surface uses the full container width and a comfortable minimum height, matching the mockups shown in the docs.
Syntax highlighting
Code blocks use @tiptap/extension-code-block-lowlight together with lowlight and highlight.js for layered syntax highlighting. lowlight ships with a curated set of languages pre-registered inside src/components/editor/code-block-config.ts, including JavaScript, TypeScript, JSON, Bash, SQL, Go, PHP, Rust, Swift, Kotlin, and more. The default toolbar exposes a code-block toggle so editors can insert and format blocks instantly.
To support an additional language, register the Highlight.js grammar before mounting the editor:
import python from "highlight.js/lib/languages/python";
import { lowlight } from "lowlight";
lowlight.registerLanguage("python", python);Styling is handled in src/components/editor/code-highlight.css. Override the .hljs token classes or append your own theme to align with your design system. The example playground demonstrates how to render and theme read-only snippets via example/src/syntax-highlighter.tsx.
Example Playground
- Run locally
npm run example:dev # served at http://localhost:5183
- ✨ Rich text editing powered by tiptap
- 🎨 Beautiful default toolbar built with shadcn/ui
- 🧰 Fully controlled component with single data flow
- 🪝 Permission-aware commands out of the box
- 🧪 Tested with React Testing Library + Vitest
- 🌈 Syntax highlighting for code blocks powered by Highlight.js
Props
| Name | Type | Description |
|---|---|---|
value |
string |
Controlled HTML content. |
onChange |
(value: string, meta: EditorMeta) => void |
Receive updates plus meta information. |
extensions |
AnyExtension[] |
Additional TipTap extensions. |
commands |
CommandDefinition[] |
Custom toolbar commands. |
permissions |
EditorPermissions |
Control read-only/link behavior. |
messages |
Partial<EditorMessages> |
Override UI copy. |
toolbar |
(props: ToolbarRenderProps) => ReactNode |
Custom toolbar renderer. |
className |
string |
Wrapper class. |
editorClassName |
string |
Content area class. |
autoFocus |
boolean |
Focus editor on mount. |
Integrating with a Shadcn Project
1. Install Dependencies
npm install @programinglive/zettly-editor @tiptap/react @tiptap/starter-kit lucide-react
npm install tailwindcss postcss autoprefixer
npx tailwindcss init -p2. Configure Tailwind Tokens
Add the content globs and CSS variables used by ZettlyEditor inside your tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
},
},
},
plugins: [],
};
export default config;Import the Tailwind entry file in your app layout:
// app/layout.tsx (Next.js)
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="bg-background text-foreground">
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}3. Render the Editor with Single Data Flow
import { useState } from "react";
import { ZettlyEditor, type EditorMeta } from "@programinglive/zettly-editor";
export function NoteEditor() {
const [value, setValue] = useState("<p>Start writing...</p>");
const [meta, setMeta] = useState<EditorMeta | null>(null);
return (
<div className="space-y-4">
<ZettlyEditor
value={value}
onChange={(next, nextMeta) => {
setValue(next);
setMeta(nextMeta);
}}
/>
<pre className="rounded-md bg-muted p-4 text-xs">{value}</pre>
<p className="text-sm text-muted-foreground">Words: {meta?.words ?? 0}</p>
</div>
);
}Persisting Editor Output
ZettlyEditor emits HTML through the onChange callback. Save this string in your preferred backend. Below are minimal examples for popular databases. All examples assume a Next.js 14 route handler, but you can adapt them to Express/Fastify easily.
Prisma (MySQL, PostgreSQL, SQLite, PlanetScale, Supabase)
Schema:
// prisma/schema.prisma
model Note {
id String @id @default(cuid())
title String
content String @db.LongText
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Route handler:
// app/api/notes/route.ts
import { NextResponse } from "next/server";
import { prisma } from "~/lib/prisma";
export async function POST(request: Request) {
const { title, content } = await request.json();
const note = await prisma.note.create({ data: { title, content } });
return NextResponse.json(note, { status: 201 });
}
export async function GET() {
const notes = await prisma.note.findMany({ orderBy: { createdAt: "desc" } });
return NextResponse.json(notes);
}Client usage:
async function saveNote(value: string) {
await fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Daily Log", content: value }),
});
}MySQL (mysql2)
// src/lib/mysql.ts
import mysql from "mysql2/promise";
export const pool = mysql.createPool({
uri: process.env.MYSQL_DATABASE_URL!,
});
// app/api/notes/mysql/route.ts
import { NextResponse } from "next/server";
import { pool } from "~/lib/mysql";
export async function POST(request: Request) {
const { title, content } = await request.json();
await pool.execute("INSERT INTO notes (title, content_html) VALUES (?, ?)", [title, content]);
return NextResponse.json({ ok: true });
}PostgreSQL (pg)
// src/lib/postgres.ts
import { Pool } from "pg";
export const pgPool = new Pool({ connectionString: process.env.POSTGRES_URL });
// app/api/notes/postgres/route.ts
import { NextResponse } from "next/server";
import { pgPool } from "~/lib/postgres";
export async function POST(request: Request) {
const { title, content } = await request.json();
await pgPool.query("INSERT INTO notes (title, content_html) VALUES ($1, $2)", [title, content]);
return NextResponse.json({ ok: true });
}MongoDB (mongodb driver)
// src/lib/mongo.ts
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI!);
export const mongo = client.db("zettly").collection("notes");
// app/api/notes/mongo/route.ts
import { NextResponse } from "next/server";
import { mongo } from "~/lib/mongo";
export async function POST(request: Request) {
const { title, content } = await request.json();
await mongo.insertOne({ title, content, createdAt: new Date() });
return NextResponse.json({ ok: true });
}Firebase Firestore
// src/lib/firebase-admin.ts
import { cert, getApps, initializeApp } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
const app = getApps()[0] ?? initializeApp({
credential: cert(JSON.parse(process.env.FIREBASE_ADMIN_KEY!)),
});
export const firestore = getFirestore(app);
// app/api/notes/firebase/route.ts
import { NextResponse } from "next/server";
import { firestore } from "~/lib/firebase-admin";
export async function POST(request: Request) {
const { title, content } = await request.json();
await firestore.collection("notes").add({ title, content, createdAt: Date.now() });
return NextResponse.json({ ok: true });
}Loading Saved Content
When you fetch stored HTML, feed it back into the editor as value.
const note = await fetch("/api/notes/123").then((res) => res.json());
return <ZettlyEditor value={note.content} onChange={handleChange} />;Development
npm install
npm run devBuild outputs to dist/ via tsup.