Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/vendor-slate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@portabletext/editor': minor
---

feat: vendor Slate into the editor

Vendors `slate`, `slate-dom`, and `slate-react` into the package to remove external dependencies and enable direct modifications to the Slate layer. React Compiler exclusions added for the vendored code. No public API changes.
30 changes: 30 additions & 0 deletions apps/sdk-notes/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

# Turbo
.turbo

# ESLint
.eslintcache
4 changes: 4 additions & 0 deletions apps/sdk-notes/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"extends": ["../../biome.json"]
}
19 changes: 19 additions & 0 deletions apps/sdk-notes/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import reactHooks from 'eslint-plugin-react-hooks'
import {globalIgnores} from 'eslint/config'
import tseslint from 'typescript-eslint'

export default tseslint.config([
globalIgnores(['dist']),
reactHooks.configs.flat.recommended,
{
files: ['src/**/*.{cjs,mjs,js,jsx,ts,tsx}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {'react-hooks/exhaustive-deps': 'error'},
},
])
12 changes: 12 additions & 0 deletions apps/sdk-notes/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PTE SDK Notes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
40 changes: 40 additions & 0 deletions apps/sdk-notes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "sdk-notes",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"check:lint": "biome lint .",
"check:react-compiler": "eslint --cache .",
"check:types": "tsc --noEmit --pretty --project tsconfig.app.json",
"check:types:watch": "tsc --watch --project tsconfig.app.json",
"clean": "del .turbo && del dist && del node_modules",
"dev": "vite",
"lint:fix": "biome lint --write .",
"preview": "vite preview"
},
"dependencies": {
"@portabletext/editor": "workspace:*",
"@portabletext/patches": "workspace:*",
"@portabletext/plugin-sdk-value": "workspace:*",
"@portabletext/schema": "workspace:*",
"@sanity/sdk": "^2.7.0",
"@sanity/sdk-react": "^2.7.0",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"tailwindcss": "^4.1.16",
"typescript": "catalog:",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
20 changes: 20 additions & 0 deletions apps/sdk-notes/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {SanityApp} from '@sanity/sdk-react'
import {NotesApp} from './NotesApp.tsx'

const config = {projectId: 'q444gl2w', dataset: 'production'}

export function App() {
return (
<SanityApp config={config} fallback={<Loading />}>
<NotesApp />
</SanityApp>
)
}

function Loading() {
return (
<div className="flex h-screen items-center justify-center text-gray-500">
Connecting to Sanity…
</div>
)
}
127 changes: 127 additions & 0 deletions apps/sdk-notes/src/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
EditorProvider,
PortableTextEditable,
type RenderDecoratorFunction,
type RenderStyleFunction,
} from '@portabletext/editor'
import {SDKValuePlugin} from '@portabletext/plugin-sdk-value'
import {defineSchema} from '@portabletext/schema'
import {useDocument, useEditDocument} from '@sanity/sdk-react'
import {Suspense, useCallback, useMemo} from 'react'

const schemaDefinition = defineSchema({})

interface NoteEditorProps {
documentId: string
}

export function NoteEditor({documentId}: NoteEditorProps) {
return (
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center text-gray-400">
Loading editor…
</div>
}
>
<NoteEditorInner documentId={documentId} />
</Suspense>
)
}

function NoteEditorInner({documentId}: NoteEditorProps) {
const {data: title} = useDocument<string>({
documentId,
documentType: 'note',
path: 'title',
})

const setTitle = useEditDocument<string>({
documentId,
documentType: 'note',
path: 'title',
})

const keyGenerator = useMemo(() => {
let key = 0
return () => `k${key++}`
}, [])

const handleTitleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.target.value)
},
[setTitle],
)

const renderStyle: RenderStyleFunction = useCallback((props) => {
switch (props.value) {
case 'h1':
return <h1 className="text-3xl font-bold my-4">{props.children}</h1>
case 'h2':
return <h2 className="text-2xl font-bold my-3">{props.children}</h2>
case 'h3':
return <h3 className="text-xl font-bold my-2">{props.children}</h3>
case 'h4':
return <h4 className="text-lg font-bold my-2">{props.children}</h4>
case 'blockquote':
return (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-2">
{props.children}
</blockquote>
)
default:
return <p className="my-1 leading-relaxed">{props.children}</p>
}
}, [])

