npm package: @programinglive/zettly-editor
Description: Shadcn-based WYSIWYG editor for Zettly todo and notes applications.
Latest version: 0.2.17

# @programinglive/zettly-editor

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

## Installation

### 1. Grab the package and peer dependencies

```bash
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:

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

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

```tsx
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`:

```ts
// 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

```tsx
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

```tsx
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:

```ts
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**
  ```bash
  npm run example:dev # served at http://localhost:5183
  ```
- ✨ Rich text editing powered by [tiptap](https://tiptap.dev/)
- 🎨 Beautiful default toolbar built with [shadcn/ui](https://ui.shadcn.com/)
- 🧰 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. |
#### 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:

```tsx
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.

```tsx
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`:

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

Then wire an ingest route:

```php
// 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

```bash
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`:

```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:

```tsx
// 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

```tsx
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
// 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:**

```ts
// 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:**

```tsx
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)

```ts
// 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)

```ts
// 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)

```ts
// 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

```ts
// 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`.

```tsx
const note = await fetch("/api/notes/123").then((res) => res.json());

return <ZettlyEditor value={note.content} onChange={handleChange} />;
```

## Development

```bash
npm install
npm run dev
```

Build outputs to `dist/` via `tsup`.