Skip to content

programinglive/zettly-editor

@programinglive/zettly-editor

Shadcn-styled TipTap editor for Zettly todo/notes apps.

Installation

1. Grab the package and peer dependencies

npm install @programinglive/zettly-editor @tiptap/react @tiptap/starter-kit react react-dom

@programinglive/zettly-editor bundles every required TipTap extension except the peers above. Install them once in your consumer app—no local linking needed.

2. Import the polished styles

The toolbar and surface rely on the published CSS bundle. Import it near the top of your app entry file:

// main.tsx / app.jsx
import "@programinglive/zettly-editor/styles";

Prefer a single import? Use the helper that pulls in CSS automatically:

import { ZettlyEditor } from "@programinglive/zettly-editor/dist/with-styles.js";

Tip: When using frameworks with SSR (Next.js, Remix), place the import in your global layout so both client and server renders stay in sync. The with-styles helper works in SSR because it only adds side-effect CSS once per bundle.

3. Configure bundler aliases (Laravel + Vite only)

If you consume the editor from a Laravel + Inertia/React stack, Vite needs explicit aliases so the published ESM bundle and CSS resolve correctly. Update your vite.config.js:

// 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"
      ),
      "@programinglive/zettly-editor/styles": path.resolve(
        __dirname,
        "node_modules/@programinglive/zettly-editor/dist/index.css"
      ),
    },
  },
});

No aliasing is required for plain React, Next.js, or Vite projects—the published package tree already works with standard Node resolution.

4. Render the editor

import { useState } from "react";
import { ZettlyEditor } from "@programinglive/zettly-editor";
// or: import { ZettlyEditor } from "@programinglive/zettly-editor/dist/with-styles.js";

export function MyEditor() {
  const [value, setValue] = useState("<p>Hello Zettly</p>");

  return (
    <ZettlyEditor
      value={value}
      onChange={(nextValue) => setValue(nextValue)}
    />
  );
}

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.

Toolbar defaults

  • Heading select: Switch between Paragraph and Heading 1-6 for structured titles.
  • Bold / Italic / Strike: Toggle inline emphasis with buttons or keyboard shortcuts (Mod+B, Mod+I, Mod+Shift+X).
  • Lists: Insert bullet or ordered lists to outline ideas quickly.
  • Blockquote: Highlight important quotes or callouts.
  • Code block: Add syntax-highlighted blocks for snippets or documentation.
  • Link: Prompted link control honors permissions and allows quick insertion/removal.
  • Highlight: Toggle yellow background marks. The library ships explicit mark styling so host CSS resets cannot hide the highlight.
  • Debug toggle: When onDebugToggle is provided, a 🐞 button exposes lifecycle events for troubleshooting.

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. While editing a block, press Shift+Enter / Mod+Enter or hit Enter on an empty line at the end to exit back to a normal paragraph.

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 default palette now differentiates between light and dark surfaces, so code blocks remain legible regardless of theme. 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.
debug boolean Enable debug mode (shows console logs and toolbar toggle).
onDebugEvent (event: DebugEvent) => void Receive structured lifecycle/toolbar events for remote logging.
onDebugToggle (enabled: boolean) => void Callback when debug is toggled via toolbar button.

Debug logging

Set the debug prop to true during integration to surface rich console output from the editor. This logs TipTap lifecycle callbacks (onCreate, onUpdate, onTransaction, onSelectionUpdate) and the toolbar's active command state, which is especially helpful when diagnosing highlight/selection issues in downstream apps.

Debug toolbar toggle

When you provide an onDebugToggle callback, a debug button (🐞) appears in the editor toolbar, allowing users to toggle debug mode on/off without code changes:

import { useState } from "react";
import { ZettlyEditor, type DebugEvent } from "@programinglive/zettly-editor";

export function MyEditor() {
  const [value, setValue] = useState("<p>Hello</p>");
  const [debugEnabled, setDebugEnabled] = useState(false);

  const handleDebugEvent = (event: DebugEvent) => {
    if (!debugEnabled) return;
    console.log("zettly debug event", event);
  };

  return (
    <ZettlyEditor
      value={value}
      onChange={setValue}
      debug={debugEnabled}
      onDebugEvent={handleDebugEvent}
      onDebugToggle={setDebugEnabled}
    />
  );
}

The debug toggle button appears at the end of the toolbar and shows the current debug state visually.

Editor footer

The editor includes a footer that displays:

  • Version: Shows the current @programinglive/zettly-editor version (e.g., "Zettly Editor v0.1.9")
  • Debug status: Indicates whether debug mode is "Enabled" or "Disabled"

This footer helps you verify which version is running and monitor debug state at a glance.

Forwarding debug events to a backend

When you need to capture events centrally (for example in https://zettly-debug.programinglive.com/), provide an onDebugEvent callback. The editor emits structured payloads describing lifecycle changes, transactions, selections, and toolbar state.

import { ZettlyEditor, type DebugEvent } from "@programinglive/zettly-editor";

const sendDebugEvent = (event: DebugEvent) => {
  fetch("https://zettly-debug.programinglive.com/api/editor-debug", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": import.meta.env.VITE_ZETTLY_DEBUG_KEY,
    },
    body: JSON.stringify({
      ...event,
      app: "todo-app",
      noteId: currentNoteId,
      userId: currentUser.id,
    }),
    keepalive: true,
  });
};

<ZettlyEditor value={value} onChange={handleChange} debug onDebugEvent={sendDebugEvent} />;

Each event includes a timestamp, selection JSON, HTML snapshot (where applicable), and active toolbar commands. This makes it easy to store, replay, or alert on anomalies in an external service.

Laravel logging example

Create a dedicated channel in config/logging.php:

'channels' => [
    // ...
    'zettly' => [
        'driver' => 'single',
        'path' => storage_path('logs/zettly-editor.log'),
        'level' => 'debug',
    ],
],

Then wire an ingest route:

// routes/api.php
Route::post('/editor-debug', [EditorDebugController::class, 'store'])->middleware('auth:sanctum');

// app/Http/Controllers/EditorDebugController.php
class EditorDebugController extends Controller
{
    public function store(EditorDebugRequest $request)
    {
        Log::channel('zettly')->info('zettly editor debug', $request->validated());
        return response()->json(['ok' => true]);
    }
}

From there you can tail storage/logs/zettly-editor.log, ship the file to your APM provider, or build a dashboard to inspect events.

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 -p

2. 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 dev

Build outputs to dist/ via tsup.

About

Shadcn-based WYSIWYG editor for Zettly todo and notes applications.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

No packages published