Keep the Extend panel put and name sibling clips as branches.

A finished job no longer closes a draft or jumps the page, and a second extend from the same part saves as p8b instead of another p8.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-02 15:16:30 -05:00
co-authored by Cursor
parent 4546aa5f61
commit 1be9bcb23f
9 changed files with 274 additions and 119 deletions
+99 -1
View File
@@ -68,7 +68,105 @@ export type LibraryEntry = {
}
export function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '')
return String(name || '').trim().replace(/\s+p\d+[a-z]?$/i, '')
}
export function clipPartLabel(name?: string) {
const match = String(name || '').trim().match(/\s+p(\d+)([a-z])?$/i)
if (!match) return ''
return `p${match[1]}${(match[2] || '').toLowerCase()}`
}
export function nextClipPartName(name: string, existingNames: string[] = []) {
const trimmed = String(name || '').trim()
if (!trimmed) return ''
const match = trimmed.match(/^(.*)\s+p(\d+)([a-z])?$/i)
const base = (match ? match[1] : trimmed).trim() || trimmed
const part = match ? Number(match[2]) + 1 : 2
const taken = new Set(existingNames.map(item => String(item || '').trim().toLowerCase()).filter(Boolean))
const first = `${base} p${part}`
if (!taken.has(first.toLowerCase())) {
return first.slice(0, 80)
}
for (const letter of 'bcdefghijklmnopqrstuvwxyz') {
const candidate = `${base} p${part}${letter}`
if (!taken.has(candidate.toLowerCase())) return candidate.slice(0, 80)
}
let extra = 2
while (extra < 100) {
const candidate = `${base} p${part}-${extra}`
if (!taken.has(candidate.toLowerCase())) return candidate.slice(0, 80)
extra += 1
}
return first.slice(0, 80)
}
export type ClipTreeNode = { clip: LibraryClip; depth: number }
function sortTreeSiblings(a: LibraryClip, b: LibraryClip) {
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
if (chain) return chain
return a.createdAt - b.createdAt
}
export function treeOrderedParts(parts: LibraryClip[]): ClipTreeNode[] {
if (!parts.length) return []
const byId = new Map(parts.map(part => [part.id, part]))
const children = new Map<string, LibraryClip[]>()
const roots: LibraryClip[] = []
for (const part of parts) {
const parentId = part.parentClipId
if (parentId && byId.has(parentId)) {
const list = children.get(parentId) || []
list.push(part)
children.set(parentId, list)
} else {
roots.push(part)
}
}
roots.sort(sortTreeSiblings)
for (const list of children.values()) list.sort(sortTreeSiblings)
const out: ClipTreeNode[] = []
const walk = (clip: LibraryClip, depth: number) => {
out.push({ clip, depth })
for (const child of children.get(clip.id) || []) walk(child, depth + 1)
}
for (const root of roots) walk(root, 0)
return out
}
export function familyBranchCount(parts: LibraryClip[]) {
const counts = new Map<string, number>()
for (const part of parts) {
if (!part.parentClipId) continue
counts.set(part.parentClipId, (counts.get(part.parentClipId) || 0) + 1)
}
return [...counts.values()].filter(count => count > 1).length
}
export function familyBranchHint(parts: LibraryClip[]) {
const groups = new Map<string, LibraryClip[]>()
for (const part of parts) {
if (!part.parentClipId) continue
const list = groups.get(part.parentClipId) || []
list.push(part)
groups.set(part.parentClipId, list)
}
const branched = [...groups.entries()].filter(([, kids]) => kids.length > 1)
if (!branched.length) return ''
return branched.map(([parentId, kids]) => {
const parent = parts.find(part => part.id === parentId)
const from = clipPartLabel(parent?.name) || 'a part'
const names = kids.map(kid => clipPartLabel(kid.name) || kid.name || 'part').join(', ')
return `${from} → ${names}`
}).join(' · ')
}
export function clipBranchFromLabel(item: LibraryClip, parts: LibraryClip[]) {
if (!item.parentClipId) return ''
const parent = parts.find(part => part.id === item.parentClipId)
const from = clipPartLabel(parent?.name) || parent?.name
return from ? `from ${from}` : ''
}
export function clipFamilyKey(clip: LibraryClip) {