Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8d44153
docs: add tiptap TextEditor component design spec
kulmann Mar 20, 2026
c8bb716
docs: add onUpdate callback to TextEditor spec
kulmann Mar 20, 2026
97c1c57
docs: add tiptap TextEditor implementation plan
kulmann Mar 20, 2026
8605d56
docs: fix plan review issues in TextEditor implementation plan
kulmann Mar 20, 2026
7589571
feat(editor): scaffold @opencloud-eu/editor package
kulmann Mar 20, 2026
1595c38
feat(editor): add toolbar types and item definitions
kulmann Mar 20, 2026
eb239d9
feat(editor): add plain text strategy
kulmann Mar 20, 2026
5e54067
feat(editor): add rich text base and HTML strategy
kulmann Mar 20, 2026
d1a1868
feat(editor): add tiptap JSON strategy
kulmann Mar 20, 2026
c8c0547
feat(editor): add markdown strategy
kulmann Mar 20, 2026
1a742c8
feat(editor): add strategy resolver
kulmann Mar 20, 2026
f20ea8d
feat(editor): add useTextEditor composable with tests
kulmann Mar 20, 2026
f150974
feat(editor): add TextEditorProvider, TextEditorContent, TextEditorTo…
kulmann Mar 20, 2026
bb468b4
refactor(mail): replace MailBodyEditor with @opencloud-eu/editor
kulmann Mar 20, 2026
fd0f7ea
refactor(text-editor): replace md-editor-v3 with @opencloud-eu/editor
kulmann Mar 20, 2026
4c89941
feat(editor): add ProseMirror content styles using design system tokens
kulmann Mar 20, 2026
42d78b6
fix: update TextEditor imports after migration to @opencloud-eu/editor
kulmann Mar 20, 2026
6da4df6
fix(editor): use editor.getMarkdown() instead of editor.storage.markd…
kulmann Mar 20, 2026
02620ad
fix(editor): pass contentType to tiptap for proper markdown parsing
kulmann Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,555 changes: 1,555 additions & 0 deletions docs/superpowers/plans/2026-03-20-tiptap-text-editor.md

Large diffs are not rendered by default.

231 changes: 231 additions & 0 deletions docs/superpowers/specs/2026-03-20-tiptap-text-editor-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# `@opencloud-eu/editor` — Tiptap-based TextEditor Component

## Overview

A standalone package providing a generic, content-type-aware rich text editor built on Tiptap v3. The editor adapts its toolbar and serialization based on the content type: plain text, markdown, HTML, or tiptap JSON.

## Goals

- Single reusable editor package that replaces both the mail app's `MailBodyEditor` and the text editor app's `md-editor-v3` usage
- Content-type-aware toolbar that only shows relevant formatting tools
- Read-only rendering mode (no editor UI, just rendered content)
- Tiptap internals fully encapsulated — consumers never touch tiptap APIs
- Styling inherits from the design system's Tailwind tokens

## Package Structure

```
packages/editor/
├── package.json # @opencloud-eu/editor
├── tsconfig.json
├── src/
│ ├── index.ts # public exports
│ ├── composables/
│ │ └── useTextEditor.ts
│ ├── components/
│ │ ├── TextEditorProvider.vue
│ │ ├── TextEditorContent.vue
│ │ └── TextEditorToolbar.vue
│ ├── strategies/
│ │ ├── types.ts
│ │ ├── resolveStrategy.ts
│ │ ├── plainText.ts
│ │ ├── markdown.ts
│ │ ├── html.ts
│ │ └── tiptapJson.ts
│ └── toolbar/
│ ├── types.ts
│ └── items.ts
```

## Public API

### Types

```ts
type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json'

interface TextEditorOptions {
contentType: ContentType
modelValue?: string
readonly?: boolean
onUpdate?: (content: string) => void // serialized in source format, debounced internally
}

interface TextEditorInstance {
editor: ShallowRef<Editor | null>
contentType: Ref<ContentType>
readonly: Ref<boolean>
getContent(): string
setContent(value: string): void
isEmpty: ComputedRef<boolean>
isFocused: ComputedRef<boolean>
focus(): void
blur(): void
destroy(): void
}
```

