Skip to content

Commit 2cf5123

Browse files
feat: add Tiptap editor components including Editor, KeyboardShortcut (#9)
* feat: add Tiptap editor components including Editor, KeyboardShortcuts, StatusBar, SlotPanel, and context management * docs: update README
1 parent ba3cb39 commit 2cf5123

17 files changed

+2894
-0
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,43 @@ This library uses the following shadcn-ui components:
9191

9292
Make sure these components are available in your project.
9393

94+
## Development
95+
96+
- pnpm install
97+
- pnpm dev
98+
99+
This library is designed to be highly customizable. You can modify or extend any component to fit your specific needs:
100+
101+
### Component Structure
102+
103+
Components are located in the `components/tiptap` directory. Each component follows a consistent structure:
104+
105+
```
106+
components/tiptap/
107+
├── TiptapEditor.vue
108+
├── TiptapContent.vue
109+
├── TiptapToolbar.vue
110+
└── ...
111+
```
112+
113+
### Component Communication
114+
115+
Components communicate through the editor instance provided by the `TiptapProvider`. When making changes:
116+
117+
- Maintain the existing props and events to ensure compatibility
118+
- Use the editor instance for commands and state management
119+
- Emit appropriate events when modifying interactive elements
120+
121+
### Styling Guidelines
122+
123+
- Components use Tailwind CSS for styling
124+
- Follow the ShadCN design patterns for consistency
125+
- Use CSS variables defined in the theme for colors and spacing
126+
127+
### Testing Changes
128+
129+
After modifying components, test your changes thoroughly across different content types and editor states to ensure compatibility and stability.
130+
94131
## License
95132

96133
MIT
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<script setup lang="ts">
2+
import type { Editor } from '@tiptap/vue-3'
3+
import { cn } from '@/lib/utils'
4+
import { computed, onMounted, ref, watch, type HTMLAttributes } from 'vue'
5+
import { useTiptapContext } from '.'
6+
7+
const props = defineProps<{
8+
editor?: Editor | null
9+
class?: HTMLAttributes['class']
10+
placeholder?: string
11+
}>()
12+
13+
// Get editor from context if not provided directly
14+
const { editor: contextEditor } = useTiptapContext()
15+
const editor = computed(() => props.editor ?? contextEditor.value)
16+
17+
// Editor is ready when it's available and initialized
18+
const isEditorReady = computed(() => {
19+
return editor.value && editor.value.isEditable
20+
})
21+
22+
// Reference to the editor element
23+
const editorElement = ref<HTMLElement | null>(null)
24+
25+
// Mount editor to DOM when editor is available or element changes
26+
watch([editor, editorElement], ([currentEditor, element]) => {
27+
if (currentEditor && element && !element.firstChild) {
28+
// If editor is ready but not yet mounted to this element
29+
element.append(currentEditor.view.dom)
30+
}
31+
}, { immediate: true })
32+
33+
// Also mount on component mount in case both are already available
34+
onMounted(() => {
35+
if (isEditorReady.value && editorElement.value && !editorElement.value.firstChild) {
36+
editorElement.value.append(editor.value!.view.dom)
37+
}
38+
39+
if (isEditorReady.value && editorElement.value && editorElement.value.firstChild) {
40+
// Add appropriate ARIA attributes
41+
const editorDOM = editorElement.value.firstChild as HTMLElement
42+
editorDOM.setAttribute('role', 'textbox')
43+
editorDOM.setAttribute('aria-multiline', 'true')
44+
editorDOM.setAttribute('aria-label', 'Rich text editor')
45+
46+
// Add content description for screen readers
47+
const srDescription = document.createElement('span')
48+
srDescription.className = 'sr-only'
49+
srDescription.textContent = 'Use keyboard shortcuts like Ctrl+B for bold, Ctrl+I for italic. Press Alt+F10 for the toolbar.'
50+
editorElement.value.appendChild(srDescription)
51+
}
52+
})
53+
</script>
54+
55+
<template>
56+
<div
57+
:class="cn(
58+
'tiptap-editor-wrapper relative',
59+
props.class
60+
)"
61+
data-slot="tiptap-content"
62+
aria-label="Rich text editor"
63+
>
64+
<div
65+
v-if="isEditorReady"
66+
ref="editorElement"
67+
class="prose prose-sm sm:prose lg:prose-lg xl:prose-xl dark:prose-invert h-full w-full focus:outline-none max-w-none px-4 py-6"
68+
role="region"
69+
/>
70+
71+
<div
72+
v-else
73+
class="tiptap-editor-placeholder h-full w-full flex items-center justify-center text-muted-foreground text-sm italic"
74+
aria-live="polite"
75+
>
76+
{{ placeholder || 'Loading editor...' }}
77+
</div>
78+
</div>
79+
</template>
80+
81+
<style>
82+
/* Editor styles */
83+
.tiptap-editor-wrapper .ProseMirror {
84+
min-height: 100px;
85+
height: 100%;
86+
border: none;
87+
outline: none;
88+
}
89+
90+
.tiptap-editor-wrapper .ProseMirror p.is-editor-empty:first-child::before {
91+
color: #adb5bd;
92+
content: attr(data-placeholder);
93+
float: left;
94+
height: 0;
95+
pointer-events: none;
96+
}
97+
98+
/* Component styles */
99+
.tiptap-editor-wrapper .component-node {
100+
margin-top: 1rem;
101+
margin-bottom: 1rem;
102+
}
103+
104+
.tiptap-editor-wrapper .component-selected {
105+
outline: 2px solid #3b82f6;
106+
outline-offset: 2px;
107+
}
108+
109+
/* Heading styles */
110+
.tiptap-editor-wrapper h1 {
111+
font-size: 2em;
112+
margin-top: 0.67em;
113+
margin-bottom: 0.67em;
114+
}
115+
116+
.tiptap-editor-wrapper h2 {
117+
font-size: 1.5em;
118+
margin-top: 0.83em;
119+
margin-bottom: 0.83em;
120+
}
121+
122+
.tiptap-editor-wrapper h3 {
123+
font-size: 1.17em;
124+
margin-top: 1em;
125+
margin-bottom: 1em;
126+
}
127+
128+
/* Node selection styles */
129+
.tiptap-editor-wrapper .ProseMirror .is-node-selected {
130+
outline: 2px solid #3b82f6;
131+
outline-offset: 2px;
132+
}
133+
134+
/* Placeholder for empty nodes */
135+
.tiptap-editor-wrapper .ProseMirror p.is-empty::before {
136+
color: #adb5bd;
137+
content: attr(data-placeholder);
138+
float: left;
139+
height: 0;
140+
pointer-events: none;
141+
}
142+
143+
/* Focus indicators for accessibility */
144+
.tiptap-editor-wrapper .ProseMirror:focus-visible {
145+
outline: 2px solid hsl(var(--primary));
146+
outline-offset: 2px;
147+
}
148+
149+
/* Better focus styles for interactive elements */
150+
.tiptap-editor-wrapper .ProseMirror *[data-interactive]:focus-visible {
151+
outline: 2px solid hsl(var(--primary));
152+
outline-offset: 2px;
153+
}
154+
155+
/* High contrast mode support */
156+
@media (forced-colors: active) {
157+
.tiptap-editor-wrapper .ProseMirror:focus-visible {
158+
outline: 3px solid CanvasText;
159+
}
160+
161+
.tiptap-editor-wrapper .component-selected {
162+
outline: 3px solid Highlight;
163+
}
164+
}
165+
</style>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import type { Editor } from '@tiptap/vue-3'
3+
import { cn } from '@/lib/utils'
4+
import { computed, type HTMLAttributes, ref } from 'vue'
5+
import { useTiptapContext } from '.'
6+
7+
const props = defineProps<{
8+
editor: Editor | null
9+
class?: HTMLAttributes['class']
10+
fullScreen?: boolean
11+
}>()
12+
13+
// Try to use the existing context from a parent TiptapProvider
14+
let editorContext
15+
try {
16+
editorContext = useTiptapContext()
17+
} catch (e) {
18+
// No provider found, this editor will manage its own state
19+
editorContext = null
20+
}
21+
22+
// If there's a provider context, we'll use that
23+
// Otherwise we'll render our own provider
24+
const useExternalProvider = computed(() => !!editorContext)
25+
</script>
26+
27+
<template>
28+
<div
29+
:class="cn(
30+
'tiptap-editor',
31+
props.fullScreen && 'fixed inset-0 z-50',
32+
props.class
33+
)"
34+
data-slot="tiptap-editor"
35+
>
36+
<TiptapProvider v-if="!useExternalProvider" :editor="props.editor">
37+
<slot />
38+
</TiptapProvider>
39+
<template v-else>
40+
<slot />
41+
</template>
42+
</div>
43+
</template>

0 commit comments

Comments
 (0)