mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Extract speech lifecycle and editor animation modules (#16767)
* Extract speech worker lifecycle helpers * Split editor feature-wall animation
This commit is contained in:
@@ -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
@@ -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, '..')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '..')
|
||||
}
|
||||
@@ -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
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user