### Composable

```ts
function useTextEditor(options: TextEditorOptions): TextEditorInstance
```

### Components

- **`TextEditorProvider`** — props: `{ editor: TextEditorInstance }`. Provides editor to children via Vue's provide/inject.
- **`TextEditorContent`** — no props. Injects editor, renders ProseMirror view.
- **`TextEditorToolbar`** — no props. Injects editor, renders toolbar items from the active strategy. Hidden when `readonly` is true or content type is `plain-text`.

### Usage

**Editing:**

```vue
const { editor } = useTextEditor({
contentType: 'markdown',
modelValue: props.content,
})

<TextEditorProvider :editor="editor">
<TextEditorToolbar />
<TextEditorContent />
</TextEditorProvider>
```

**Read-only rendering:**

```vue
const { editor } = useTextEditor({
contentType: 'html',
modelValue: props.content,
readonly: true,
})

<TextEditorProvider :editor="editor">
<TextEditorContent />
</TextEditorProvider>
```

**Update handling:** An explicit `onUpdate` callback in `TextEditorOptions` rather than v-model. The callback receives the serialized content string in the source format and is debounced internally to avoid serialization cost on every keystroke. Consumers can also pull content on demand via `getContent()`.

## Content Type Strategy Pattern

Each content type is a strategy implementing:

```ts
interface ContentTypeStrategy {
extensions(): Extension[]
toolbarItems(): ToolbarGroup[]
serialize(editor: Editor): string
deserialize(content: string): Content
}
```

### Plain Text

- **Extensions:** `Document`, `Paragraph`, `Text`, `HardBreak`
- **Toolbar:** none
- **Serialize:** `editor.getText()`
- **Deserialize:** wrap in paragraph nodes
- **Behavior:** strips formatting on paste

### Markdown

- **Extensions:** `StarterKit`, `Link`, `Table`, `TaskList`, `TaskItem`, `CodeBlock`, `HorizontalRule`
- **Toolbar:** bold, italic, strikethrough, heading (1-3), bullet list, ordered list, task list, blockquote, code inline, code block, link, horizontal rule, table
- **Serialize/deserialize:** `@tiptap/markdown` with custom node mappings
- **Note:** round-trip fidelity is best-effort — tiptap normalizes some markdown constructs

### HTML

- **Extensions:** everything markdown has + `Underline`, `Image`
- **Toolbar:** everything markdown has + underline, image
- **Serialize:** `editor.getHTML()`
- **Deserialize:** pass HTML directly (tiptap parses natively)

### Tiptap JSON

- **Extensions:** same as HTML
- **Toolbar:** same as HTML
- **Serialize:** `JSON.stringify(editor.getJSON())`
- **Deserialize:** `JSON.parse(content)` passed to tiptap directly
- **Note:** lossless round-trip (native format)

HTML and Tiptap JSON share the same extension set and toolbar — only serialization differs. They share a base strategy to avoid duplication.

## Toolbar Architecture

### Types

```ts
interface ToolbarItem {
id: string
label: string
icon: string
action: (editor: Editor) => void
isActive: (editor: Editor) => boolean
}

type ToolbarGroup = ToolbarItem[]
```

### Grouping

| Group | Items |
|---|---|
| Text formatting | Bold, Italic, Underline*, Strikethrough |
| Headings | H1, H2, H3 |
| Lists | Bullet list, Ordered list, Task list |
| Block | Blockquote, Code block, Horizontal rule |
| Insert | Link, Image*, Table |

*Underline and Image only present for HTML and Tiptap JSON content types.

### Rendering

- Flat row of icon buttons grouped with visual separators
- Each button shows active state via `isActive`
- Hidden entirely when `readonly` or `plain-text`
- No dropdowns or nested menus

## Integration Plan

### Mail app (`web-app-mail`)

- Remove `MailBodyEditor.vue` and `MailComposeFormattingToolbar.vue`
- Replace with `useTextEditor({ contentType: 'html' })`
- `TextEditorToolbar` placed at the bottom of compose form (preserving current layout)
- `TextEditorContent` in the compose body area
- Link sanitization (DOMPurify) moves into the HTML strategy's `Link` extension config
- Tiptap dependencies removed from `web-app-mail/package.json`

