Skip to content
Merged
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
25 changes: 22 additions & 3 deletions website/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import type { ExitBoundaryHandler } from '@meowdown/core'
import type { TagItem, WikilinkItem } from '@meowdown/react'
import type { EditorHandle, TagItem, WikilinkItem } from '@meowdown/react'
import { getId } from '@ocavue/utils'
import { clsx } from 'clsx/lite'
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from 'react'
import {
type CSSProperties,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'

import { DemoEditor } from './components/demo-editor.tsx'
import { SelectionMenuShortcut, useSelectionDemo } from './selection-demo.tsx'
import { uploadFile } from './upload-file.ts'
import { MODES, useEditorMode } from './use-editor-mode.ts'

Expand Down Expand Up @@ -45,6 +53,8 @@ Label your notes with tags like #meow and #markdown. Type \`#\` followed by a le

Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note.

Select some text and click the sparkle button (or press \`Mod-Shift-J\`) to run a command on it. The result streams into a preview, and nothing changes until you accept it.

Track things two ways. Type \`+ \` for a circle checkbox task, or \`[] \` for a square checkbox task:

+ [ ] Ship the circle task
Expand Down Expand Up @@ -205,6 +215,9 @@ function Brand() {
export function App() {
const { mode, setMode, activeMode } = useEditorMode()

const editorRef = useRef<EditorHandle>(null)
const selectionDemo = useSelectionDemo(editorRef)

const [spellCheck, setSpellCheck] = useState<boolean | undefined>(undefined)

useEffect(() => {
Expand Down Expand Up @@ -289,15 +302,21 @@ export function App() {
mode={mode}
spellCheck={spellCheck}
initialMarkdown={INITIAL_CONTENT}
handleRef={editorRef}
onTagSearch={searchTags}
onWikilinkSearch={searchNotes}
onSelectionMenuSearch={selectionDemo.onSelectionMenuSearch}
pendingReplacementActions={selectionDemo.pendingReplacementActions}
onPendingReplacementResolve={selectionDemo.onPendingReplacementResolve}
onFilePaste={uploadFile}
onImageClick={handleImageClick}
onLinkClick={handleLinkClick}
onTagClick={handleTagClick}
onWikilinkClick={handleWikilinkClick}
onExitBoundary={handleExitBoundary}
/>
>
<SelectionMenuShortcut onTrigger={selectionDemo.openMenu} />
</DemoEditor>
</div>

{edgeFlash && (
Expand Down
12 changes: 10 additions & 2 deletions website/src/components/demo-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type EditorMode,
type EditorProps,
} from '@meowdown/react'
import { useRef } from 'react'
import { useRef, type RefObject } from 'react'

import { CodeMirrorEditor } from './codemirror-editor.tsx'

Expand All @@ -18,6 +18,12 @@ export interface DemoEditorProps extends Omit<EditorProps, 'mode' | 'handleRef'>
* 'focus'.
*/
mode?: DemoMode

/**
* Handle of whichever editor is currently mounted. In source mode the
* selection and pending-replacement methods are no-ops.
*/
handleRef?: RefObject<EditorHandle | null>
}

export function DemoEditor({
Expand All @@ -26,12 +32,14 @@ export function DemoEditor({
onDocChange,
readOnly,
children,
handleRef,
...richProps
}: DemoEditorProps) {
// Handle of whichever editor is currently mounted. Reading it during render
// seeds the next editor with the previous one's content when the mode flips
// between the rich and source families.
const childRef = useRef<EditorHandle>(null)
const fallbackRef = useRef<EditorHandle>(null)
const childRef = handleRef ?? fallbackRef
const seedMarkdown = childRef.current?.getMarkdown() ?? initialMarkdown ?? ''

if (mode === 'source') {
Expand Down
224 changes: 224 additions & 0 deletions website/src/selection-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { getPendingReplacement, type PendingReplacementMode } from '@meowdown/core'
import {
useEditor,
useKeymap,
type EditorHandle,
type PendingReplacementResolveHandler,
type SelectionMenuContext,
type SelectionMenuSearchHandler,
} from '@meowdown/react'
import { useCallback, useMemo, useRef, type ReactNode, type RefObject } from 'react'

/**
* A selection-menu command for the demo: the result is computed locally from
* the selected text and streamed into the pending-replacement preview in small
* chunks, standing in for an AI provider.
*/
interface DemoCommand {
id: string
label: string
detail: string
mode: PendingReplacementMode
transform: (selectedText: string) => string
}

/** Leading Markdown block markers: heading, list, blockquote, task box. */
const LEADING_BLOCK_MARKERS = /^\s*(?:(?:[-*+]|\d+[.)]|#{1,6}|>)\s+)*(?:\[[ xX]\]\s+)?/

const DEMO_COMMANDS: DemoCommand[] = [
{
id: 'meowify',
label: 'Meowify',
detail: 'Every word becomes meow',
mode: 'replace',
transform: (selectedText) =>
selectedText.replaceAll(/\p{L}+/gu, (word) => (/^\p{Lu}/u.test(word) ? 'Meow' : 'meow')),
},
{
id: 'uppercase',
label: 'Uppercase',
detail: 'Shout the selection',
mode: 'replace',
transform: (selectedText) => selectedText.toUpperCase(),
},
{
id: 'bullets',
label: 'Turn into bullet list',
detail: 'One bullet per line',
mode: 'replace',
transform: (selectedText) =>
selectedText
.split('\n')
.map((line) => line.replace(LEADING_BLOCK_MARKERS, '').trim())
.filter((line) => line.length > 0)
.map((line) => `- ${line}`)
.join('\n'),
},
{
id: 'summarize',
label: 'Write a short summary',
detail: 'Appends below the selection',
mode: 'append',
transform: (selectedText) => {
const wordCount = selectedText.split(/\s+/).filter(Boolean).length
return `**Summary:** the selection has ${wordCount} ${wordCount === 1 ? 'word' : 'words'} and, in essence, says: meow.`
},
},
]

const SEARCH_LATENCY_MS = 150
const STREAM_CHUNK_SIZE = 4
const STREAM_INTERVAL_MS = 30

/** One in-flight (or previewed) demo run, kept for retry. */
interface DemoRun {
command: DemoCommand
context: SelectionMenuContext
cancelled: boolean
}

const ACTION_BUTTON_CLASS =
'inline-flex cursor-pointer items-center rounded-lg px-2.5 py-1 text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'

function DemoReplacementActions({
onRetry,
onInsertBelow,
}: {
onRetry: () => void
onInsertBelow: () => void
}) {
return (
<>
<button type="button" className={ACTION_BUTTON_CLASS} onClick={onRetry}>
Retry
</button>
<button type="button" className={ACTION_BUTTON_CLASS} onClick={onInsertBelow}>
Insert below
</button>
</>
)
}

export interface SelectionDemoValue {
onSelectionMenuSearch: SelectionMenuSearchHandler
pendingReplacementActions: ReactNode
onPendingReplacementResolve: PendingReplacementResolveHandler
/** Opens the selection menu; bound to `Mod-Shift-j` by `SelectionMenuShortcut`. */
openMenu: () => void
}

/**
* Wires the selection menu and the pending-replacement preview to a set of
* locally computed demo commands, mirroring how a real host (prompt list,
* streaming provider call, retry control) would use the editor API.
*/
export function useSelectionDemo(handleRef: RefObject<EditorHandle | null>): SelectionDemoValue {
const runRef = useRef<DemoRun | undefined>(undefined)

const beginStream = useCallback(
(command: DemoCommand, context: SelectionMenuContext) => {
if (runRef.current) runRef.current.cancelled = true
const run: DemoRun = { command, context, cancelled: false }
runRef.current = run

const result = command.transform(context.selectedText)
let offset = 0
const timer = setInterval(() => {
if (run.cancelled || offset >= result.length) {
clearInterval(timer)
return
}
handleRef.current?.appendPendingReplacementText(
result.slice(offset, offset + STREAM_CHUNK_SIZE),
)
offset += STREAM_CHUNK_SIZE
}, STREAM_INTERVAL_MS)
},
[handleRef],
)

const runCommand = useCallback(
(command: DemoCommand, context: SelectionMenuContext) => {
const handle = handleRef.current
if (!handle) return
const { from, to } = context
if (!handle.startPendingReplacement({ from, to, mode: command.mode })) return
beginStream(command, context)
},
[handleRef, beginStream],
)

const onSelectionMenuSearch = useCallback<SelectionMenuSearchHandler>(
async (query) => {
// Simulate network latency so the menu's loading state shows up.
await new Promise((resolve) => setTimeout(resolve, SEARCH_LATENCY_MS))
const needle = query.trim().toLowerCase()
const commands = needle
? DEMO_COMMANDS.filter((command) => command.label.toLowerCase().includes(needle))
: DEMO_COMMANDS
return commands.map((command) => ({
id: command.id,
label: command.label,
detail: command.detail,
onSelect: (context: SelectionMenuContext) => runCommand(command, context),
}))
},
[runCommand],
)

const retry = useCallback(() => {
const run = runRef.current
const handle = handleRef.current
if (!run || !handle) return
// Restage over the current staged range: it tracks edits made while the
// text streamed, so the offsets captured at menu-open time may be stale.
const staged = handle.editor ? getPendingReplacement(handle.editor.state) : null
const range = staged ?? run.context
if (
!handle.startPendingReplacement({ from: range.from, to: range.to, mode: run.command.mode })
) {
return
}
beginStream(run.command, run.context)
}, [handleRef, beginStream])

const insertBelow = useCallback(() => {
handleRef.current?.acceptPendingReplacement({ mode: 'append' })
}, [handleRef])

const pendingReplacementActions = useMemo<ReactNode>(
() => <DemoReplacementActions onRetry={retry} onInsertBelow={insertBelow} />,
[retry, insertBelow],
)

const onPendingReplacementResolve = useCallback<PendingReplacementResolveHandler>(() => {
if (runRef.current) runRef.current.cancelled = true
runRef.current = undefined
}, [])

const openMenu = useCallback(() => {
handleRef.current?.openSelectionMenu()
}, [handleRef])

return { onSelectionMenuSearch, pendingReplacementActions, onPendingReplacementResolve, openMenu }
}

/**
* Binds `Mod-Shift-j` inside the editor to open the selection menu. An empty
* selection lets the key fall through.
*/
export function SelectionMenuShortcut({ onTrigger }: { onTrigger: () => void }) {
const editor = useEditor()
const keymap = useMemo(
() => ({
'Mod-Shift-j': () => {
if (editor.state.selection.empty) return false
onTrigger()
return true
},
}),
[editor, onTrigger],
)
useKeymap(keymap)
return null
}
Loading