Extract speech lifecycle and editor animation modules (#16767)

* Extract speech worker lifecycle helpers

* Split editor feature-wall animation
This commit is contained in:
Neil
2026-08-27 16:49:35 -07:00
committed by GitHub
parent 9a0a2b1c31
commit f27ea956f2
8 changed files with 725 additions and 661 deletions
-1
View File
@@ -51,7 +51,6 @@ inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
+35 -112
View File
@@ -1,13 +1,12 @@
/* eslint-disable max-lines -- Why: speech worker ownership, warm reuse, and
timeout teardown must stay co-located so dictation lifecycle state cannot drift. */
timeout teardown stay co-located so dictation lifecycle state cannot drift. */
import { Worker } from 'node:worker_threads'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { getCatalogModel } from './model-catalog'
import type { ModelManager } from './model-manager'
import { OpenAiTranscriptionSession } from './openai-transcription-client'
import { readOpenAiSpeechApiKey } from './openai-api-key-store'
import { getSherpaModulePath, getSttWorkerPath } from './stt-worker-paths'
import { waitForSttWorkerStop, type SttWorkerStopOutcome } from './stt-worker-stop'
export const START_DICTATION_TIMEOUT_MS = 60_000
const STOP_DICTATION_TIMEOUT_MS = 60_000
@@ -28,8 +27,6 @@ type StopInFlight = {
promise: Promise<void>
}
type StopOutcome = 'stopped' | 'error' | 'exit' | 'timeout'
export class SttService {
private worker: Worker | null = null
private cloudSession: OpenAiTranscriptionSession | null = null
@@ -164,8 +161,8 @@ export class SttService {
throw new Error(`Model not ready: ${modelState.status}`)
}
const workerPath = this.getWorkerPath()
const sherpaModulePath = this.getSherpaModulePath()
const workerPath = getSttWorkerPath()
const sherpaModulePath = getSherpaModulePath()
this.worker = new Worker(workerPath, {
workerData: { sherpaModulePath }
@@ -384,83 +381,39 @@ export class SttService {
}
private createStopPromise(worker: Worker, capturedSink: SttEventSink | null): Promise<void> {
return new Promise<void>((resolve) => {
let settled = false
let receivedStopped = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
worker.off('message', onStopped)
worker.off('error', onError)
worker.off('exit', onExit)
}
const finish = (outcome: StopOutcome): void => {
if (settled) {
return
}
settled = true
cleanup()
if (outcome === 'stopped') {
if (this.worker === worker) {
this.activeOwner = null
this.eventSink = null
this.scheduleIdleTeardown()
}
resolve()
return
}
if (!receivedStopped) {
capturedSink?.({ type: 'stopped' })
}
// Why: a worker that cannot finish dictation is no longer reusable; drop
// its lifecycle listeners so a stale worker can't retain this service.
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
if (outcome !== 'exit') {
void worker.terminate().catch(() => undefined)
}
if (this.worker === worker) {
this.worker = null
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
}
resolve()
}
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
if (msg.type === 'stopped') {
receivedStopped = true
finish('stopped')
}
}
const onError = (): void => {
finish('error')
}
const onExit = (): void => {
finish('exit')
}
timeout = setTimeout(() => {
finish('timeout')
}, STOP_DICTATION_TIMEOUT_MS)
timeout.unref?.()
worker.on('message', onStopped)
worker.on('error', onError)
worker.on('exit', onExit)
return waitForSttWorkerStop({
worker,
capturedSink,
timeoutMs: STOP_DICTATION_TIMEOUT_MS,
finish: (outcome) => this.finishWorkerStop(worker, outcome)
})
}
private finishWorkerStop(worker: Worker, outcome: SttWorkerStopOutcome): void {
if (outcome === 'stopped') {
if (this.worker === worker) {
this.activeOwner = null
this.eventSink = null
this.scheduleIdleTeardown()
}
return
}
// Why: a worker that cannot finish dictation is no longer reusable; drop
// its lifecycle listeners so a stale worker can't retain this service.
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
if (outcome !== 'exit') {
void worker.terminate().catch(() => undefined)
}
if (this.worker === worker) {
this.worker = null
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
}
}
isActive(): boolean {
return this.worker !== null || this.cloudSession !== null
}
@@ -481,13 +434,6 @@ export class SttService {
}
}
private getWorkerPath(): string {
if (getAppEnvironment().isPackaged()) {
return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js')
}
return join(__dirname, 'stt-worker.js')
}
private clearIdleTeardownTimer(): void {
if (this.idleTeardownTimer) {
clearTimeout(this.idleTeardownTimer)
@@ -552,27 +498,4 @@ export class SttService {
this.cleanupWorkerLifecycleListeners = null
cleanup?.()
}
private getSherpaModulePath(): string {
// Why: the main sherpa-onnx npm package uses WASM, which cannot access
// the host filesystem to load model files. The platform-specific native
// addon (e.g. sherpa-onnx-darwin-arm64) has direct filesystem access
// and better performance. We resolve its absolute path here because
// the worker runs from out/main/ where bare require() can't find it.
const nativePkg =
process.platform === 'win32' && process.arch === 'x64'
? 'sherpa-onnx-win-x64'
: `sherpa-onnx-${process.platform}-${process.arch}`
if (getAppEnvironment().isPackaged()) {
const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePkg)
if (existsSync(resourcesNodeModule)) {
return resourcesNodeModule
}
return join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', nativePkg)
}
const resolved = require.resolve(nativePkg)
return join(resolved, '..')
}
}
+28
View File
@@ -0,0 +1,28 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
export function getSttWorkerPath(): string {
if (getAppEnvironment().isPackaged()) {
return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js')
}
return join(__dirname, 'stt-worker.js')
}
export function getSherpaModulePath(): string {
const nativePackage =
process.platform === 'win32' && process.arch === 'x64'
? 'sherpa-onnx-win-x64'
: `sherpa-onnx-${process.platform}-${process.arch}`
if (getAppEnvironment().isPackaged()) {
const resourcesNodeModule = join(process.resourcesPath, 'node_modules', nativePackage)
if (existsSync(resourcesNodeModule)) {
return resourcesNodeModule
}
return join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', nativePackage)
}
const resolved = require.resolve(nativePackage)
return join(resolved, '..')
}
+53
View File
@@ -0,0 +1,53 @@
import type { Worker } from 'node:worker_threads'
import type { SttEventSink } from './stt-service'
export type SttWorkerStopOutcome = 'stopped' | 'error' | 'exit' | 'timeout'
export function waitForSttWorkerStop(args: {
worker: Worker
capturedSink: SttEventSink | null
timeoutMs: number
finish: (outcome: SttWorkerStopOutcome) => void
}): Promise<void> {
return new Promise<void>((resolve) => {
let settled = false
let receivedStopped = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
args.worker.off('message', onStopped)
args.worker.off('error', onError)
args.worker.off('exit', onExit)
}
const finish = (outcome: SttWorkerStopOutcome): void => {
if (settled) {
return
}
settled = true
cleanup()
if (outcome !== 'stopped' && !receivedStopped) {
args.capturedSink?.({ type: 'stopped' })
}
args.finish(outcome)
resolve()
}
const onStopped = (message: { type: string }): void => {
if (message.type === 'stopped') {
receivedStopped = true
finish('stopped')
}
}
const onError = (): void => finish('error')
const onExit = (): void => finish('exit')
timeout = setTimeout(() => finish('timeout'), args.timeoutMs)
timeout.unref?.()
args.worker.on('message', onStopped)
args.worker.on('error', onError)
args.worker.on('exit', onExit)
})
}
@@ -1,25 +1,16 @@
/* eslint-disable max-lines -- Why: this animation is a self-contained storyboard; splitting the slash-menu DOM, toolbar SVGs, and timing constants into separate modules would obscure the sequence rather than clarify it. */
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import type { JSX, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { getShortcutPlatform } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { TB_ICON, ToolbarBtn, ToolbarSep } from './editor-animated-toolbar-icons'
import { activeLineClass, caretClass } from './editor-animated-visual-markup'
import { useEditorAnimatedVisualAnimation } from './use-editor-animated-visual-animation'
// Why: the visual leans on direct DOM mutation (typing into a node, swapping
// classes, anchoring a floating menu by measured rect) so the loop reads
// like the HTML mock instead of fighting React's reconciliation.
const PRE_HOVER_MS = 450
const TYPE_PER_CHAR_MS = 60
const POST_TYPE_MS = 120
const MENU_HOLD_MS = 900
const CLICK_RIPPLE_MS = 220
const POST_CLICK_MS = 140
const POST_H1_REVEAL_MS = 260
const POST_H1_TYPE_MS = 700
const NEW_LINE_HOLD_MS = 380
const FINAL_HOLD_MS = 2200
const KBD_CLASS_DOC =
'rounded border border-border bg-card px-1.5 py-0.5 font-mono text-[10.5px] text-muted-foreground'
@@ -44,208 +35,6 @@ function CursorIcon(): JSX.Element {
)
}
// Toolbar icons mirror RichMarkdownToolbar.tsx — same families so the
// surface reads as Orca's actual editor, not a generic editor.
const TB_ICON: Record<string, JSX.Element> = {
pilcrow: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 3H6.5a3 3 0 0 0 0 6H8" />
<path d="M9 3v11" />
<path d="M12 3v11" />
</svg>
),
h1: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4v8" />
<path d="M9 4v8" />
<path d="M3 8h6" />
<path d="M12 6l1-1v7" />
</svg>
),
h2: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4v8" />
<path d="M9 4v8" />
<path d="M3 8h6" />
<path d="M11 6.2A1.5 1.5 0 0 1 14 6.5c0 1.4-3 2-3 5.5h3" />
</svg>
),
h3: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4v8" />
<path d="M9 4v8" />
<path d="M3 8h6" />
<path d="M11 6.2A1.5 1.5 0 0 1 14 6.5c0 1.5-3 1.5-3 1.5s3 0 3 2c0 1.4-2.5 1.7-3 1" />
</svg>
),
bold: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 3h4a2.5 2.5 0 0 1 0 5H5z" />
<path d="M5 8h4.5a2.5 2.5 0 0 1 0 5H5z" />
</svg>
),
italic: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 3 6 13" />
<path d="M5 3h5" />
<path d="M6 13h5" />
</svg>
),
strike: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 8h10" />
<path d="M11 5a3 3 0 0 0-3-2H7a2.5 2.5 0 0 0-2.5 2.5C4.5 7 6 8 8 8" />
<path d="M5.5 11A2.5 2.5 0 0 0 8 13h1a3 3 0 0 0 3-2.5" />
</svg>
),
list: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx={3.5} cy={4} r={0.7} fill="currentColor" />
<circle cx={3.5} cy={8} r={0.7} fill="currentColor" />
<circle cx={3.5} cy={12} r={0.7} fill="currentColor" />
<path d="M7 4h6" />
<path d="M7 8h6" />
<path d="M7 12h6" />
</svg>
),
olist: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.5 3h1v2.5" />
<path d="M2 8h2c0.5 0 0.5 1 0 1l-1.5 2H4" />
<path d="M7 4h6" />
<path d="M7 8h6" />
<path d="M7 12h6" />
</svg>
),
check: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x={2.5} y={2.5} width={11} height={11} rx={2} />
<path d="m5.5 8 2 2 3-4" />
</svg>
),
quote: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 4H3v3.5L5 9V6h2V4z" />
<path d="M11 4h-2v3.5l2 1.5V6h2V4z" />
</svg>
),
code: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 5-3 3 3 3" />
<path d="m10 5 3 3-3 3" />
</svg>
),
copy: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x={5} y={5} width={8} height={8} rx={1.4} />
<path d="M3 11V4a1 1 0 0 1 1-1h7" />
</svg>
)
}
function ToolbarBtn(props: { iconKey: keyof typeof TB_ICON }): JSX.Element {
return (
<span className="inline-flex size-[22px] items-center justify-center rounded text-muted-foreground">
<span className="size-[13px] [&>svg]:size-full">{TB_ICON[props.iconKey]}</span>
</span>
)
}
function ToolbarSep(): JSX.Element {
return <span className="mx-1 h-3.5 w-px bg-foreground/10" />
}
// Slash menu row — visible always; the active row gets the highlight
// background, mirroring RichMarkdownSlashMenu.tsx.
function SlashRow(props: {
@@ -287,313 +76,16 @@ export function EditorAnimatedVisual(props: { reducedMotion: boolean }): JSX.Ele
const menuRef = useRef<HTMLDivElement | null>(null)
const rowH1Ref = useRef<HTMLDivElement | null>(null)
const rowCodeRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (reducedMotion) {
return
}
const docMaybe = docRef.current
const activeLineInitial = activeLineRef.current
const cursorMaybe = cursorRef.current
const menuMaybe = menuRef.current
const afterMaybe = afterRef.current
if (!docMaybe || !activeLineInitial || !cursorMaybe || !menuMaybe || !afterMaybe) {
return
}
// Re-bind to non-null locals so the helper closures spanning `await`
// points keep their narrowed types — TS flow analysis drops the narrow
// through async boundaries otherwise.
const doc: HTMLDivElement = docMaybe
const cursor: HTMLDivElement = cursorMaybe
const menu: HTMLDivElement = menuMaybe
const after: HTMLDivElement = afterMaybe
let cancelled = false
const timers: number[] = []
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
const id = window.setTimeout(() => resolve(), ms)
timers.push(id)
})
// Stash initial DOM so we can restore between loops.
const initialActiveLineHTML = activeLineInitial.outerHTML
const initialActiveLineParent = activeLineInitial.parentNode
const initialActiveLineNextSibling = activeLineInitial.nextSibling
let activeLine: HTMLDivElement = activeLineInitial
let activeText: HTMLSpanElement | null = activeTextRef.current
let activeCaret: HTMLSpanElement | null =
activeLineInitial.querySelector<HTMLSpanElement>('[data-md-caret]')
function setSlashMode(mode: 'all' | 'code'): void {
menu.querySelectorAll<HTMLElement>('[data-slash-show]').forEach((el) => {
const allowed = (el.getAttribute('data-slash-show') ?? '').split(',')
el.style.display = allowed.includes(mode) ? '' : 'none'
})
}
function placeMenuNearLine(line: HTMLElement): void {
const docRect = doc.getBoundingClientRect()
const lineRect = line.getBoundingClientRect()
// Why: nudge the menu right so it doesn't cover the "/" the user just
// typed — keeps the typed character visible alongside the menu.
const x = lineRect.left - docRect.left + 16
menu.style.left = `${x}px`
menu.style.top = '0px'
const wasShown = menu.dataset.shown === '1'
if (!wasShown) {
menu.style.visibility = 'hidden'
menu.dataset.shown = '1'
menu.style.opacity = '1'
menu.style.transform = 'none'
}
const menuH = menu.getBoundingClientRect().height
if (!wasShown) {
menu.dataset.shown = ''
menu.style.opacity = ''
menu.style.transform = ''
menu.style.visibility = ''
}
const belowY = lineRect.bottom - docRect.top + 6
const aboveY = lineRect.top - docRect.top - menuH - 6
const docH = docRect.height
const fitsBelow = belowY + menuH <= docH - 4
menu.style.top = `${fitsBelow ? belowY : Math.max(4, aboveY)}px`
}
function moveCursorTo(targetEl: HTMLElement, offsetX = 0, offsetY = 0): void {
const docRect = doc.getBoundingClientRect()
const tRect = targetEl.getBoundingClientRect()
const x = tRect.left - docRect.left + offsetX
const y = tRect.top - docRect.top + offsetY
cursor.style.transform = `translate(${x}px, ${y}px)`
}
function showMenu(): void {
menu.dataset.shown = '1'
menu.style.opacity = '1'
menu.style.transform = 'translateY(0) scale(1)'
}
function hideMenu(): void {
menu.dataset.shown = ''
menu.style.opacity = '0'
menu.style.transform = 'translateY(-4px) scale(0.985)'
}
function clearActiveRow(): void {
menu
.querySelectorAll<HTMLElement>('[data-slash-row]')
.forEach((el) => el.classList.remove('slash-active'))
}
async function typeInto(
el: HTMLElement,
text: string,
perChar = TYPE_PER_CHAR_MS
): Promise<void> {
for (const ch of text) {
if (cancelled) {
return
}
el.textContent = (el.textContent ?? '') + ch
await wait(perChar)
}
}
function clearAfter(): void {
after.innerHTML = ''
}
function restoreInitialActiveLine(): void {
// Pull whatever the active line currently is back into the original
// shape so the next loop starts from the same DOM as render.
activeLine.remove()
const wrapper = document.createElement('div')
wrapper.innerHTML = initialActiveLineHTML
const fresh = wrapper.firstElementChild as HTMLDivElement | null
if (!fresh) {
return
}
if (initialActiveLineParent) {
if (
initialActiveLineNextSibling &&
initialActiveLineNextSibling.parentNode === initialActiveLineParent
) {
initialActiveLineParent.insertBefore(fresh, initialActiveLineNextSibling)
} else {
initialActiveLineParent.appendChild(fresh)
}
}
activeLine = fresh
activeText = fresh.querySelector<HTMLSpanElement>('[data-md-active-text]')
activeCaret = fresh.querySelector<HTMLSpanElement>('[data-md-caret]')
}
async function loop(): Promise<void> {
while (!cancelled) {
// Reset state.
clearAfter()
hideMenu()
clearActiveRow()
cursor.style.transition = 'none'
cursor.style.opacity = '0'
cursor.style.transform = 'translate(-30px, 80px)'
// Force reflow so the next transition takes effect.
void cursor.offsetWidth
cursor.style.transition = ''
await wait(PRE_HOVER_MS)
if (cancelled) {
return
}
// 1. Type "/" on the fresh active line.
if (activeText) {
activeText.textContent = ''
}
await typeInto(activeText ?? activeLine, '/')
if (cancelled) {
return
}
await wait(POST_TYPE_MS)
if (cancelled) {
return
}
// 2. Slash menu opens, anchored near the line.
setSlashMode('all')
placeMenuNearLine(activeLine)
showMenu()
cursor.style.opacity = '1'
const rowH1 = rowH1Ref.current
if (rowH1) {
moveCursorTo(rowH1, 14, 11)
rowH1.classList.add('slash-active')
}
await wait(MENU_HOLD_MS)
if (cancelled) {
return
}
// 3. Click — line becomes an H1.
cursor.dataset.clicking = '1'
await wait(CLICK_RIPPLE_MS)
if (cancelled) {
return
}
cursor.dataset.clicking = ''
hideMenu()
cursor.style.opacity = '0'
await wait(POST_CLICK_MS)
if (cancelled) {
return
}
// Convert the active line to an H1: clear the slash glyph, drop the
// monospace styling, type the heading.
activeLine.dataset.role = 'h1'
if (activeText) {
activeText.textContent = ''
}
if (activeCaret) {
activeCaret.style.display = ''
}
await wait(POST_H1_REVEAL_MS)
if (cancelled) {
return
}
await typeInto(activeText ?? activeLine, 'Ship checklist', 55)
if (cancelled) {
return
}
await wait(POST_H1_TYPE_MS)
if (cancelled) {
return
}
// 4. New active line below the H1 — user types "/code".
const newActive = document.createElement('div')
newActive.dataset.role = 'active'
newActive.className = activeLineClass()
const newText = document.createElement('span')
newText.dataset.mdActiveText = '1'
const newCaret = document.createElement('span')
newCaret.dataset.mdCaret = '1'
newCaret.className = caretClass()
newActive.appendChild(newText)
newActive.appendChild(newCaret)
after.appendChild(newActive)
const lineForBeat2 = newActive
await wait(NEW_LINE_HOLD_MS)
if (cancelled) {
return
}
for (const ch of '/code') {
if (cancelled) {
return
}
newText.textContent = (newText.textContent ?? '') + ch
await wait(TYPE_PER_CHAR_MS)
}
await wait(POST_TYPE_MS)
if (cancelled) {
return
}
// Filter to the Code Block row, anchor menu, highlight.
clearActiveRow()
if (rowH1) {
rowH1.classList.remove('slash-active')
}
setSlashMode('code')
placeMenuNearLine(lineForBeat2)
showMenu()
cursor.style.opacity = '1'
const rowCode = rowCodeRef.current
if (rowCode) {
moveCursorTo(rowCode, 14, 11)
rowCode.classList.add('slash-active')
}
await wait(MENU_HOLD_MS)
if (cancelled) {
return
}
// 5. Click — line becomes a code block.
cursor.dataset.clicking = '1'
await wait(CLICK_RIPPLE_MS)
if (cancelled) {
return
}
cursor.dataset.clicking = ''
hideMenu()
cursor.style.opacity = '0'
await wait(POST_CLICK_MS)
if (cancelled) {
return
}
const codeBlock = document.createElement('div')
codeBlock.className = 'mt-1.5 animate-[md-block-in_380ms_cubic-bezier(.2,.8,.2,1)_both]'
codeBlock.innerHTML = codeBlockHTML()
lineForBeat2.replaceWith(codeBlock)
await wait(FINAL_HOLD_MS)
if (cancelled) {
return
}
// Restore the initial DOM and loop.
restoreInitialActiveLine()
}
}
void loop()
return () => {
cancelled = true
timers.forEach((id) => window.clearTimeout(id))
}
}, [reducedMotion])
useEditorAnimatedVisualAnimation(reducedMotion, {
docRef,
activeLineRef,
activeTextRef,
afterRef,
cursorRef,
menuRef,
rowH1Ref,
rowCodeRef
})
return (
<div className="relative overflow-visible rounded-xl border border-border bg-card text-foreground shadow-[0_1px_2px_rgba(24,24,27,0.04)]">
@@ -829,29 +321,3 @@ function ActiveLine(props: {
</div>
)
}
// Helpers shared between initial render and re-created beat-2 lines so the
// styling stays in lockstep regardless of which path mounts the node.
function activeLineClass(): string {
return 'relative mt-1.5 min-h-[18px] py-px'
}
function caretClass(): string {
return 'inline-block'
}
function codeBlockHTML(): string {
return `
<div style="background: rgba(24,24,27,0.04); border: 1px solid rgba(24,24,27,0.10); border-radius: 8px; overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.55;">
<div style="display: flex; align-items: center; justify-content: space-between; padding: 5px 9px; border-bottom: 1px solid rgba(24,24,27,0.10); background: rgba(24,24,27,0.04);">
<span style="font-size: 10px; font-weight: 600; color: rgb(113 113 122); letter-spacing: 0.02em;">typescript</span>
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; color: rgb(113 113 122);">
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="8" height="8" rx="1.4"/><path d="M3 11V4a1 1 0 0 1 1-1h7"/></svg>
<span>Copy</span>
</span>
</div>
<div style="padding: 8px 11px; background: #fff; display: flex; flex-direction: column; gap: 2px;">
<div><span style="color:#a855f7;">await</span> <span style="color:#2563eb;">runSmokeTests</span><span style="color:rgb(113 113 122);">({</span> env<span style="color:rgb(113 113 122);">:</span> <span style="color:#16a34a;">'staging'</span> <span style="color:rgb(113 113 122);">})</span></div>
<div><span style="color:#a855f7;">await</span> <span style="color:#2563eb;">publish</span><span style="color:rgb(113 113 122);">({</span> tag<span style="color:rgb(113 113 122);">:</span> <span style="color:#16a34a;">'v0.4.0'</span> <span style="color:rgb(113 113 122);">})</span></div>
</div>
</div>`
}
@@ -0,0 +1,203 @@
import type { JSX } from 'react'
// Toolbar icons mirror RichMarkdownToolbar.tsx — same families so the
// surface reads as Orca's actual editor, not a generic editor.
export const TB_ICON: Record<string, JSX.Element> = {
pilcrow: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 3H6.5a3 3 0 0 0 0 6H8" />
<path d="M9 3v11" />
<path d="M12 3v11" />
</svg>
),
h1: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4v8" />
<path d="M9 4v8" />
<path d="M3 8h6" />
<path d="M12 6l1-1v7" />
</svg>
),
h2: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4v8" />
<path d="M9 4v8" />
<path d="M3 8h6" />
<path d="M11 6.2A1.5 1.5 0 0 1 14 6.5c0 1.4-3 2-3 5.5h3" />
</svg>
),
h3: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4v8" />
<path d="M9 4v8" />
<path d="M3 8h6" />
<path d="M11 6.2A1.5 1.5 0 0 1 14 6.5c0 1.5-3 1.5-3 1.5s3 0 3 2c0 1.4-2.5 1.7-3 1" />
</svg>
),
bold: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 3h4a2.5 2.5 0 0 1 0 5H5z" />
<path d="M5 8h4.5a2.5 2.5 0 0 1 0 5H5z" />
</svg>
),
italic: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 3 6 13" />
<path d="M5 3h5" />
<path d="M6 13h5" />
</svg>
),
strike: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 8h10" />
<path d="M11 5a3 3 0 0 0-3-2H7a2.5 2.5 0 0 0-2.5 2.5C4.5 7 6 8 8 8" />
<path d="M5.5 11A2.5 2.5 0 0 0 8 13h1a3 3 0 0 0 3-2.5" />
</svg>
),
list: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx={3.5} cy={4} r={0.7} fill="currentColor" />
<circle cx={3.5} cy={8} r={0.7} fill="currentColor" />
<circle cx={3.5} cy={12} r={0.7} fill="currentColor" />
<path d="M7 4h6" />
<path d="M7 8h6" />
<path d="M7 12h6" />
</svg>
),
olist: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.5 3h1v2.5" />
<path d="M2 8h2c0.5 0 0.5 1 0 1l-1.5 2H4" />
<path d="M7 4h6" />
<path d="M7 8h6" />
<path d="M7 12h6" />
</svg>
),
check: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x={2.5} y={2.5} width={11} height={11} rx={2} />
<path d="m5.5 8 2 2 3-4" />
</svg>
),
quote: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 4H3v3.5L5 9V6h2V4z" />
<path d="M11 4h-2v3.5l2 1.5V6h2V4z" />
</svg>
),
code: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 5-3 3 3 3" />
<path d="m10 5 3 3-3 3" />
</svg>
),
copy: (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x={5} y={5} width={8} height={8} rx={1.4} />
<path d="M3 11V4a1 1 0 0 1 1-1h7" />
</svg>
)
}
export function ToolbarBtn(props: { iconKey: keyof typeof TB_ICON }): JSX.Element {
return (
<span className="inline-flex size-[22px] items-center justify-center rounded text-muted-foreground">
<span className="size-[13px] [&>svg]:size-full">{TB_ICON[props.iconKey]}</span>
</span>
)
}
export function ToolbarSep(): JSX.Element {
return <span className="mx-1 h-3.5 w-px bg-foreground/10" />
}
@@ -0,0 +1,52 @@
import type { RefObject } from 'react'
export type EditorAnimatedVisualRefs = {
docRef: RefObject<HTMLDivElement | null>
activeLineRef: RefObject<HTMLDivElement | null>
activeTextRef: RefObject<HTMLSpanElement | null>
afterRef: RefObject<HTMLDivElement | null>
cursorRef: RefObject<HTMLDivElement | null>
menuRef: RefObject<HTMLDivElement | null>
rowH1Ref: RefObject<HTMLDivElement | null>
rowCodeRef: RefObject<HTMLDivElement | null>
}
export const EDITOR_ANIMATION_TIMING = {
preHoverMs: 450,
typePerCharMs: 60,
postTypeMs: 120,
menuHoldMs: 900,
clickRippleMs: 220,
postClickMs: 140,
postH1RevealMs: 260,
postH1TypeMs: 700,
newLineHoldMs: 380,
finalHoldMs: 2200
} as const
// Helpers shared between initial render and re-created beat-2 lines so the
// styling stays in lockstep regardless of which path mounts the node.
export function activeLineClass(): string {
return 'relative mt-1.5 min-h-[18px] py-px'
}
export function caretClass(): string {
return 'inline-block'
}
export function codeBlockHTML(): string {
return `
<div style="background: rgba(24,24,27,0.04); border: 1px solid rgba(24,24,27,0.10); border-radius: 8px; overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.55;">
<div style="display: flex; align-items: center; justify-content: space-between; padding: 5px 9px; border-bottom: 1px solid rgba(24,24,27,0.10); background: rgba(24,24,27,0.04);">
<span style="font-size: 10px; font-weight: 600; color: rgb(113 113 122); letter-spacing: 0.02em;">typescript</span>
<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; color: rgb(113 113 122);">
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="8" height="8" rx="1.4"/><path d="M3 11V4a1 1 0 0 1 1-1h7"/></svg>
<span>Copy</span>
</span>
</div>
<div style="padding: 8px 11px; background: #fff; display: flex; flex-direction: column; gap: 2px;">
<div><span style="color:#a855f7;">await</span> <span style="color:#2563eb;">runSmokeTests</span><span style="color:rgb(113 113 122);">({</span> env<span style="color:rgb(113 113 122);">:</span> <span style="color:#16a34a;">'staging'</span> <span style="color:rgb(113 113 122);">})</span></div>
<div><span style="color:#a855f7;">await</span> <span style="color:#2563eb;">publish</span><span style="color:rgb(113 113 122);">({</span> tag<span style="color:rgb(113 113 122);">:</span> <span style="color:#16a34a;">'v0.4.0'</span> <span style="color:rgb(113 113 122);">})</span></div>
</div>
</div>`
}
@@ -0,0 +1,340 @@
import { useEffect } from 'react'
import {
EDITOR_ANIMATION_TIMING,
activeLineClass,
caretClass,
codeBlockHTML,
type EditorAnimatedVisualRefs
} from './editor-animated-visual-markup'
export function useEditorAnimatedVisualAnimation(
reducedMotion: boolean,
refs: EditorAnimatedVisualRefs
): void {
const {
docRef,
activeLineRef,
activeTextRef,
afterRef,
cursorRef,
menuRef,
rowH1Ref,
rowCodeRef
} = refs
useEffect(() => {
if (reducedMotion) {
return
}
const docMaybe = docRef.current
const activeLineInitial = activeLineRef.current
const cursorMaybe = cursorRef.current
const menuMaybe = menuRef.current
const afterMaybe = afterRef.current
if (!docMaybe || !activeLineInitial || !cursorMaybe || !menuMaybe || !afterMaybe) {
return
}
// Re-bind to non-null locals so the helper closures spanning `await`
// points keep their narrowed types — TS flow analysis drops the narrow
// through async boundaries otherwise.
const doc: HTMLDivElement = docMaybe
const cursor: HTMLDivElement = cursorMaybe
const menu: HTMLDivElement = menuMaybe
const after: HTMLDivElement = afterMaybe
let cancelled = false
const timers: number[] = []
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
const id = window.setTimeout(() => resolve(), ms)
timers.push(id)
})
// Stash initial DOM so we can restore between loops.
const initialActiveLineHTML = activeLineInitial.outerHTML
const initialActiveLineParent = activeLineInitial.parentNode
const initialActiveLineNextSibling = activeLineInitial.nextSibling
let activeLine: HTMLDivElement = activeLineInitial
let activeText: HTMLSpanElement | null = activeTextRef.current
let activeCaret: HTMLSpanElement | null =
activeLineInitial.querySelector<HTMLSpanElement>('[data-md-caret]')
function setSlashMode(mode: 'all' | 'code'): void {
menu.querySelectorAll<HTMLElement>('[data-slash-show]').forEach((el) => {
const allowed = (el.getAttribute('data-slash-show') ?? '').split(',')
el.style.display = allowed.includes(mode) ? '' : 'none'
})
}
function placeMenuNearLine(line: HTMLElement): void {
const docRect = doc.getBoundingClientRect()
const lineRect = line.getBoundingClientRect()
// Why: nudge the menu right so it doesn't cover the "/" the user just
// typed — keeps the typed character visible alongside the menu.
const x = lineRect.left - docRect.left + 16
menu.style.left = `${x}px`
menu.style.top = '0px'
const wasShown = menu.dataset.shown === '1'
if (!wasShown) {
menu.style.visibility = 'hidden'
menu.dataset.shown = '1'
menu.style.opacity = '1'
menu.style.transform = 'none'
}
const menuH = menu.getBoundingClientRect().height
if (!wasShown) {
menu.dataset.shown = ''
menu.style.opacity = ''
menu.style.transform = ''
menu.style.visibility = ''
}
const belowY = lineRect.bottom - docRect.top + 6
const aboveY = lineRect.top - docRect.top - menuH - 6
const docH = docRect.height
const fitsBelow = belowY + menuH <= docH - 4
menu.style.top = `${fitsBelow ? belowY : Math.max(4, aboveY)}px`
}
function moveCursorTo(targetEl: HTMLElement, offsetX = 0, offsetY = 0): void {
const docRect = doc.getBoundingClientRect()
const tRect = targetEl.getBoundingClientRect()
const x = tRect.left - docRect.left + offsetX
const y = tRect.top - docRect.top + offsetY
cursor.style.transform = `translate(${x}px, ${y}px)`
}
function showMenu(): void {
menu.dataset.shown = '1'
menu.style.opacity = '1'
menu.style.transform = 'translateY(0) scale(1)'
}
function hideMenu(): void {
menu.dataset.shown = ''
menu.style.opacity = '0'
menu.style.transform = 'translateY(-4px) scale(0.985)'
}
function clearActiveRow(): void {
menu
.querySelectorAll<HTMLElement>('[data-slash-row]')
.forEach((el) => el.classList.remove('slash-active'))
}
async function typeInto(
el: HTMLElement,
text: string,
perChar: number = EDITOR_ANIMATION_TIMING.typePerCharMs
): Promise<void> {
for (const ch of text) {
if (cancelled) {
return
}
el.textContent = (el.textContent ?? '') + ch
await wait(perChar)
}
}
function clearAfter(): void {
after.innerHTML = ''
}
function restoreInitialActiveLine(): void {
// Pull whatever the active line currently is back into the original
// shape so the next loop starts from the same DOM as render.
activeLine.remove()
const wrapper = document.createElement('div')
wrapper.innerHTML = initialActiveLineHTML
const fresh = wrapper.firstElementChild as HTMLDivElement | null
if (!fresh) {
return
}
if (initialActiveLineParent) {
if (
initialActiveLineNextSibling &&
initialActiveLineNextSibling.parentNode === initialActiveLineParent
) {
initialActiveLineParent.insertBefore(fresh, initialActiveLineNextSibling)
} else {
initialActiveLineParent.appendChild(fresh)
}
}
activeLine = fresh
activeText = fresh.querySelector<HTMLSpanElement>('[data-md-active-text]')
activeCaret = fresh.querySelector<HTMLSpanElement>('[data-md-caret]')
}
async function loop(): Promise<void> {
while (!cancelled) {
// Reset state.
clearAfter()
hideMenu()
clearActiveRow()
cursor.style.transition = 'none'
cursor.style.opacity = '0'
cursor.style.transform = 'translate(-30px, 80px)'
// Force reflow so the next transition takes effect.
void cursor.offsetWidth
cursor.style.transition = ''
await wait(EDITOR_ANIMATION_TIMING.preHoverMs)
if (cancelled) {
return
}
// 1. Type "/" on the fresh active line.
if (activeText) {
activeText.textContent = ''
}
await typeInto(activeText ?? activeLine, '/')
if (cancelled) {
return
}
await wait(EDITOR_ANIMATION_TIMING.postTypeMs)
if (cancelled) {
return
}
// 2. Slash menu opens, anchored near the line.
setSlashMode('all')
placeMenuNearLine(activeLine)
showMenu()
cursor.style.opacity = '1'
const rowH1 = rowH1Ref.current
if (rowH1) {
moveCursorTo(rowH1, 14, 11)
rowH1.classList.add('slash-active')
}
await wait(EDITOR_ANIMATION_TIMING.menuHoldMs)
if (cancelled) {
return
}
// 3. Click — line becomes an H1.
cursor.dataset.clicking = '1'
await wait(EDITOR_ANIMATION_TIMING.clickRippleMs)
if (cancelled) {
return
}
cursor.dataset.clicking = ''
hideMenu()
cursor.style.opacity = '0'
await wait(EDITOR_ANIMATION_TIMING.postClickMs)
if (cancelled) {
return
}
// Convert the active line to an H1: clear the slash glyph, drop the
// monospace styling, type the heading.
activeLine.dataset.role = 'h1'
if (activeText) {
activeText.textContent = ''
}
if (activeCaret) {
activeCaret.style.display = ''
}
await wait(EDITOR_ANIMATION_TIMING.postH1RevealMs)
if (cancelled) {
return
}
await typeInto(activeText ?? activeLine, 'Ship checklist', 55)
if (cancelled) {
return
}
await wait(EDITOR_ANIMATION_TIMING.postH1TypeMs)
if (cancelled) {
return
}
// 4. New active line below the H1 — user types "/code".
const newActive = document.createElement('div')
newActive.dataset.role = 'active'
newActive.className = activeLineClass()
const newText = document.createElement('span')
newText.dataset.mdActiveText = '1'
const newCaret = document.createElement('span')
newCaret.dataset.mdCaret = '1'
newCaret.className = caretClass()
newActive.appendChild(newText)
newActive.appendChild(newCaret)
after.appendChild(newActive)
const lineForBeat2 = newActive
await wait(EDITOR_ANIMATION_TIMING.newLineHoldMs)
if (cancelled) {
return
}
for (const ch of '/code') {
if (cancelled) {
return
}
newText.textContent = (newText.textContent ?? '') + ch
await wait(EDITOR_ANIMATION_TIMING.typePerCharMs)
}
await wait(EDITOR_ANIMATION_TIMING.postTypeMs)
if (cancelled) {
return
}
// Filter to the Code Block row, anchor menu, highlight.
clearActiveRow()
if (rowH1) {
rowH1.classList.remove('slash-active')
}
setSlashMode('code')
placeMenuNearLine(lineForBeat2)
showMenu()
cursor.style.opacity = '1'
const rowCode = rowCodeRef.current
if (rowCode) {
moveCursorTo(rowCode, 14, 11)
rowCode.classList.add('slash-active')
}
await wait(EDITOR_ANIMATION_TIMING.menuHoldMs)
if (cancelled) {
return
}
// 5. Click — line becomes a code block.
cursor.dataset.clicking = '1'
await wait(EDITOR_ANIMATION_TIMING.clickRippleMs)
if (cancelled) {
return
}
cursor.dataset.clicking = ''
hideMenu()
cursor.style.opacity = '0'
await wait(EDITOR_ANIMATION_TIMING.postClickMs)
if (cancelled) {
return
}
const codeBlock = document.createElement('div')
codeBlock.className = 'mt-1.5 animate-[md-block-in_380ms_cubic-bezier(.2,.8,.2,1)_both]'
codeBlock.innerHTML = codeBlockHTML()
lineForBeat2.replaceWith(codeBlock)
await wait(EDITOR_ANIMATION_TIMING.finalHoldMs)
if (cancelled) {
return
}
// Restore the initial DOM and loop.
restoreInitialActiveLine()
}
}
void loop()
return () => {
cancelled = true
timers.forEach((id) => window.clearTimeout(id))
}
}, [
activeLineRef,
activeTextRef,
afterRef,
cursorRef,
docRef,
menuRef,
reducedMotion,
rowCodeRef,
rowH1Ref
])
}