### Text editor app (`web-app-text-editor`)

- Replace `md-editor-v3` usage with the new editor
- Content type resolved from file extension: `.md`/`.markdown` → `markdown`, everything else → `plain-text`
- Read-only mode uses same component with `readonly: true`
- `md-editor-v3` and `@codemirror` dependencies removed from `web-pkg`
- The existing `TextEditor` component in `web-pkg/src/components/TextEditor/` replaced by imports from `@opencloud-eu/editor`

### Notes app (external, `web-extensions`)

- Consumes `@opencloud-eu/editor` as a dependency
- Picks whichever content type fits its storage format

### Dependency flow

```
@opencloud-eu/editor (owns all tiptap deps)
├── web-app-mail
├── web-app-text-editor
└── web-extensions/notes (external)
```

Tiptap dependencies live only in `@opencloud-eu/editor`. No other package imports tiptap directly.

## Styling

The editor does not ship its own base styles. All typography, spacing, and visual styling inherits from the design system's Tailwind tokens and CSS custom properties (`--oc-*`).
37 changes: 37 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@opencloud-eu/editor",
"version": "0.0.0",
"private": true,
"description": "Content-type-aware rich text editor built on Tiptap",
"license": "AGPL-3.0",
"main": "src/index.ts",
"type": "module",
"dependencies": {
"@tiptap/core": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-image": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-table": "^3.20.4",
"@tiptap/extension-table-cell": "^3.20.4",
"@tiptap/extension-table-header": "^3.20.4",
"@tiptap/extension-table-row": "^3.20.4",
"@tiptap/extension-task-item": "^3.20.4",
"@tiptap/extension-task-list": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/markdown": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@tiptap/vue-3": "^3.20.4"
},
"peerDependencies": {
"@opencloud-eu/design-system": "workspace:^",
"vue": "^3.5.0",
"vue3-gettext": "^4.0.0-beta.1"
},
"devDependencies": {
"@opencloud-eu/web-test-helpers": "workspace:*"
}
}
15 changes: 15 additions & 0 deletions packages/editor/src/components/TextEditorContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<EditorContent v-if="textEditor.editor.value" :editor="textEditor.editor.value" />
</template>

<script setup lang="ts">
import { inject } from 'vue'
import { EditorContent } from '@tiptap/vue-3'
import type { TextEditorInstance } from '../types'

const textEditor = inject<TextEditorInstance>('textEditor')!
</script>

<style>
@import '../styles/content.css';
</style>
16 changes: 16 additions & 0 deletions packages/editor/src/components/TextEditorProvider.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<div class="text-editor-provider">
<slot />
</div>
</template>

<script setup lang="ts">
import { provide } from 'vue'
import type { TextEditorInstance } from '../types'

const props = defineProps<{
editor: TextEditorInstance
}>()

provide('textEditor', props.editor)
</script>
35 changes: 35 additions & 0 deletions packages/editor/src/components/TextEditorToolbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<template>
<div v-if="visible" class="text-editor-toolbar inline-flex items-center gap-3">
<div
v-for="(group, groupIndex) in textEditor.toolbarItems"
:key="groupIndex"
class="text-editor-toolbar-group inline-flex items-stretch rounded-lg overflow-hidden bg-role-surface-variant"
>
<oc-button
v-for="item in group"
:key="item.id"
type="button"
appearance="raw"
class="text-editor-toolbar-btn min-w-[42px] h-[35px] px-[11px] inline-flex items-center justify-center"
:class="{ 'text-editor-toolbar-btn--active': item.isActive(textEditor.editor.value!) }"
:aria-label="item.label"
@click.stop="item.action(textEditor.editor.value!)"
>
<oc-icon :name="item.icon" fill-type="none" size="small" />
</oc-button>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, inject } from 'vue'
import type { TextEditorInstance } from '../types'

const textEditor = inject<TextEditorInstance>('textEditor')!

const visible = computed(() => {
if (textEditor.readonly.value) return false
if (textEditor.contentType.value === 'plain-text') return false
return !!textEditor.editor.value
})
</script>
Loading
Loading