Skip to content
vue
<script setup lang="ts">
import 'prosekit/basic/style.css'

import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import {
  ref,
  watchPostEffect,
} from 'vue'

import { defineExtension } from './extension'
import InlineMenu from './inline-menu.vue'

const defaultContent = '<p>'
  + '<span style="color: #ef4444">Select</span> '
  + '<span style="color: #f97316">some</span> '
  + '<span style="color: #eab308">text</span> '
  + '<span style="color: #22c55e">to</span> '
  + '<span style="color: #3b82f6">change</span> '
  + '<span style="color: #6366f1">the</span> '
  + '<span style="color: #a855f7">color</span> '
  + '</p>'

const editor = createEditor({ extension: defineExtension(), defaultContent })

const editorRef = ref<HTMLDivElement | null>(null)
watchPostEffect((onCleanup) => {
  editor.mount(editorRef.value)
  onCleanup(() => editor.unmount())
})
</script>

<template>
  <ProseKit :editor="editor">
    <div class='box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 shadow dark:border-zinc-700 flex flex-col bg-white dark:bg-neutral-900'>
      <div class='relative w-full flex-1 box-border overflow-y-scroll'>
        <div
          ref="editorRef"
          spellcheck="false"
          class='ProseMirror box-border min-h-full px-[max(4rem,_calc(50%-20rem))] py-8 outline-none outline-0 [&_span[data-mention="user"]]:text-blue-500 [&_span[data-mention="tag"]]:text-violet-500 [&_pre]:text-white [&_pre]:bg-zinc-800'
        />
        <InlineMenu />
      </div>
    </div>
  </ProseKit>
</template>
ts
import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'

import { defineTextColor } from './text-color'

export function defineExtension() {
  return union(defineBasicExtension(), defineTextColor())
}

export type EditorExtension = ReturnType<typeof defineExtension>
vue
<script setup lang="ts">
import {
  useEditor,
  useKeymap,
} from 'prosekit/vue'
import { InlinePopover } from 'prosekit/vue/inline-popover'
import { ref } from 'vue'

import Button from './button.vue'
import type { EditorExtension } from './extension'

const editor = useEditor<EditorExtension>({ update: true })
const colors = [
  { name: 'default', value: '' },
  { name: 'red', value: '#ef4444' },
  { name: 'orange', value: '#f97316' },
  { name: 'yellow', value: '#eab308' },
  { name: 'green', value: '#22c55e' },
  { name: 'blue', value: '#3b82f6' },
  { name: 'indigo', value: '#6366f1' },
  { name: 'violet', value: '#a855f7' },
]

function hasTextColor(color: string) {
  return editor.value.marks.textColor.isActive({ color })
}

function toggleTextColor(color: string) {
  if (!color || hasTextColor(color)) {
    editor.value.commands.removeTextColor()
  } else {
    editor.value.commands.setTextColor({ color })
  }
}

const open = ref(false)
function onOpenChange(value: boolean) {
  open.value = value
}

function onEscape() {
  if (open.value) {
    open.value = false
    return true
  }
  return false
}

useKeymap({ Escape: onEscape })
</script>

<template>
  <InlinePopover
    class='z-10 box-border border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden relative flex min-w-[8rem] space-x-1 overflow-auto whitespace-nowrap rounded-md p-1'
    :open="open"
    @open-change="onOpenChange"
  >
    <Button
      v-for="color in colors"
      :key="color.name"
      :pressed="hasTextColor(color.value)"
      :tooltip="color.name"
      @click="() => toggleTextColor(color.value)"
    >
      <span :style="{ color: color.value }">A</span>
    </Button>
  </InlinePopover>
</template>
ts
import {
  addMark,
  defineCommands,
  defineMarkSpec,
  removeMark,
  union,
} from 'prosekit/core'
import type { Command } from 'prosekit/pm/state'

export interface TextColorAttrs {
  color: string | null
}

export function defineTextColorSpec() {
  return defineMarkSpec<'textColor', TextColorAttrs>({
    name: 'textColor',
    attrs: {
      color: { default: null },
    },
    parseDOM: [
      {
        style: 'color',
        getAttrs: (value) => {
          return { color: value }
        },
      },
    ],
    toDOM: (mark) => {
      return ['span', { style: `color: ${mark.attrs.color};` }, 0]
    },
  })
}

function setTextColor(attrs: TextColorAttrs): Command {
  return addMark({ type: 'textColor', attrs })
}

function removeTextColor(): Command {
  return removeMark({ type: 'textColor' })
}

export function defineTextColorCommands() {
  return defineCommands({
    setTextColor,
    removeTextColor,
  })
}

export function defineTextColor() {
  return union(defineTextColorSpec(), defineTextColorCommands())
}
vue
<script setup lang="ts">
import {
  TooltipContent,
  TooltipRoot,
  TooltipTrigger,
} from 'prosekit/vue/tooltip'

defineProps<{
  pressed?: Boolean
  disabled?: Boolean
  tooltip?: string
}>()

const emit = defineEmits<{
  click: []
}>()
</script>

<template>
  <TooltipRoot>
    <TooltipTrigger class='block'>
      <button
        :data-state="pressed ? 'on' : 'off'"
        :disabled="disabled ? true : undefined"
        class='outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 disabled:pointer-events-none min-w-9 min-h-9 disabled:opacity-50 hover:disabled:opacity-50 bg-transparent hover:bg-zinc-100 dark:hover:bg-zinc-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700'
        @click="() => emit('click')"
        @mousedown.prevent
      >
        <slot />
        <span v-if="tooltip" class="sr-only">{{ tooltip }}</span>
      </button>
    </TooltipTrigger>
    <TooltipContent v-if="tooltip" class='z-50 overflow-hidden rounded-md border border-solid bg-zinc-900 dark:bg-zinc-50 px-3 py-1.5 text-xs text-zinc-50 dark:text-zinc-900 shadow-sm [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'>
      {{ tooltip }}
    </TooltipContent>
  </TooltipRoot>
</template>