Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/runs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ function RunWizard({ id }: { id: string }) {
toast.success('Launch plan copied')
}

function editField(platform: PlatformId, path: (string | number)[], value: string) {
dispatch({ type: 'EDIT_FIELD', platform, path, value })
}

if (status === 'loading') {
return <p className="px-6 py-16 text-center text-sm text-muted-foreground">Loading run…</p>
}
Expand Down Expand Up @@ -161,6 +165,7 @@ function RunWizard({ id }: { id: string }) {
onExportMarkdown={exportMarkdown}
onCopyPlan={copyPlan}
onExportPlan={exportPlan}
onEditField={editField}
onStartOver={() => router.push('/runs')}
/>
)}
Expand Down
38 changes: 38 additions & 0 deletions components/common/EditableText/EditableText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { EditableText } from './EditableText'

describe('EditableText', () => {
it('commits trimmed text on blur when changed', () => {
const onCommit = vi.fn()
render(<EditableText value="hello" onCommit={onCommit} ariaLabel="Field" />)
const el = screen.getByRole('textbox', { name: 'Field' })
el.textContent = ' world '
fireEvent.blur(el)
expect(onCommit).toHaveBeenCalledWith('world')
})

it('does not commit when the value is unchanged', () => {
const onCommit = vi.fn()
render(<EditableText value="hello" onCommit={onCommit} ariaLabel="Field" />)
const el = screen.getByRole('textbox', { name: 'Field' })
fireEvent.blur(el)
expect(onCommit).not.toHaveBeenCalled()
})

it('reverts to the previous value when cleared to empty', () => {
const onCommit = vi.fn()
render(<EditableText value="hello" onCommit={onCommit} ariaLabel="Field" />)
const el = screen.getByRole('textbox', { name: 'Field' })
el.textContent = ' '
fireEvent.blur(el)
expect(onCommit).not.toHaveBeenCalled()
expect(el.textContent).toBe('hello')
})

it('renders read-only text (no textbox) when onCommit is omitted', () => {
render(<EditableText value="static" ariaLabel="Field" />)
expect(screen.queryByRole('textbox')).toBeNull()
expect(screen.getByText('static')).toBeInTheDocument()
})
})
65 changes: 65 additions & 0 deletions components/common/EditableText/EditableText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client'
import { useRef, type KeyboardEvent, type ClipboardEvent } from 'react'
import type { EditableTextProps } from './EditableText.types'

