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-domUsage
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.
Example Playground
- Run locally
npm run example:dev - Edit vs Preview The playground now includes an Edit/Preview toggle so you can test read-only rendering without leaving the page.
- Sample content Out of the box, every toolbar button has a corresponding snippet in the starter document—use it as a reference when wiring up persistence.
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.