const renderDecorator: RenderDecoratorFunction = useCallback((props) => {
switch (props.value) {
case 'strong':
return <strong>{props.children}</strong>
case 'em':
return <em>{props.children}</em>
case 'underline':
return <u>{props.children}</u>
case 'code':
return (
<code className="bg-gray-100 px-1 rounded text-sm font-mono">
{props.children}
</code>
)
default:
return <span>{props.children}</span>
}
}, [])

return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
<input
type="text"
placeholder="Untitled"
value={title ?? ''}
onChange={handleTitleChange}
className="w-full text-2xl font-bold outline-none border-none bg-transparent mb-4 placeholder:text-gray-300"
/>
<EditorProvider
initialConfig={{
schemaDefinition,
keyGenerator,
}}
>
<SDKValuePlugin
documentId={documentId}
documentType="note"
path="body"
/>
<PortableTextEditable
className="outline-none min-h-[300px]"
renderStyle={renderStyle}
renderDecorator={renderDecorator}
/>
</EditorProvider>
</div>
</div>
)
}
132 changes: 132 additions & 0 deletions apps/sdk-notes/src/NoteList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type {DocumentHandle} from '@sanity/sdk'
import {
createDocument,
createDocumentHandle,
useApplyDocumentActions,
useDocumentPreview,
useDocuments,
} from '@sanity/sdk-react'
import {Suspense} from 'react'

interface NoteListProps {
selectedNoteId: string | null
onSelectNote: (id: string) => void
}

export function NoteList(props: NoteListProps) {
return (
<Suspense
fallback={<div className="p-4 text-sm text-gray-400">Loading notes…</div>}
>
<NoteListInner {...props} />
</Suspense>
)
}

function NoteListInner({selectedNoteId, onSelectNote}: NoteListProps) {
const {data: notes, isPending} = useDocuments({
documentType: 'note',
orderings: [{field: '_updatedAt', direction: 'desc'}],
})
const apply = useApplyDocumentActions()

async function handleCreateNote() {
const handle = createDocumentHandle({
documentId: crypto.randomUUID(),
documentType: 'note',
})
await apply(createDocument(handle))
onSelectNote(handle.documentId)
}

return (
<>
<div className="p-3 border-b border-gray-200">
<button
type="button"
onClick={handleCreateNote}
className="w-full rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 transition-colors"
>
New note
</button>
</div>
{isPending && notes.length === 0 ? (
<div className="p-4 text-sm text-gray-400">Loading…</div>
) : notes.length === 0 ? (
<div className="p-4 text-sm text-gray-400">No notes yet</div>
) : (
<ul className="flex-1 overflow-y-auto">
{notes.map((note) => (
<li key={note.documentId}>
<Suspense
fallback={
<NoteListItemFallback
documentId={note.documentId}
isSelected={selectedNoteId === note.documentId}
onSelect={() => onSelectNote(note.documentId)}
/>
}
>
<NoteListItem
handle={note}
isSelected={selectedNoteId === note.documentId}
onSelect={() => onSelectNote(note.documentId)}
/>
</Suspense>
</li>
))}
</ul>
)}
</>
)
}

function NoteListItem({
handle,
isSelected,
onSelect,
}: {
handle: DocumentHandle
isSelected: boolean
onSelect: () => void
}) {
const {data: preview} = useDocumentPreview(handle)

return (
<button
type="button"
onClick={onSelect}
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-100 transition-colors ${
isSelected ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''
}`}
>
<div className="font-medium text-sm truncate">
{preview.title || 'Untitled'}
</div>
</button>
)
}

function NoteListItemFallback({
documentId,
isSelected,
onSelect,
}: {
documentId: string
isSelected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-100 transition-colors ${
isSelected ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''
}`}
>
<div className="font-medium text-sm truncate text-gray-400">
{documentId.slice(0, 8)}…
</div>
</button>
)
}
Loading