// Seamless inline editing: the text itself is contentEditable — no input box, no
// edit/save buttons. Edits commit on blur and persist via the run's autosave.
// React renders `value` as the initial child and never re-renders it mid-edit
// (commits happen on blur, so the value prop only changes when unfocused), so the
// caret never jumps.
export function EditableText({ value, onCommit, multiline = false, ariaLabel, className = '' }: EditableTextProps) {
const ref = useRef<HTMLDivElement>(null)

if (!onCommit) {
return (
<div className={className} style={multiline ? { whiteSpace: 'pre-wrap' } : undefined}>
{value}
</div>
)
}

function commit() {
const el = ref.current
if (!el) return
// jsdom lacks innerText; fall back to textContent so tests still exercise commit.
const raw = (multiline ? el.innerText || el.textContent : el.textContent) || ''
const next = raw.replace(/ /g, ' ').replace(/\n+$/, '').trim()
if (!next) {
el.textContent = value // never persist blank prose — revert
return
}
if (next !== value) onCommit!(next)
}

function handleKeyDown(e: KeyboardEvent) {
if (!multiline && e.key === 'Enter') {
e.preventDefault()
ref.current?.blur()
}
}

function handlePaste(e: ClipboardEvent) {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
}
Comment on lines +41 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Current browser support and deprecation status for Document.execCommand('insertText') in contentEditable elements (Chrome, Firefox, Safari, iOS Safari), and recommended modern replacement API.

💡 Result:

As of June 2026, document.execCommand('insertText') is officially deprecated and non-standard [1][2][3]. It is no longer on a standards track, and its use is discouraged by browser vendors and documentation platforms [1][4]. Browser Support Status Despite being deprecated, the method remains widely implemented across major browsers for compatibility reasons [1][5]. - Chrome, Edge, and Opera: Full support [6]. - Firefox: Supported, though with some known historical caveats regarding nested calls and input manipulation in specific older versions [6][7][2]. - Safari (including iOS Safari): Supported [6]. Because it is not standardized, browser vendors have no obligation to maintain it, and it may cease to function at any time [1][4]. Developers should treat it as an unstable feature [1]. Modern Replacement and Recommendations There is no single direct, drop-in replacement API for document.execCommand('insertText') that provides the same behavior, particularly regarding the preservation of the browser's native undo/redo history [1][4][5]. - For general text editing: Developers are encouraged to use the Selection and Range APIs to manually manipulate the DOM or, for complex editors, leverage established open-source rich-text editing libraries (e.g., CKEditor, TinyMCE, ProseMirror, Slate) which abstract away the inconsistencies of native browser editing [4][8][5]. - For advanced custom editing: The EditContext API is the modern, standardized alternative designed to give developers granular control over text input, composition, and selection, without relying on the DOM-heavy contentEditable attribute [9][10][11]. However, EditContext is primarily for building custom editing experiences rather than a direct replacement for simple text insertion [10]. - For undo/redo preservation: If you must maintain native undo/redo behavior, many developers are currently forced to continue using execCommand as a legacy fallback due to the lack of a standardized modern API that satisfies this specific requirement [12][13][5]. Some implementers build custom undo/redo stacks using MutationObserver or application-level state management, though this significantly increases implementation complexity [4][5].

Citations:


🏁 Script executed:

# First, let's examine the EditableText component to understand the context
fd -t f "EditableText.tsx" --type file

Repository: AhmedFr/launch-kit

Length of output: 110


🏁 Script executed:

# Once found, examine the file around lines 41-45
cat -n components/common/EditableText/EditableText.tsx | head -60 | tail -30

Repository: AhmedFr/launch-kit

Length of output: 1172


🏁 Script executed:

# Check if there are any tests for this component
fd -t f "EditableText" --type file | grep -i test

Repository: AhmedFr/launch-kit

Length of output: 115


🏁 Script executed:

# Check the test file to understand the expected behavior
cat -n components/common/EditableText/EditableText.test.tsx

Repository: AhmedFr/launch-kit

Length of output: 1813


🏁 Script executed:

# Get more context about the component - check the beginning and full structure
head -40 components/common/EditableText/EditableText.tsx | cat -n

Repository: AhmedFr/launch-kit

Length of output: 1766


🏁 Script executed:

# Check if there's any type information about the ref
rg -A 5 -B 5 "useRef" components/common/EditableText/EditableText.tsx

Repository: AhmedFr/launch-kit

Length of output: 857


Replace deprecated paste insertion API with a fallback-safe approach.

Line 44 uses document.execCommand('insertText', ...) after preventing default paste at Line 42. This API is officially deprecated and non-standard as of June 2026; if the browser blocks or removes support for it, pasted text will be lost. Switch to the Selection and Range APIs with execCommand as a fallback to ensure reliable paste handling.

Suggested patch
 function handlePaste(e: ClipboardEvent) {
   e.preventDefault()
   const text = e.clipboardData.getData('text/plain')
-  document.execCommand('insertText', false, text)
+  const selection = window.getSelection()
+  if (selection && selection.rangeCount > 0) {
+    const range = selection.getRangeAt(0)
+    range.deleteContents()
+    range.insertNode(document.createTextNode(text))
+    range.collapse(false)
+    selection.removeAllRanges()
+    selection.addRange(range)
+    return
+  }
+  // Fallback for engines that still support it
+  document.execCommand('insertText', false, text)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function handlePaste(e: ClipboardEvent) {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
range.deleteContents()
range.insertNode(document.createTextNode(text))
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
return
}
// Fallback for engines that still support it
document.execCommand('insertText', false, text)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/common/EditableText/EditableText.tsx` around lines 41 - 45, In the
handlePaste function, replace the deprecated document.execCommand('insertText')
call with a modern approach that prioritizes the Selection and Range APIs. First
attempt to retrieve the current selection using window.getSelection(), create a
range, delete the current content, and insert the new text using the Range API.
If the selection is unavailable or the modern approach fails, fall back to
document.execCommand('insertText') as a compatibility measure to ensure reliable
paste handling across all browsers.


return (
<div
ref={ref}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-label={ariaLabel}
aria-multiline={multiline || undefined}
tabIndex={0}
onBlur={commit}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className={`-mx-1 cursor-text rounded-sm px-1 outline-none transition-colors hover:bg-muted/40 focus:bg-muted/30 focus:ring-1 focus:ring-primary/40 ${className}`}
style={multiline ? { whiteSpace: 'pre-wrap' } : undefined}
>
{value}
</div>
)
}
9 changes: 9 additions & 0 deletions components/common/EditableText/EditableText.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type EditableTextProps = {
value: string
/** When omitted, the text renders read-only (no contentEditable). */
onCommit?: (next: string) => void
/** Multiline keeps line breaks and lets Enter insert a newline. */
multiline?: boolean
ariaLabel: string
className?: string
}
1 change: 1 addition & 0 deletions components/common/EditableText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { EditableText } from './EditableText'
1 change: 1 addition & 0 deletions components/post-kit/PostKit/PostKit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function renderKit() {
onExportMarkdown={vi.fn()}
onCopyPlan={vi.fn()}
onExportPlan={vi.fn()}
onEditField={vi.fn()}
onStartOver={vi.fn()}
/>
</RuntimeConfigProvider>,
Expand Down
6 changes: 4 additions & 2 deletions components/post-kit/PostKit/PostKit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { PostKitProps } from './PostKit.types'
type View = 'edit' | 'launch-ops' | 'preview'
const VIEW_LABELS: Record<View, string> = { edit: 'Edit', 'launch-ops': 'Launch ops', preview: 'Preview' }

export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan, onExportPlan, onStartOver }: PostKitProps) {
export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan, onExportPlan, onEditField, onStartOver }: PostKitProps) {
const { core, platforms, plan } = generation
const firstReady = PLATFORMS.find((p) => platforms[p.id])?.id ?? 'product-hunt'
const [platform, setPlatform] = useState<PlatformId>(firstReady)
Expand Down Expand Up @@ -117,7 +117,9 @@ export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan,
This platform didn&apos;t generate. Pick another, or start over to try again.
</p>
) : activeView === 'edit' ? (
Sections ? <Sections core={core} content={content} /> : null
Sections ? (
<Sections core={core} content={content} onEdit={(path, value) => onEditField(platform, path, value)} />
) : null
) : activeView === 'launch-ops' ? (
<LaunchOpsSection kit={content as ProductHuntContent} />
) : Preview ? (
Expand Down
1 change: 1 addition & 0 deletions components/post-kit/PostKit/PostKit.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export type PostKitProps = {
onExportMarkdown: (platform: PlatformId) => void
onCopyPlan: () => void
onExportPlan: () => void
onEditField: (platform: PlatformId, path: (string | number)[], value: string) => void
onStartOver: () => void
}
17 changes: 14 additions & 3 deletions components/post-kit/platforms/AppSumoSections/AppSumoSections.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { SectionCard } from '@/components/common/SectionCard'
import { CopyButton } from '@/components/common/CopyButton'
import { EditableText } from '@/components/common/EditableText'
import { Badge } from '@/components/ui/badge'
import type { AppSumoContent } from '@/lib/types'
import type { PlatformSectionsProps } from '../platform-sections.types'

export function AppSumoSections({ content, loading = false }: PlatformSectionsProps) {
export function AppSumoSections({ content, loading = false, onEdit }: PlatformSectionsProps) {
const deal = content as AppSumoContent
return (
<div className="space-y-4">
<SectionCard title="Deal headline" loading={loading}>
<div className="flex items-center justify-between gap-2">
<p className="font-medium">{deal.dealHeadline}</p>
<EditableText
value={deal.dealHeadline}
onCommit={onEdit && ((v) => onEdit(['dealHeadline'], v))}
ariaLabel="Deal headline"
className="flex-1 font-medium"
/>
<CopyButton value={deal.dealHeadline} />
</div>
</SectionCard>
Expand All @@ -20,7 +26,12 @@ export function AppSumoSections({ content, loading = false }: PlatformSectionsPr
<span className="text-xs uppercase text-muted-foreground">Overview</span>
<CopyButton value={deal.pitch} />
</div>
<p className="whitespace-pre-wrap">{deal.pitch}</p>
<EditableText
value={deal.pitch}
onCommit={onEdit && ((v) => onEdit(['pitch'], v))}
multiline
ariaLabel="Pitch"
/>
</SectionCard>

<SectionCard title="What's included" loading={loading}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { SectionCard } from '@/components/common/SectionCard'
import { CopyButton } from '@/components/common/CopyButton'
import { EditableText } from '@/components/common/EditableText'
import type { HackerNewsContent } from '@/lib/types'
import type { PlatformSectionsProps } from '../platform-sections.types'

export function HackerNewsSections({ content, loading = false }: PlatformSectionsProps) {
export function HackerNewsSections({ content, loading = false, onEdit }: PlatformSectionsProps) {
const hn = content as HackerNewsContent
return (
<div className="space-y-4">
<SectionCard title="Show HN title" loading={loading}>
<div className="flex items-center justify-between gap-2">
<p className="font-medium">{hn.title}</p>
<EditableText
value={hn.title}
onCommit={onEdit && ((v) => onEdit(['title'], v))}
ariaLabel="Show HN title"
className="flex-1 font-medium"
/>
<CopyButton value={hn.title} />
</div>
</SectionCard>
Expand All @@ -19,15 +25,25 @@ export function HackerNewsSections({ content, loading = false }: PlatformSection
<span className="text-xs uppercase text-muted-foreground">Body</span>
<CopyButton value={hn.postBody} />
</div>
<p className="whitespace-pre-wrap">{hn.postBody}</p>
<EditableText
value={hn.postBody}
onCommit={onEdit && ((v) => onEdit(['postBody'], v))}
multiline
ariaLabel="Post body"
/>
</SectionCard>

<SectionCard title="First comment" loading={loading}>
<div className="flex items-center justify-between">
<span className="text-xs uppercase text-muted-foreground">Comment</span>
<CopyButton value={hn.firstComment} />
</div>
<p className="whitespace-pre-wrap">{hn.firstComment}</p>
<EditableText
value={hn.firstComment}
onCommit={onEdit && ((v) => onEdit(['firstComment'], v))}
multiline
ariaLabel="First comment"
/>
</SectionCard>

<SectionCard title="Posting tips" loading={loading}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import type { ProductHuntContent } from '@/lib/types'
import type { PlatformSectionsProps } from '../platform-sections.types'

// Launch-day ops live in their own "Launch ops" tab in PostKit, not in this edit view.
export function ProductHuntSections({ content, loading }: PlatformSectionsProps) {
export function ProductHuntSections({ content, loading, onEdit }: PlatformSectionsProps) {
const kit = content as ProductHuntContent
return (
<div className="space-y-4">
<CopySection kit={kit} loading={loading} />
<TopicsCommentSection kit={kit} loading={loading} />
<CopySection kit={kit} loading={loading} onEdit={onEdit} />
<TopicsCommentSection kit={kit} loading={loading} onEdit={onEdit} />
<GallerySection kit={kit} loading={loading} />
<VideoSection kit={kit} loading={loading} />
</div>
Expand Down
17 changes: 14 additions & 3 deletions components/post-kit/platforms/RedditSections/RedditSections.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { SectionCard } from '@/components/common/SectionCard'
import { CopyButton } from '@/components/common/CopyButton'
import { EditableText } from '@/components/common/EditableText'
import type { RedditContent } from '@/lib/types'
import type { PlatformSectionsProps } from '../platform-sections.types'

export function RedditSections({ content, loading = false }: PlatformSectionsProps) {
export function RedditSections({ content, loading = false, onEdit }: PlatformSectionsProps) {
const reddit = content as RedditContent
return (
<div className="space-y-4">
Expand Down Expand Up @@ -33,14 +34,24 @@ export function RedditSections({ content, loading = false }: PlatformSectionsPro
<span className="text-xs uppercase text-muted-foreground">Title</span>
<CopyButton value={reddit.title} />
</div>
<p className="font-medium">{reddit.title}</p>
<EditableText
value={reddit.title}
onCommit={onEdit && ((v) => onEdit(['title'], v))}
ariaLabel="Reddit post title"
className="font-medium"
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs uppercase text-muted-foreground">Body</span>
<CopyButton value={reddit.body} />
</div>
<p className="whitespace-pre-wrap">{reddit.body}</p>
<EditableText
value={reddit.body}
onCommit={onEdit && ((v) => onEdit(['body'], v))}
multiline
ariaLabel="Reddit post body"
/>
</div>
</SectionCard>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SocialSections } from './SocialSections'
import type { LaunchCore, SocialContent } from '@/lib/types'

Expand All @@ -25,4 +25,18 @@ describe('SocialSections', () => {
expect(screen.getByText('Would you share how you use Acme?')).toBeInTheDocument()
expect(screen.getByText('#buildinpublic')).toBeInTheDocument()
})

it('commits a tweet edit with the correct array path', () => {
const onEdit = vi.fn()
render(<SocialSections core={core} content={content} onEdit={onEdit} />)
const tweet = screen.getByRole('textbox', { name: 'Tweet 2' })
tweet.textContent = 'Edited second tweet'
fireEvent.blur(tweet)
expect(onEdit).toHaveBeenCalledWith(['thread', 'tweets', 1], 'Edited second tweet')
})

it('renders tweets read-only when onEdit is omitted', () => {
render(<SocialSections core={core} content={content} />)
expect(screen.queryByRole('textbox', { name: 'Tweet 1' })).toBeNull()
})
})
12 changes: 9 additions & 3 deletions components/post-kit/platforms/SocialSections/SocialSections.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { SectionCard } from '@/components/common/SectionCard'
import { CopyButton } from '@/components/common/CopyButton'
import { EditableText } from '@/components/common/EditableText'
import type { SocialContent } from '@/lib/types'
import type { PlatformSectionsProps } from '../platform-sections.types'

export function SocialSections({ content, loading = false }: PlatformSectionsProps) {
export function SocialSections({ content, loading = false, onEdit }: PlatformSectionsProps) {
const social = content as SocialContent
const fullThread = social.thread.tweets.join('\n\n')
const channels: { label: string; value: string }[] = [
Expand All @@ -21,12 +22,17 @@ export function SocialSections({ content, loading = false }: PlatformSectionsPro
</div>
<ol className="space-y-2">
{social.thread.tweets.map((tweet, i) => (
<li key={tweet} className="rounded-xl border border-border px-3 py-2">
<li key={i} className="rounded-xl border border-border px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground">{i + 1}/{social.thread.tweets.length}</span>
<CopyButton value={tweet} />
</div>
<p className="whitespace-pre-wrap">{tweet}</p>
<EditableText
value={tweet}
onCommit={onEdit && ((v) => onEdit(['thread', 'tweets', i], v))}
multiline
ariaLabel={`Tweet ${i + 1}`}
/>
</li>
))}
</ol>
Expand Down
13 changes: 11 additions & 2 deletions components/post-kit/platforms/platform-sections.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { LaunchCore, PlatformContent } from '@/lib/types'

// Commits an inline edit of one prose field. `path` is relative to the active
// platform's content object (e.g. ['copy','tagline'], ['thread','tweets',2]).
export type EditFieldFn = (path: (string | number)[], value: string) => void

// Every platform's edit-view section group takes the shared core plus its own
// content (narrowed internally) and a loading flag for the regenerate shimmer.
export type PlatformSectionsProps = { core: LaunchCore; content: PlatformContent; loading?: boolean }
// content (narrowed internally), a loading flag, and an optional inline-edit hook.
export type PlatformSectionsProps = {
core: LaunchCore
content: PlatformContent
loading?: boolean
onEdit?: EditFieldFn
}
Loading
Loading