
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
rich-text-editoruiWYSIWYG editor wrapped around TipTap — bold/italic/links/lists/headings/blockquote/code, plus a configurable toolbar. Drop into forms where Markdown is too low-level.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/rich-text-editor.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/rich-text-editor.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/rich-text-editor.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/rich-text-editor.jsonnpx shadcn-vue@latest add @uipkge/rich-text-editorInstalls to:app/components/ui/rich-text-editor/| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | string | '' | optional |
placeholder | string | 'Start writing...' | optional |
class | string | — | optional |
editorClass | string | — | optional |
minHeight | string | '120px' | optional |
Type aliases from this item's source — use them to shape the data you pass in.
ToolbarIteminterface ToolbarItem {
type: 'button' | 'separator' | 'link'
icon?: any
action?: () => void
isActive?: () => boolean
title?: string
}<script setup lang="ts">
import { computed, watch, onBeforeUnmount, ref, useId } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
List,
ListOrdered,
ListChecks,
AlignLeft,
AlignCenter,
AlignRight,
Link as LinkIcon,
Heading1,
Heading2,
Quote,
Minus,
Undo2,
Redo2,
Code,
RemoveFormatting,
ChevronDown,
} from 'lucide-vue-next'
import { Toggle } from '@/components/ui/toggle'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
class?: string
editorClass?: string
minHeight?: string
}>(),
{
modelValue: '',
placeholder: 'Start writing...',
minHeight: '120px',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
// StarterKit ships its own link + underline since v3 — disable them so
// we can register the standalone packages with our own configuration
// without TipTap warning about duplicate extension names.
link: false,
underline: false,
}),
Placeholder.configure({ placeholder: props.placeholder }),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-primary underline cursor-pointer' },
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
TaskItem.configure({ nested: true }),
],
editorProps: {
attributes: {
class: 'prose prose-sm dark:prose-invert max-w-none focus:outline-none',
},
},
onUpdate: ({ editor: e }) => {
emit('update:modelValue', e.getHTML())
},
})
watch(
() => props.modelValue,
(val) => {
if (editor.value && editor.value.getHTML() !== val) {
editor.value.commands.setContent(val || '', { emitUpdate: false })
}
},
)
onBeforeUnmount(() => {
editor.value?.destroy()
})
// Link editing lives in a popover rather than window.prompt: a native prompt
// is unstyleable, blocks the main thread, cannot be tested, and is suppressed
// outright in sandboxed iframes and some mobile browsers.
const linkOpen = ref(false)
const linkUrl = ref('')
const linkFieldId = useId()
function openLinkEditor() {
if (!editor.value) return
// Prefill with the current href so the popover edits instead of replaces.
linkUrl.value = (editor.value.getAttributes('link').href as string | undefined) ?? ''
linkOpen.value = true
}
function applyLink() {
const url = linkUrl.value.trim()
if (!editor.value || !url) return
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
linkOpen.value = false
}
function removeLink() {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
linkUrl.value = ''
linkOpen.value = false
}
interface ToolbarItem {
type: 'button' | 'separator' | 'link'
icon?: any
action?: () => void
isActive?: () => boolean
title?: string
}
const showExtended = ref(false)
const essentialItems = computed<ToolbarItem[]>(() => {
if (!editor.value) return []
const e = editor.value
return [
{
type: 'button',
icon: Bold,
action: () => e.chain().focus().toggleBold().run(),
isActive: () => e.isActive('bold'),
title: 'Bold',
},
{
type: 'button',
icon: Italic,
action: () => e.chain().focus().toggleItalic().run(),
isActive: () => e.isActive('italic'),
title: 'Italic',
},
{
type: 'button',
icon: UnderlineIcon,
action: () => e.chain().focus().toggleUnderline().run(),
isActive: () => e.isActive('underline'),
title: 'Underline',
},
{
type: 'button',
icon: Strikethrough,
action: () => e.chain().focus().toggleStrike().run(),
isActive: () => e.isActive('strike'),
title: 'Strikethrough',
},
{ type: 'separator' },
{
type: 'button',
icon: List,
action: () => e.chain().focus().toggleBulletList().run(),
isActive: () => e.isActive('bulletList'),
title: 'Bullet list',
},
{
type: 'button',
icon: ListOrdered,
action: () => e.chain().focus().toggleOrderedList().run(),
isActive: () => e.isActive('orderedList'),
title: 'Numbered list',
},
{ type: 'separator' },
{ type: 'link', icon: LinkIcon, action: openLinkEditor, isActive: () => e.isActive('link'), title: 'Link' },
{ type: 'separator' },
{ type: 'button', icon: Undo2, action: () => e.chain().focus().undo().run(), isActive: () => false, title: 'Undo' },
{ type: 'button', icon: Redo2, action: () => e.chain().focus().redo().run(), isActive: () => false, title: 'Redo' },
]
})
const extendedItems = computed<ToolbarItem[]>(() => {
if (!editor.value) return []
const e = editor.value
return [
{
type: 'button',
icon: Heading1,
action: () => e.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => e.isActive('heading', { level: 1 }),
title: 'Heading 1',
},
{
type: 'button',
icon: Heading2,
action: () => e.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => e.isActive('heading', { level: 2 }),
title: 'Heading 2',
},
{ type: 'separator' },
{
type: 'button',
icon: Code,
action: () => e.chain().focus().toggleCode().run(),
isActive: () => e.isActive('code'),
title: 'Inline code',
},
{
type: 'button',
icon: Quote,
action: () => e.chain().focus().toggleBlockquote().run(),
isActive: () => e.isActive('blockquote'),
title: 'Blockquote',
},
{
type: 'button',
icon: Minus,
action: () => e.chain().focus().setHorizontalRule().run(),
isActive: () => false,
title: 'Divider',
},
{
type: 'button',
icon: ListChecks,
action: () => e.chain().focus().toggleTaskList().run(),
isActive: () => e.isActive('taskList'),
title: 'Task list',
},
{ type: 'separator' },
{
type: 'button',
icon: AlignLeft,
action: () => e.chain().focus().setTextAlign('left').run(),
isActive: () => e.isActive({ textAlign: 'left' }),
title: 'Align left',
},
{
type: 'button',
icon: AlignCenter,
action: () => e.chain().focus().setTextAlign('center').run(),
isActive: () => e.isActive({ textAlign: 'center' }),
title: 'Align center',
},
{
type: 'button',
icon: AlignRight,
action: () => e.chain().focus().setTextAlign('right').run(),
isActive: () => e.isActive({ textAlign: 'right' }),
title: 'Align right',
},
{ type: 'separator' },
{
type: 'button',
icon: RemoveFormatting,
action: () => e.chain().focus().clearNodes().unsetAllMarks().run(),
isActive: () => false,
title: 'Clear formatting',
},
]
})
</script>
<template>
<div data-uipkge data-slot="rich-text-editor" :class="cn('rich-text-editor rounded-lg border', props.class)">
<!-- Toolbar -->
<div v-if="editor" class="border-b" role="toolbar" aria-label="Text formatting">
<!-- Essential row -->
<div class="flex items-center gap-0.5 px-2 py-1.5">
<template v-for="(item, i) in essentialItems" :key="'e' + i">
<Separator v-if="item.type === 'separator'" orientation="vertical" class="mx-1 h-5" />
<Popover v-else-if="item.type === 'link'" v-model:open="linkOpen">
<PopoverTrigger as-child>
<Toggle
size="sm"
:pressed="item.isActive?.()"
:title="item.title"
:aria-label="item.title"
class="focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none"
@click="openLinkEditor"
>
<component :is="item.icon" class="size-3.5" aria-hidden="true" />
</Toggle>
</PopoverTrigger>
<PopoverContent align="start" class="w-72 p-3">
<form class="flex flex-col gap-2" @submit.prevent="applyLink">
<label :for="`${linkFieldId}`" class="text-foreground text-xs font-medium">Link URL</label>
<Input
:id="linkFieldId"
v-model="linkUrl"
type="url"
size="small"
placeholder="https://example.com"
autocomplete="url"
spellcheck="false"
/>
<div class="flex items-center justify-end gap-2">
<Button v-if="item.isActive?.()" type="button" variant="ghost" size="sm" @click="removeLink">
Remove
</Button>
<Button type="submit" size="sm" :disabled="!linkUrl.trim()">Apply</Button>
</div>
</form>
</PopoverContent>
</Popover>
<Toggle
v-else
size="sm"
:pressed="item.isActive?.()"
:title="item.title"
:aria-label="item.title"
class="focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none"
@click="item.action?.()"
>
<component :is="item.icon" class="size-3.5" aria-hidden="true" />
</Toggle>
</template>
<Separator orientation="vertical" class="mx-1 h-5" />
<!-- Expand toggle -->
<button
type="button"
:title="showExtended ? 'Hide more options' : 'Show more options'"
:aria-label="showExtended ? 'Hide more options' : 'Show more options'"
:aria-expanded="showExtended"
:class="[
'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring inline-flex size-7 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none',
showExtended && 'bg-muted text-foreground',
]"
@click="showExtended = !showExtended"
>
<ChevronDown
:class="['size-3.5 transition-transform duration-200', showExtended && 'rotate-180']"
aria-hidden="true"
/>
</button>
</div>
<!-- Extended row (collapsible) -->
<div
:class="[
'grid transition-colors duration-200 ease-in-out',
showExtended ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
]"
>
<div class="overflow-hidden">
<div class="flex items-center gap-0.5 border-t px-2 py-1.5">
<template v-for="(item, i) in extendedItems" :key="'x' + i">
<Separator v-if="item.type === 'separator'" orientation="vertical" class="mx-1 h-5" />
<Toggle
v-else
size="sm"
:pressed="item.isActive?.()"
:title="item.title"
:aria-label="item.title"
class="focus-visible:ring-ring size-7 p-0 focus-visible:ring-2 focus-visible:outline-none"
@click="item.action?.()"
>
<component :is="item.icon" class="size-3.5" aria-hidden="true" />
</Toggle>
</template>
</div>
</div>
</div>
</div>
<!-- Editor -->
<EditorContent
:editor="editor"
:class="cn('rich-text-content cursor-text overflow-y-auto px-3 py-2', props.editorClass)"
:style="{ minHeight: props.minHeight }"
@click="editor?.commands.focus()"
/>
</div>
</template>
<style>
/* Editor placeholder */
.rich-text-editor .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
opacity: 0.5;
pointer-events: none;
height: 0;
}
/* Prose overrides for compact styling */
.rich-text-content .tiptap {
min-height: inherit;
}
.rich-text-content .tiptap > *:first-child {
margin-top: 0;
}
.rich-text-content .tiptap > *:last-child {
margin-bottom: 0;
}
.rich-text-content .tiptap h1 {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.3;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.rich-text-content .tiptap h2 {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.3;
margin-top: 0.75rem;
margin-bottom: 0.375rem;
}
.rich-text-content .tiptap p {
font-size: 0.875rem;
line-height: 1.6;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rich-text-content .tiptap ul,
.rich-text-content .tiptap ol {
padding-left: 1.25rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rich-text-content .tiptap li {
font-size: 0.875rem;
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
.rich-text-content .tiptap blockquote {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
color: var(--muted-foreground);
font-style: italic;
}
.rich-text-content .tiptap code {
background: var(--muted);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
font-size: 0.8rem;
font-family: ui-monospace, monospace;
}
.rich-text-content .tiptap pre {
background: var(--muted);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.rich-text-content .tiptap pre code {
background: none;
padding: 0;
font-size: 0.8rem;
}
.rich-text-content .tiptap hr {
border-color: var(--border);
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
/* Task list styling */
.rich-text-content .tiptap ul[data-type='taskList'] {
list-style: none;
padding-left: 0;
}
.rich-text-content .tiptap ul[data-type='taskList'] li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.rich-text-content .tiptap ul[data-type='taskList'] li > label {
flex-shrink: 0;
margin-top: 0.125rem;
}
.rich-text-content .tiptap ul[data-type='taskList'] li > label input[type='checkbox'] {
accent-color: var(--primary);
width: 0.875rem;
height: 0.875rem;
cursor: pointer;
}
.rich-text-content .tiptap ul[data-type='taskList'] li > div {
flex: 1;
}
/* Link styling */
.rich-text-content .tiptap a {
color: var(--primary);
text-decoration: underline;
cursor: pointer;
}
</style>
export { default as RichTextEditor } from './RichTextEditor.vue'
Raw manifest:https://uipkge.dev/r/vue/rich-text-editor.json