Repository: ByteSliceHQ/deepdish
Pull request #206: feat(workbench): add rich-text editing support
Author: login: dylnslck
Created: 2025-03-22T17:11:51Z | Merged: 2025-03-23T19:58:40Z

Resolves DEEP-256


Diff:
diff --git a/.changeset/old-lights-wait.md b/.changeset/old-lights-wait.md
new file mode 100644
index 00000000..9a283b8f
--- /dev/null
+++ b/.changeset/old-lights-wait.md
@@ -0,0 +1,5 @@
+---
+"@deepdish/workbench": minor
+---
+
+Added support for rich-text editing.
diff --git a/packages/workbench/src/components/json-schema-form/dynamic-form.tsx b/packages/workbench/src/components/json-schema-form/dynamic-form.tsx
index 4be89198..1fbe637a 100644
--- a/packages/workbench/src/components/json-schema-form/dynamic-form.tsx
+++ b/packages/workbench/src/components/json-schema-form/dynamic-form.tsx
@@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'
 import { Checkbox } from '@/components/ui/checkbox'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
+import type { Meta } from '@deepdish/core/schema'
 import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'
 import { useEffect, useMemo } from 'react'
 import {
@@ -12,13 +13,15 @@ import {
   useForm,
 } from 'react-hook-form'
 import type { Content } from '.'
+import { RichText } from '../rich-text'
 
 function renderFields(
   uniqueId: string,
   properties: Record<string, JSONSchema7Definition>,
   register: UseFormRegister<FieldValues>,
   control: Control<FieldValues>,
-  parentKey?: string,
+  parentKey: string,
+  meta: Record<string, Meta>,
 ) {
   return Object.entries(properties).map(([key, subSchema]) => {
     if (typeof subSchema === 'boolean') {
@@ -37,13 +40,15 @@ function renderFields(
             register,
             control,
             fieldName,
+            meta,
           )}
         </Fieldset>
       )
     }
 
     if (subSchema.type === 'string') {
-      // TODO: support rich-text editing
+      const rich = Boolean(meta[fieldName]?.rich)
+
       return (
         <Field
           key={fieldName}
@@ -55,6 +60,16 @@ function renderFields(
             name={fieldName}
             control={control}
             render={({ field: { onChange, value } }) => {
+              if (rich) {
+                return (
+                  <RichText
+                    content={value || ''}
+                    uniqueId={fieldName}
+                    onChange={onChange}
+                  />
+                )
+              }
+
               return (
                 <Input id={fieldName} value={value || ''} onChange={onChange} />
               )
@@ -130,7 +145,11 @@ function renderFields(
   })
 }
 
-function getDefaultValues(schema: JSONSchema7, content: unknown): FieldValues {
+function getDefaultValues(
+  schema: JSONSchema7,
+  content: unknown,
+  rootKey: string,
+): FieldValues {
   const schemaAndContentAreCompatible =
     schema.type === 'object' &&
     schema.properties &&
@@ -138,7 +157,9 @@ function getDefaultValues(schema: JSONSchema7, content: unknown): FieldValues {
     content !== null
 
   if (schemaAndContentAreCompatible) {
-    return content
+    return {
+      [rootKey]: content,
+    }
   }
 
   // TODO: adjust warning for null content
@@ -191,13 +212,15 @@ function Fieldset(props: {
 
 export function DynamicForm(props: {
   content: unknown
+  contentRootKey: string
   onSubmit: (content: Content) => void
   schema: JSONSchema7
+  meta: Record<string, Meta>
   uniqueId: string
 }) {
   const defaultValues: FieldValues = useMemo(() => {
-    return getDefaultValues(props.schema, props.content)
-  }, [props.schema, props.content])
+    return getDefaultValues(props.schema, props.content, props.contentRootKey)
+  }, [props.schema, props.content, props.contentRootKey])
 
   // TODO: validation, error states, etc.
   const form = useForm({
@@ -208,14 +231,21 @@ export function DynamicForm(props: {
     form.reset(defaultValues)
   }, [form, defaultValues])
 
+  function handleSubmit(content: Record<typeof props.contentRootKey, Content>) {
+    const value = content[props.contentRootKey]
+    props.onSubmit(value)
+  }
+
   if (props.schema.type === 'object' && props.schema.properties) {
     return (
-      <form onSubmit={form.handleSubmit(props.onSubmit)} className="space-y-4">
+      <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
         {renderFields(
           props.uniqueId,
           props.schema.properties,
           form.register,
           form.control,
+          props.contentRootKey,
+          props.meta,
         )}
         <Button
           variant="default"
diff --git a/packages/workbench/src/components/json-schema-form/index.tsx b/packages/workbench/src/components/json-schema-form/index.tsx
index 4402ad6a..4d930dd6 100644
--- a/packages/workbench/src/components/json-schema-form/index.tsx
+++ b/packages/workbench/src/components/json-schema-form/index.tsx
@@ -1,3 +1,4 @@
+import type { Meta } from '@deepdish/core/schema'
 import type { JSONSchema7 } from 'json-schema'
 import { useEffect, useRef, useState } from 'react'
 import { DynamicForm } from './dynamic-form'
@@ -7,9 +8,11 @@ export type Content = object | string | number | boolean | null | undefined
 
 export function JsonSchemaForm(props: {
   content: unknown
+  contentRootKey: string
   onSubmit: (content: Content) => Promise<void>
   schema: JSONSchema7
   uniqueId: string
+  meta: Record<string, Meta>
 }) {
   const initialUniqueId = useRef<string>(props.uniqueId)
   const [shouldRender, setShouldRender] = useState<boolean>(false)
@@ -33,21 +36,27 @@ export function JsonSchemaForm(props: {
   }
 
   if (props.schema.type === 'string') {
+    const meta = props.meta[props.contentRootKey]
+
     return (
       <SimpleTextForm
-        uniqueId={props.uniqueId}
         content={props.content as string}
+        meta={meta}
         onSubmit={props.onSubmit}
+        uniqueId={props.uniqueId}
       />
     )
   }
 
   if (props.schema.type === 'number') {
+    const meta = props.meta[props.contentRootKey]
+
     return (
       <SimpleNumberForm
-        uniqueId={props.uniqueId}
         content={props.content as number}
+        meta={meta}
         onSubmit={props.onSubmit}
+        uniqueId={props.uniqueId}
       />
     )
   }
diff --git a/packages/workbench/src/components/json-schema-form/simple-form.tsx b/packages/workbench/src/components/json-schema-form/simple-form.tsx
index 223988a0..499d4b1f 100644
--- a/packages/workbench/src/components/json-schema-form/simple-form.tsx
+++ b/packages/workbench/src/components/json-schema-form/simple-form.tsx
@@ -1,7 +1,9 @@
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
+import type { Meta } from '@deepdish/core/schema'
 import { useState } from 'react'
 import type { Content } from '.'
+import { RichText } from '../rich-text'
 
 function Wrapper(props: {
   children: React.ReactNode
@@ -11,6 +13,7 @@ function Wrapper(props: {
 
 export function SimpleNumberForm(props: {
   content: number
+  meta: Meta
   onSubmit: (content: Content) => Promise<void>
   uniqueId: string
 }) {
@@ -44,6 +47,7 @@ export function SimpleNumberForm(props: {
 
 export function SimpleTextForm(props: {
   content: string
+  meta: Meta
   onSubmit: (content: Content) => void
   uniqueId: string
 }) {
@@ -58,10 +62,18 @@ export function SimpleTextForm(props: {
 
   return (
     <Wrapper>
-      <Input
-        value={value || ''}
-        onChange={(e) => setValue(e.currentTarget.value)}
-      />
+      {props.meta.rich ? (
+        <RichText
+          content={value || ''}
+          uniqueId={props.uniqueId}
+          onChange={setValue}
+        />
+      ) : (
+        <Input
+          value={value || ''}
+          onChange={(e) => setValue(e.currentTarget.value)}
+        />
+      )}
       <Button
         variant="default"
         size="sm"
diff --git a/packages/workbench/src/components/rich-text/formatting-controls.tsx b/packages/workbench/src/components/rich-text/formatting-controls.tsx
index 83b93ebd..4628946d 100644
--- a/packages/workbench/src/components/rich-text/formatting-controls.tsx
+++ b/packages/workbench/src/components/rich-text/formatting-controls.tsx
@@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'
 import type { Editor } from '@tiptap/react'
 import {
   BoldIcon,
+  BracesIcon,
   CodeIcon,
   Heading1Icon,
   Heading2Icon,
@@ -14,101 +15,111 @@ import {
   TextQuoteIcon,
 } from 'lucide-react'
 
+function FormatButton(props: {
+  active?: boolean
+  children: React.ReactNode
+  disabled?: boolean
+  onClick: () => void
+}) {
+  return (
+    <Button
+      type="button"
+      onClick={props.onClick}
+      disabled={props.disabled}
+      variant={props.active ? 'secondary' : 'ghost'}
+    >
+      {props.children}
+    </Button>
+  )
+}
+
 export function FormattingControls(props: { editor: Editor }) {
   return (
     <div className="flex items-center gap-1 px-3 py-2 bg-white rounded-md border overflow-x-auto">
-      <Button
+      <FormatButton
+        active={props.editor.isActive('bold')}
         onClick={() => props.editor.chain().focus().toggleBold().run()}
         disabled={!props.editor.can().chain().focus().toggleBold().run()}
-        variant={props.editor.isActive('bold') ? 'secondary' : 'ghost'}
       >
         <BoldIcon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('italic')}
         onClick={() => props.editor.chain().focus().toggleItalic().run()}
         disabled={!props.editor.can().chain().focus().toggleItalic().run()}
-        variant={props.editor.isActive('italic') ? 'secondary' : 'ghost'}
       >
         <ItalicIcon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('strike')}
         onClick={() => props.editor.chain().focus().toggleStrike().run()}
         disabled={!props.editor.can().chain().focus().toggleStrike().run()}
-        variant={props.editor.isActive('strike') ? 'secondary' : 'ghost'}
       >
         <StrikethroughIcon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('code')}
         onClick={() => props.editor.chain().focus().toggleCode().run()}
         disabled={!props.editor.can().chain().focus().toggleCode().run()}
-        variant={props.editor.isActive('code') ? 'secondary' : 'ghost'}
       >
         <CodeIcon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('heading', { level: 1 })}
         onClick={() =>
           props.editor.chain().focus().toggleHeading({ level: 1 }).run()
         }
-        variant={
-          props.editor.isActive('heading', { level: 1 }) ? 'secondary' : 'ghost'
-        }
       >
         <Heading1Icon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('heading', { level: 2 })}
         onClick={() =>
           props.editor.chain().focus().toggleHeading({ level: 2 }).run()
         }
-        variant={
-          props.editor.isActive('heading', { level: 2 }) ? 'secondary' : 'ghost'
-        }
       >
         <Heading2Icon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('heading', { level: 3 })}
         onClick={() =>
           props.editor.chain().focus().toggleHeading({ level: 3 }).run()
         }
-        variant={
-          props.editor.isActive('heading', { level: 3 }) ? 'secondary' : 'ghost'
-        }
       >
         <Heading3Icon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('heading', { level: 4 })}
         onClick={() =>
           props.editor.chain().focus().toggleHeading({ level: 4 }).run()
         }
-        variant={
-          props.editor.isActive('heading', { level: 4 }) ? 'secondary' : 'ghost'
-        }
       >
         <Heading4Icon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('bulletList')}
         onClick={() => props.editor.chain().focus().toggleBulletList().run()}
-        variant={props.editor.isActive('bulletList') ? 'secondary' : 'ghost'}
       >
         <ListIcon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('orderedList')}
         onClick={() => props.editor.chain().focus().toggleOrderedList().run()}
-        variant={props.editor.isActive('orderedList') ? 'secondary' : 'ghost'}
       >
         <ListOrderedIcon className="size-4" />
-      </Button>
-      <Button
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('codeBlock')}
         onClick={() => props.editor.chain().focus().toggleCodeBlock().run()}
-        variant={props.editor.isActive('codeBlock') ? 'secondary' : 'ghost'}
       >
-        <CodeIcon className="size-4" />
-      </Button>
-      <Button
+        <BracesIcon className="size-4" />
+      </FormatButton>
+      <FormatButton
+        active={props.editor.isActive('blockquote')}
         onClick={() => props.editor.chain().focus().toggleBlockquote().run()}
-        variant={props.editor.isActive('blockquote') ? 'secondary' : 'ghost'}
       >
         <TextQuoteIcon className="size-4" />
-      </Button>
+      </FormatButton>
     </div>
   )
 }
diff --git a/packages/workbench/src/components/rich-text/index.tsx b/packages/workbench/src/components/rich-text/index.tsx
index 82891239..1e8a3d69 100644
--- a/packages/workbench/src/components/rich-text/index.tsx
+++ b/packages/workbench/src/components/rich-text/index.tsx
@@ -66,7 +66,7 @@ export function RichText(props: {
         onFocus={() => setFocused(true)}
         onBlur={() => setFocused(false)}
         editor={editor}
-        className="py-3 px-4 text-sm"
+        className="[&>div[contenteditable=true]]:py-2 [&>[contenteditable=true]]:px-3 text-sm"
       />
       <BubbleMenu editor={editor}>
         <FormattingControls editor={editor} />
diff --git a/packages/workbench/src/lib/context.tsx b/packages/workbench/src/lib/context.tsx
index 4763d33e..0e573e9b 100644
--- a/packages/workbench/src/lib/context.tsx
+++ b/packages/workbench/src/lib/context.tsx
@@ -1,3 +1,4 @@
+import type { Meta } from '@deepdish/core/schema'
 import type { JSONSchema7 } from 'json-schema'
 import { createContext, useContext, useState } from 'react'
 import { Tailwind } from 'react-shadow-scope'
@@ -5,6 +6,7 @@ import { Tailwind } from 'react-shadow-scope'
 export type Procedures = {
   getContracts: () => Promise<string[]>
   getContractKeys: (name: string) => Promise<string[]>
+  getContractMeta: (name: string) => Promise<Record<string, Meta>>
   getContractSchema: (name: string) => Promise<JSONSchema7>
   getKey: (
     contract: string,
diff --git a/packages/workbench/src/lib/queries.ts b/packages/workbench/src/lib/queries.ts
index f3ebf279..38ade30b 100644
--- a/packages/workbench/src/lib/queries.ts
+++ b/packages/workbench/src/lib/queries.ts
@@ -43,6 +43,11 @@ export function useContractKeys(contractName: string) {
   return useSuspenseQuery(contractKeysOptions(procedures, contractName))
 }
 
+export function useContractMeta(contractName: string) {
+  const procedures = useProcedures()
+  return useSuspenseQuery(contractMetaOptions(procedures, contractName))
+}
+
 export function useContractSchema(contractName: string) {
   const procedures = useProcedures()
   return useSuspenseQuery(contractSchemaOptions(procedures, contractName))
@@ -113,6 +118,18 @@ export function contractKeysOptions(
   })
 }
 
+export function contractMetaOptions(
+  procedures: Procedures,
+  contractName: string,
+) {
+  return queryOptions({
+    queryKey: ['contracts', contractName, 'meta'],
+    queryFn: async () => {
+      return await procedures.getContractMeta(contractName)
+    },
+  })
+}
+
 export function contractSchemaOptions(
   procedures: Procedures,
   contractName: string,
diff --git a/packages/workbench/src/mocks/data.ts b/packages/workbench/src/mocks/data.ts
index 7ec1718c..ada4bbdf 100644
--- a/packages/workbench/src/mocks/data.ts
+++ b/packages/workbench/src/mocks/data.ts
@@ -27,7 +27,7 @@ export const catalog = {
   },
   feature: {
     schema: featureSchema,
-    serializedSchema: serialize(blogSchema),
+    serializedSchema: serialize(featureSchema),
     keys: {
       'home/feature-1': {
         name: 'Feature 1 Name',
@@ -42,7 +42,7 @@ export const catalog = {
   },
   text: {
     schema: textSchema,
-    serializedSchema: serialize(blogSchema),
+    serializedSchema: serialize(textSchema),
     keys: {
       'home/headline': 'Headline',
       'home/sub-headline': 'Sub-Headline',
diff --git a/packages/workbench/src/mocks/procedures.ts b/packages/workbench/src/mocks/procedures.ts
index 1725fc8b..385ba2c4 100644
--- a/packages/workbench/src/mocks/procedures.ts
+++ b/packages/workbench/src/mocks/procedures.ts
@@ -16,6 +16,15 @@ export const procedures = {
 
     return Object.keys(catalog[name as keyof typeof catalog].keys)
   },
+  getContractMeta: async (name: string) => {
+    await delay(100)
+
+    if (!(name in catalog)) {
+      throw new Error(`Contract '${name}' not found.`)
+    }
+
+    return catalog[name as keyof typeof catalog].meta
+  },
   getContractSchema: async (name: string) => {
     await delay(100)
 
diff --git a/packages/workbench/src/routes/catalog/$contract/$key.tsx b/packages/workbench/src/routes/catalog/$contract/$key.tsx
index db04a65c..23d6ab78 100644
--- a/packages/workbench/src/routes/catalog/$contract/$key.tsx
+++ b/packages/workbench/src/routes/catalog/$contract/$key.tsx
@@ -17,6 +17,7 @@ import {
 import {
   contractsOptions,
   keyOptions,
+  useContractMeta,
   useKey,
   useUpdateKey,
 } from '@/lib/queries'
@@ -125,6 +126,7 @@ function Breadcrumbs() {
 function RouteComponent() {
   const { contract, key } = Route.useParams()
   const { data } = useKey(contract, key)
+  const { data: meta } = useContractMeta(contract)
   const { mutateAsync: updateKey } = useUpdateKey(contract, key)
 
   async function handleSubmit(content: unknown) {
@@ -148,8 +150,10 @@ function RouteComponent() {
         <div className="p-4 flex-1 overflow-y-auto">
           <JsonSchemaForm
             content={data.content}
-            schema={data.schema}
+            contentRootKey="root"
             onSubmit={handleSubmit}
+            meta={meta}
+            schema={data.schema}
             uniqueId={data.name}
           />
         </div>
diff --git a/packages/workbench/src/routes/catalog/$contract/new.tsx b/packages/workbench/src/routes/catalog/$contract/new.tsx
index ec93b02a..620cb9f1 100644
--- a/packages/workbench/src/routes/catalog/$contract/new.tsx
+++ b/packages/workbench/src/routes/catalog/$contract/new.tsx
@@ -16,8 +16,10 @@ import {
 } from '@/components/ui/dropdown-menu'
 import { Input } from '@/components/ui/input'
 import {
+  contractMetaOptions,
   contractSchemaOptions,
   contractsOptions,
+  useContractMeta,
   useContractSchema,
   useContracts,
   useCreateKey,
@@ -27,10 +29,11 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
 import { MenuIcon } from 'lucide-react'
 import { useEffect, useState } from 'react'
 import { toast } from 'sonner'
+import * as v from 'valibot'
 
-type NewKeySearch = {
-  key?: string
-}
+const newKeySchema = v.object({
+  key: v.optional(v.string()),
+})
 
 export const Route = createFileRoute('/catalog/$contract/new')({
   component: RouteComponent,
@@ -40,11 +43,12 @@ export const Route = createFileRoute('/catalog/$contract/new')({
       context.queryClient.ensureQueryData(
         contractSchemaOptions(context.procedures, params.contract),
       ),
+      context.queryClient.ensureQueryData(
+        contractMetaOptions(context.procedures, params.contract),
+      ),
     ])
   },
-  validateSearch: (search): NewKeySearch => ({
-    key: search.key ? String(search.key) : undefined,
-  }),
+  validateSearch: (search) => v.parse(newKeySchema, search),
 })
 
 function Breadcrumbs(props: {
@@ -101,11 +105,12 @@ function Breadcrumbs(props: {
 }
 
 function RouteComponent() {
-  const { contract: currentContract } = Route.useParams()
+  const { contract } = Route.useParams()
   const { key: defaultKeyName = '' } = Route.useSearch()
-  const { data: schema } = useContractSchema(currentContract)
+  const { data: schema } = useContractSchema(contract)
+  const { data: meta } = useContractMeta(contract)
   const [keyName, setKeyName] = useState(defaultKeyName)
-  const { mutateAsync: createKey } = useCreateKey(currentContract)
+  const { mutateAsync: createKey } = useCreateKey(contract)
 
   async function handleSubmit(content: unknown) {
     if (!keyName) {
@@ -137,10 +142,12 @@ function RouteComponent() {
       <Breadcrumbs keyName={keyName} onKeyNameChange={setKeyName} />
       <div className="p-4 flex-1 overflow-y-auto">
         <JsonSchemaForm
-          uniqueId={`${currentContract}-new`}
           content={null}
-          schema={schema}
+          contentRootKey="root"
+          meta={meta}
           onSubmit={handleSubmit}
+          schema={schema}
+          uniqueId={`${contract}-new`}
         />
       </div>
     </div>


Reviews:
user: login: electric-gizzard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T19:14:19Z
user: login: electric-gizzard
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T19:17:46Z
user: login: dylnslck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T19:56:20Z

Review comments:
diff_hunk: @@ -37,13 +40,15 @@ function renderFields(
             register,
             control,
             fieldName,
+            meta,
           )}
         </Fieldset>
       )
     }
 
     if (subSchema.type === 'string') {
-      // TODO: support rich-text editing
+      const rich = meta[fieldName]?.rich ?? false
path: packages/workbench/src/components/json-schema-form/dynamic-form.tsx
user: login: electric-gizzard
body: Consider just using the `Boolean(...)` function here since the optional chaining would result in `undefined`.
created_at: 2025-03-23T19:14:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,13 +40,15 @@ function renderFields(
             register,
             control,
             fieldName,
+            meta,
           )}
         </Fieldset>
       )
     }
 
     if (subSchema.type === 'string') {
-      // TODO: support rich-text editing
+      const rich = meta[fieldName]?.rich ?? false
path: packages/workbench/src/components/json-schema-form/dynamic-form.tsx
user: login: dylnslck
body: TypeScript infers the type of `rich` to be `boolean` not `boolean | undefined`. If `fieldName` is not in `meta` it'll result in `false`. If `fieldName` is in `meta` but `rich` is not, then it'll still result in `false.

But, `Boolean` is more readable, so I like the suggestion.
created_at: 2025-03-23T19:56:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-22T17:11:54Z
body: ###  🦋  Changeset detected

Latest commit: 5306fa5ad8aad73a24b5f74ffed206a5cb2146df

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 5 packages</summary>

  | Name                | Type  |
| ------------------- | ----- |
| @deepdish/workbench | Minor |
| @deepdish/cms       | Patch |
| @deepdish/nextjs    | Patch |
| @deepdish/demo      | Patch |
| @deepdish/marketing | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/ByteSliceHQ/deepdish/new/rich-text-editing?filename=.changeset/four-jokes-punch.md&value=---%0A%22%40deepdish%2Fworkbench%22%3A%20patch%0A---%0A%0Afeat(workbench)%3A%20add%20rich%20text%20editing%20support%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-03-22T17:11:55Z
body: [vc]: #9mBG2zRLWRQJuyxFiA73sCmJ0Kx+w3o9oFKw9qkXnHk=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJkZWVwZGlzaC1tYXJrZXRpbmciLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vYnl0ZS1zbGljZS02NTNhOTI1ZC9kZWVwZGlzaC1tYXJrZXRpbmcvOU1Xb2t6cGM0M29lOVJrNGV5VnVMenNNOTh5cyIsInByZXZpZXdVcmwiOiJkZWVwZGlzaC1tYXJrZXRpbmctZ2l0LXJpY2gtdGV4dC1lZGl0aW5nLWJ5dGUtc2xpY2UtNjUzYTkyNWQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJkZWVwZGlzaC1tYXJrZXRpbmctZ2l0LXJpY2gtdGV4dC1lZGl0aW5nLWJ5dGUtc2xpY2UtNjUzYTkyNWQudmVyY2VsLmFwcCJ9LCJyb290RGlyZWN0b3J5IjoiYXBwcy9tYXJrZXRpbmcifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **deepdish-marketing** | ✅ Ready ([Inspect](https://vercel.com/byte-slice-653a925d/deepdish-marketing/9MWokzpc43oe9Rk4eyVuLzsM98ys)) | [Visit Preview](https://deepdish-marketing-git-rich-text-editing-byte-slice-653a925d.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/deepdish-marketing-git-rich-text-editing-byte-slice-653a925d.vercel.app?via=pr-comment-feedback-link) | Mar 23, 2025 7:58pm |


author_association: NONE