diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 80b2fa56b3b..6df9dddce05 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index 7903c663c28..a840d7aa256 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -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 } -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 { - return new Promise((resolve) => { - let settled = false - let receivedStopped = false - let timeout: ReturnType | 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, '..') - } } diff --git a/src/main/speech/stt-worker-paths.ts b/src/main/speech/stt-worker-paths.ts new file mode 100644 index 00000000000..e18515a0d98 --- /dev/null +++ b/src/main/speech/stt-worker-paths.ts @@ -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, '..') +} diff --git a/src/main/speech/stt-worker-stop.ts b/src/main/speech/stt-worker-stop.ts new file mode 100644 index 00000000000..dec6d24bcb1 --- /dev/null +++ b/src/main/speech/stt-worker-stop.ts @@ -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 { + return new Promise((resolve) => { + let settled = false + let receivedStopped = false + let timeout: ReturnType | 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) + }) +} diff --git a/src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx index ff02e98bf82..e18af542824 100644 --- a/src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx @@ -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 = { - pilcrow: ( - - - - - - ), - h1: ( - - - - - - - ), - h2: ( - - - - - - - ), - h3: ( - - - - - - - ), - bold: ( - - - - - ), - italic: ( - - - - - - ), - strike: ( - - - - - - ), - list: ( - - - - - - - - - ), - olist: ( - - - - - - - - ), - check: ( - - - - - ), - quote: ( - - - - - ), - code: ( - - - - - ), - copy: ( - - - - - ) -} - -function ToolbarBtn(props: { iconKey: keyof typeof TB_ICON }): JSX.Element { - return ( - - {TB_ICON[props.iconKey]} - - ) -} - -function ToolbarSep(): JSX.Element { - return -} - // 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(null) const rowH1Ref = useRef(null) const rowCodeRef = useRef(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 => - 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('[data-md-caret]') - - function setSlashMode(mode: 'all' | 'code'): void { - menu.querySelectorAll('[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('[data-slash-row]') - .forEach((el) => el.classList.remove('slash-active')) - } - - async function typeInto( - el: HTMLElement, - text: string, - perChar = TYPE_PER_CHAR_MS - ): Promise { - 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('[data-md-active-text]') - activeCaret = fresh.querySelector('[data-md-caret]') - } - - async function loop(): Promise { - 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 (
@@ -829,29 +321,3 @@ function ActiveLine(props: {
) } - -// 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 ` -
-
- typescript - - - Copy - -
-
-
await runSmokeTests({ env: 'staging' })
-
await publish({ tag: 'v0.4.0' })
-
-
` -} diff --git a/src/renderer/src/components/feature-wall/editor-animated-toolbar-icons.tsx b/src/renderer/src/components/feature-wall/editor-animated-toolbar-icons.tsx new file mode 100644 index 00000000000..200719ecfe1 --- /dev/null +++ b/src/renderer/src/components/feature-wall/editor-animated-toolbar-icons.tsx @@ -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 = { + pilcrow: ( + + + + + + ), + h1: ( + + + + + + + ), + h2: ( + + + + + + + ), + h3: ( + + + + + + + ), + bold: ( + + + + + ), + italic: ( + + + + + + ), + strike: ( + + + + + + ), + list: ( + + + + + + + + + ), + olist: ( + + + + + + + + ), + check: ( + + + + + ), + quote: ( + + + + + ), + code: ( + + + + + ), + copy: ( + + + + + ) +} + +export function ToolbarBtn(props: { iconKey: keyof typeof TB_ICON }): JSX.Element { + return ( + + {TB_ICON[props.iconKey]} + + ) +} + +export function ToolbarSep(): JSX.Element { + return +} diff --git a/src/renderer/src/components/feature-wall/editor-animated-visual-markup.ts b/src/renderer/src/components/feature-wall/editor-animated-visual-markup.ts new file mode 100644 index 00000000000..a2a103b8c06 --- /dev/null +++ b/src/renderer/src/components/feature-wall/editor-animated-visual-markup.ts @@ -0,0 +1,52 @@ +import type { RefObject } from 'react' + +export type EditorAnimatedVisualRefs = { + docRef: RefObject + activeLineRef: RefObject + activeTextRef: RefObject + afterRef: RefObject + cursorRef: RefObject + menuRef: RefObject + rowH1Ref: RefObject + rowCodeRef: RefObject +} + +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 ` +
+
+ typescript + + + Copy + +
+
+
await runSmokeTests({ env: 'staging' })
+
await publish({ tag: 'v0.4.0' })
+
+
` +} diff --git a/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts b/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts new file mode 100644 index 00000000000..c5420ec04a0 --- /dev/null +++ b/src/renderer/src/components/feature-wall/use-editor-animated-visual-animation.ts @@ -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 => + 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('[data-md-caret]') + + function setSlashMode(mode: 'all' | 'code'): void { + menu.querySelectorAll('[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('[data-slash-row]') + .forEach((el) => el.classList.remove('slash-active')) + } + + async function typeInto( + el: HTMLElement, + text: string, + perChar: number = EDITOR_ANIMATION_TIMING.typePerCharMs + ): Promise { + 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('[data-md-active-text]') + activeCaret = fresh.querySelector('[data-md-caret]') + } + + async function loop(): Promise { + 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 + ]) +}