@deepblend/dsh-blender-ui 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/client.js +326 -14
  2. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -478,7 +478,7 @@ window.__ModuleLoader__.load({
478
478
  'recipes.select': '使用这个配方',
479
479
  'recipes.selected': '已选择',
480
480
  'recipes.parameters': '调整配方',
481
- 'recipes.previewNote': '示例图展示默认参数。创建后会渲染你的设置,耗时取决于设备。',
481
+ 'recipes.previewNote': '示例图展示默认参数。切换配方会保留各自的调整,恢复默认值只影响当前配方。创建后会渲染你的设置,耗时取决于设备。',
482
482
  'recipes.createPreview': '创建并生成预览',
483
483
  'recipes.creatingPreview': '正在创建并渲染…',
484
484
  'recipes.stale': '选中的配方已更新或不可用,请重新选择。',
@@ -495,6 +495,22 @@ window.__ModuleLoader__.load({
495
495
  'projects.creationFailed': '未能确认创建结果。输入已保留;可重试上次创建,或刷新列表检查已保存的项目。',
496
496
  'projects.retryCreation': '重试上次创建',
497
497
  'projects.originalTitle': '上次创建:{title}',
498
+ 'projects.drafts': '继续创建草稿',
499
+ 'projects.draftHint': '创建草稿保存在此浏览器。恢复只填写输入,不会自动创建项目。',
500
+ 'projects.draftSaved': '当前创建草稿已保存到此浏览器。',
501
+ 'projects.draftUnavailable': '此浏览器暂时无法保存草稿。当前输入仍可使用,但重载或关闭页面后可能丢失。',
502
+ 'projects.draftFull': '创建草稿已满。删除不用的浏览器草稿后可继续保存,当前输入仍保留。',
503
+ 'projects.draftTooLarge': '当前创建草稿过大,无法保存到浏览器。当前输入仍保留。',
504
+ 'projects.draftInvalid': '这份浏览器草稿无法读取,原记录没有被更改。',
505
+ 'projects.draftMissingRecipe': '原配方 {id}@{version} 当前不可用。原创建请求仍可重试;新建作品前请选择当前配方。',
506
+ 'projects.draftOldParameters': '原配方参数',
507
+ 'projects.draftRestored': '已恢复创建草稿。检查输入后继续;上次请求需要明确重试。',
508
+ 'projects.draftRestore': '继续这份草稿',
509
+ 'projects.draftDelete': '删除浏览器草稿',
510
+ 'projects.draftClear': '清空当前创建草稿',
511
+ 'projects.draftUncertain': '创建结果尚未确认',
512
+ 'projects.untitledDraft': '未命名创建草稿',
513
+ 'projects.damagedDraft': '无法读取的创建草稿',
498
514
  'projects.refreshList': '刷新项目列表',
499
515
  'projects.creationDetails': '详细诊断',
500
516
  'projects.environmentGuide': '查看环境检查指南',
@@ -1027,7 +1043,7 @@ window.__ModuleLoader__.load({
1027
1043
  'recipes.select': 'Use this recipe',
1028
1044
  'recipes.selected': 'Selected',
1029
1045
  'recipes.parameters': 'Customize recipe',
1030
- 'recipes.previewNote': 'Images show default parameters. Creating a project renders your settings; time depends on your device.',
1046
+ 'recipes.previewNote': 'Images show default parameters. Switching recipes keeps each set of edits; reset affects only the current recipe. Creating a project renders your settings; time depends on your device.',
1031
1047
  'recipes.createPreview': 'Create and preview',
1032
1048
  'recipes.creatingPreview': 'Creating and rendering…',
1033
1049
  'recipes.stale': 'The selected recipe changed or is unavailable. Please select it again.',
@@ -1044,6 +1060,22 @@ window.__ModuleLoader__.load({
1044
1060
  'projects.creationFailed': 'The creation result could not be confirmed. Your inputs are kept; retry the previous creation, or refresh the list to check saved projects.',
1045
1061
  'projects.retryCreation': 'Retry previous creation',
1046
1062
  'projects.originalTitle': 'Previous creation: {title}',
1063
+ 'projects.drafts': 'Continue a creation draft',
1064
+ 'projects.draftHint': 'Creation drafts stay in this browser. Restoring fills the inputs and does not create a project automatically.',
1065
+ 'projects.draftSaved': 'The current creation draft is saved in this browser.',
1066
+ 'projects.draftUnavailable': 'This browser cannot save the draft right now. Current inputs still work, but reloading or closing the page may lose them.',
1067
+ 'projects.draftFull': 'Creation draft storage is full. Delete unused browser drafts to save more; current inputs remain.',
1068
+ 'projects.draftTooLarge': 'This creation draft is too large to save in the browser. Current inputs remain.',
1069
+ 'projects.draftInvalid': 'This browser draft cannot be read. Its original record has not been changed.',
1070
+ 'projects.draftMissingRecipe': 'Recipe {id}@{version} is currently unavailable. You can still retry its original creation request; choose a current recipe before starting a new project.',
1071
+ 'projects.draftOldParameters': 'Original recipe parameters',
1072
+ 'projects.draftRestored': 'Creation draft restored. Check the inputs before continuing; retry the previous request explicitly.',
1073
+ 'projects.draftRestore': 'Continue this draft',
1074
+ 'projects.draftDelete': 'Delete browser draft',
1075
+ 'projects.draftClear': 'Clear current creation draft',
1076
+ 'projects.draftUncertain': 'Creation result unconfirmed',
1077
+ 'projects.untitledDraft': 'Untitled creation draft',
1078
+ 'projects.damagedDraft': 'Unreadable creation draft',
1047
1079
  'projects.refreshList': 'Refresh project list',
1048
1080
  'projects.creationDetails': 'Diagnostic details',
1049
1081
  'projects.environmentGuide': 'Open the environment check guide',
@@ -1363,6 +1395,104 @@ window.__ModuleLoader__.load({
1363
1395
  else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 4294967296)
1364
1396
  return `create-${Date.now().toString(36)}-${(++creationKeySequence).toString(36)}-${[...bytes].map(n => n.toString(16).padStart(8, '0')).join('')}`
1365
1397
  }
1398
+ const CREATION_DRAFT_VERSION = 'deepblend.creation-draft/v1'
1399
+ const CREATION_DRAFT_BYTES = 262144
1400
+ const creationDraftPrefix = root => `${CREATION_DRAFT_VERSION}:${encodeURIComponent(root)}:`
1401
+ const creationRecipeReference = recipe => recipe ? { id: recipe.id, version: recipe.version, digest: recipe.digest } : null
1402
+ const recipeChoiceIdentity = recipe => creationSignature(creationRecipeReference(recipe))
1403
+ const copyRecipeParameters = values => Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]))
1404
+ const rememberRecipeChoice = (choices, recipe, parameters) => !recipe ? choices : [
1405
+ ...choices.filter(choice => recipeChoiceIdentity(choice.recipe) !== recipeChoiceIdentity(recipe)),
1406
+ { recipe: creationRecipeReference(recipe), parameters: copyRecipeParameters(parameters) },
1407
+ ]
1408
+ function validRecipeChoices(choices) {
1409
+ if (choices === undefined) return true // Existing 0.3.6 browser drafts contain only their selected recipe.
1410
+ if (!Array.isArray(choices)) return false
1411
+ const seen = new Set()
1412
+ return choices.every(choice => {
1413
+ if (!choice || !choice.recipe || !validCreationRecipeReference(choice.recipe)
1414
+ || Object.keys(choice).some(key => !['recipe', 'parameters'].includes(key))
1415
+ || !choice.parameters || typeof choice.parameters !== 'object' || Array.isArray(choice.parameters)) return false
1416
+ const key = recipeChoiceIdentity(choice.recipe)
1417
+ if (seen.has(key)) return false
1418
+ seen.add(key)
1419
+ return Object.values(choice.parameters).every(value => value === '' || Number.isFinite(value)
1420
+ || Array.isArray(value) && value.length === 3 && value.every(v => Number.isFinite(v) && v >= 0 && v <= 1))
1421
+ })
1422
+ }
1423
+ function recipeChoiceFits(recipe, parameters) {
1424
+ return Object.keys(parameters).length === recipe.parameters.length && recipe.parameters.every(parameter => {
1425
+ const value = parameters[parameter.id]
1426
+ return parameter.type === 'color' ? Array.isArray(value) && value.length === 3 && value.every(v => Number.isFinite(v) && v >= 0 && v <= 1)
1427
+ : value === '' || Number.isFinite(value)
1428
+ })
1429
+ }
1430
+ function creationDraftJson(value) {
1431
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return
1432
+ if (typeof value === 'number' && Number.isFinite(value)) return
1433
+ if (!value || typeof value !== 'object') throw new Error('Draft input is not JSON')
1434
+ for (const item of Object.values(value)) creationDraftJson(item)
1435
+ }
1436
+ function creationDraftForms(forms) {
1437
+ const snapshot = { title: forms.title, goal: forms.goal,
1438
+ recipe: creationRecipeReference(forms.recipe), recipeParameters: forms.recipeParameters }
1439
+ creationDraftJson(snapshot)
1440
+ return editorClone(snapshot)
1441
+ }
1442
+ const creationRequestBody = forms => editorClone({ title: forms.title.trim(),
1443
+ goal: forms.goal.length > 0 ? forms.goal : undefined, renderPreview: Boolean(forms.recipe),
1444
+ recipe: forms.recipe ? { ...creationRecipeReference(forms.recipe), parameters: forms.recipeParameters } : undefined })
1445
+ function validCreationRecipeReference(recipe) {
1446
+ return recipe === null || Boolean(recipe && typeof recipe === 'object' && !Array.isArray(recipe)
1447
+ && typeof recipe.id === 'string' && recipe.id && typeof recipe.version === 'string' && recipe.version
1448
+ && /^[a-f0-9]{64}$/.test(recipe.digest || ''))
1449
+ }
1450
+ function parseCreationDraft(raw, root, id) {
1451
+ try {
1452
+ if (typeof raw !== 'string' || new Blob([raw]).size > CREATION_DRAFT_BYTES) return null
1453
+ const draft = JSON.parse(raw), forms = draft.forms
1454
+ creationDraftJson(forms)
1455
+ if (draft.schemaVersion !== CREATION_DRAFT_VERSION || draft.projectsRoot !== root || draft.id !== id
1456
+ || (draft.ownerActive !== undefined && typeof draft.ownerActive !== 'boolean')
1457
+ || !Number.isFinite(Date.parse(draft.savedAt)) || !forms || typeof forms.title !== 'string'
1458
+ || typeof forms.goal !== 'string' || !validCreationRecipeReference(forms.recipe)
1459
+ || Object.keys(forms).some(key => !['title', 'goal', 'recipe', 'recipeParameters'].includes(key))
1460
+ || !forms.recipeParameters || typeof forms.recipeParameters !== 'object' || Array.isArray(forms.recipeParameters)
1461
+ || !validRecipeChoices(draft.recipeChoices)) return null
1462
+ const attempt = draft.attempt
1463
+ if (attempt !== null) {
1464
+ const body = attempt?.body
1465
+ creationDraftJson(body)
1466
+ if (!attempt || typeof attempt.key !== 'string' || !attempt.key.trim() || attempt.key.length > 128 || /[\x00-\x1f\x7f]/.test(attempt.key)
1467
+ || !body || typeof body.title !== 'string' || !body.title.trim() || typeof body.renderPreview !== 'boolean'
1468
+ || (body.goal !== undefined && typeof body.goal !== 'string')
1469
+ || (body.recipe !== undefined && (!body.recipe || !validCreationRecipeReference(body.recipe)
1470
+ || !body.recipe.parameters || typeof body.recipe.parameters !== 'object' || Array.isArray(body.recipe.parameters)))
1471
+ || body.renderPreview !== Boolean(body.recipe)
1472
+ || Object.keys(body).some(key => !['title', 'goal', 'recipe', 'renderPreview'].includes(key))
1473
+ || attempt.signature !== creationSignature(body)) return null
1474
+ }
1475
+ return draft
1476
+ } catch { return null }
1477
+ }
1478
+ function creationDraftStorage(settings) {
1479
+ try { return settings.creationDraftStorage ?? globalThis.localStorage } catch { return undefined }
1480
+ }
1481
+ function readCreationDrafts(storage, root) {
1482
+ if (!root) return { entries: [], available: false }
1483
+ try {
1484
+ if (!storage || typeof storage.key !== 'function' || !Number.isSafeInteger(storage.length)) return { entries: [], available: false }
1485
+ const prefix = creationDraftPrefix(root), entries = []
1486
+ for (let index = 0; index < storage.length; index++) {
1487
+ const key = storage.key(index)
1488
+ if (typeof key !== 'string' || !key.startsWith(prefix)) continue
1489
+ const id = key.slice(prefix.length), raw = storage.getItem(key), draft = parseCreationDraft(raw, root, id)
1490
+ entries.push({ id, raw, draft })
1491
+ }
1492
+ entries.sort((a, b) => String(b.draft?.savedAt || '').localeCompare(String(a.draft?.savedAt || '')))
1493
+ return { entries, available: true }
1494
+ } catch { return { entries: [], available: false } }
1495
+ }
1366
1496
  function creationErrorMessage(code) {
1367
1497
  if (code === 'UI_FETCH_FAILED' || /^HTTP_/.test(code)) return t('projects.replyMissing')
1368
1498
  if (code === 'BLENDER_NOT_FOUND' || code === 'RUNTIME_UNAVAILABLE') return t('projects.environmentFailed')
@@ -2480,6 +2610,9 @@ window.__ModuleLoader__.load({
2480
2610
  imageDownloads: {},
2481
2611
  creationWork: null,
2482
2612
  creationRequestProtocol: null,
2613
+ creationDrafts: [],
2614
+ creationDraftStorageStatus: null,
2615
+ creationDraftMissingRecipe: null,
2483
2616
  inspectionForms: {},
2484
2617
  inspectionWork: {},
2485
2618
  inspectionRevisions: {},
@@ -2503,6 +2636,7 @@ window.__ModuleLoader__.load({
2503
2636
  editorLastEdits: {},
2504
2637
  editorComparison: null,
2505
2638
  patchDrafts: {},
2639
+ recipeChoices: [],
2506
2640
  forms: { title: '', goal: '', recipe: null, recipeParameters: {}, patch: null, frameStart: '', frameEnd: '', profile: 'preview' },
2507
2641
  busy: { create: false, patch: false, editor: false, render: false, restore: false },
2508
2642
  notices: { projects: null, scene: null, preview: null, jobs: null, revisions: null },
@@ -2541,6 +2675,11 @@ window.__ModuleLoader__.load({
2541
2675
  let live = false
2542
2676
  let loadSequence = 0
2543
2677
  let creationAttempt = null
2678
+ const draftStorage = creationDraftStorage(settings)
2679
+ let creationDraftId = newCreationKey(), creationDraftRoot = null, persistedCreationSignature = null
2680
+ let restoredCreationSource = null, creationDraftWriting = false
2681
+ let creationDraftClosed = false
2682
+ const draftLifecycle = settings.creationDraftLifecycle ?? globalThis.window
2544
2683
  let fileChoiceSignature = null, fileChoicesStopped = false
2545
2684
  const photographyControllers = new Map()
2546
2685
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
@@ -2584,6 +2723,9 @@ window.__ModuleLoader__.load({
2584
2723
  const assetWork = (projectId, changes) => set({ assetWork: { ...data.assetWork, [projectId]: { ...data.assetWork[projectId], ...changes } } })
2585
2724
 
2586
2725
  const notify = () => {
2726
+ if (!creationDraftClosed) data.recipeChoices = rememberRecipeChoice(data.recipeChoices,
2727
+ data.forms.recipe || data.creationDraftMissingRecipe, data.forms.recipeParameters)
2728
+ persistCreationDraft()
2587
2729
  const signature = JSON.stringify([data.activeProjectId, data.selected?.scene?.revision, data.view, ...FILE_FIELDS.map(field => fileChoiceAllowed(data, field))])
2588
2730
  if (signature !== fileChoiceSignature) { data.fileChoiceGeneration += 1; fileChoiceSignature = signature }
2589
2731
  snapshot = { ...data, forms: { ...data.forms }, busy: { ...data.busy }, notices: { ...data.notices } }
@@ -2594,6 +2736,58 @@ window.__ModuleLoader__.load({
2594
2736
  /** Change one nested table. @param {'forms'|'busy'|'notices'} table @param {string} key @param {any} value */
2595
2737
  const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
2596
2738
 
2739
+ function refreshCreationDrafts() {
2740
+ const reading = readCreationDrafts(draftStorage, creationDraftRoot)
2741
+ data.creationDrafts = reading.entries.filter(entry => entry.id !== creationDraftId).map(entry => ({ id: entry.id,
2742
+ title: entry.draft?.forms.title || t('projects.untitledDraft'), savedAt: entry.draft?.savedAt || null,
2743
+ uncertain: Boolean(entry.draft?.attempt), invalid: !entry.draft }))
2744
+ return reading
2745
+ }
2746
+ function persistCreationDraft() {
2747
+ if (creationDraftClosed || creationDraftWriting || !creationDraftRoot || data.projectsRoot !== creationDraftRoot) return
2748
+ creationDraftWriting = true
2749
+ try {
2750
+ const forms = { ...creationDraftForms(data.forms), recipe: creationRecipeReference(data.forms.recipe) || data.creationDraftMissingRecipe }
2751
+ creationDraftJson(data.recipeChoices)
2752
+ const signature = JSON.stringify([forms, creationAttempt, data.recipeChoices])
2753
+ if (signature === persistedCreationSignature) return
2754
+ const reading = refreshCreationDrafts()
2755
+ if (!reading.available) { data.creationDraftStorageStatus = 'unavailable'; return }
2756
+ const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2757
+ if (!forms.title && !forms.goal && !forms.recipe && !Object.keys(forms.recipeParameters).length && !creationAttempt && !data.recipeChoices.length) {
2758
+ draftStorage.removeItem(key); data.creationDraftStorageStatus = null
2759
+ } else {
2760
+ if (!reading.entries.some(entry => entry.id === creationDraftId) && reading.entries.length >= 20) {
2761
+ data.creationDraftStorageStatus = 'full'; return
2762
+ }
2763
+ const raw = JSON.stringify({ schemaVersion: CREATION_DRAFT_VERSION, projectsRoot: creationDraftRoot,
2764
+ id: creationDraftId, ownerActive: true, savedAt: new Date().toISOString(), forms, attempt: creationAttempt, recipeChoices: data.recipeChoices })
2765
+ if (new Blob([raw]).size > CREATION_DRAFT_BYTES) { data.creationDraftStorageStatus = 'tooLarge'; return }
2766
+ draftStorage.setItem(key, raw)
2767
+ if (draftStorage.getItem(key) !== raw) throw new Error('Draft write did not persist')
2768
+ data.creationDraftStorageStatus = 'saved'
2769
+ }
2770
+ persistedCreationSignature = signature
2771
+ refreshCreationDrafts()
2772
+ } catch { data.creationDraftStorageStatus = 'unavailable' }
2773
+ finally { creationDraftWriting = false }
2774
+ }
2775
+ function closeCreationDraft() {
2776
+ if (creationDraftClosed) return
2777
+ persistCreationDraft()
2778
+ creationDraftClosed = true
2779
+ if (!creationDraftRoot) return
2780
+ try {
2781
+ const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2782
+ const saved = parseCreationDraft(draftStorage?.getItem(key), creationDraftRoot, creationDraftId)
2783
+ if (saved) draftStorage.setItem(key, JSON.stringify({ ...saved, ownerActive: false }))
2784
+ } catch { /* Current inputs were already saved or visibly reported as unsaved. */ }
2785
+ }
2786
+ function reopenCreationDraft() {
2787
+ creationDraftClosed = false; persistedCreationSignature = null
2788
+ persistCreationDraft()
2789
+ }
2790
+
2597
2791
  /** The project every per-project route is addressed to. */
2598
2792
  const target = () => data.projectId ?? data.projects.find(project => !project.creationPending)?.projectId ?? null
2599
2793
 
@@ -2668,6 +2862,17 @@ window.__ModuleLoader__.load({
2668
2862
  const next = { ...comparison, afterPreviews: editorClone(afterPreviews) }
2669
2863
  patch.editorComparison = { ...next, ...editorPreviewPair(next), resolved: afterPreviews.length > 0 }
2670
2864
  }
2865
+ if (patch.projectsRoot !== creationDraftRoot) {
2866
+ // Switching stores cannot carry an old store's request identity into a new one.
2867
+ if (creationDraftRoot !== null) {
2868
+ creationAttempt = null; restoredCreationSource = null; creationDraftId = newCreationKey()
2869
+ patch.creationWork = null; patch.creationDraftMissingRecipe = null; patch.recipeChoices = []
2870
+ patch.forms = { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }
2871
+ }
2872
+ creationDraftRoot = typeof patch.projectsRoot === 'string' && patch.projectsRoot ? patch.projectsRoot : null
2873
+ persistedCreationSignature = null
2874
+ }
2875
+ refreshCreationDrafts()
2671
2876
  set(patch)
2672
2877
  arm()
2673
2878
  }
@@ -2684,12 +2889,7 @@ window.__ModuleLoader__.load({
2684
2889
  /** A write just happened: re-run every read, including the view-specific ones. */
2685
2890
  const reload = () => { tick += 1; void load() }
2686
2891
 
2687
- const creationBody = () => editorClone({ title: data.forms.title.trim(),
2688
- goal: data.forms.goal.length > 0 ? data.forms.goal : undefined,
2689
- renderPreview: Boolean(data.forms.recipe), recipe: data.forms.recipe ? {
2690
- id: data.forms.recipe.id, version: data.forms.recipe.version, digest: data.forms.recipe.digest,
2691
- parameters: data.forms.recipeParameters,
2692
- } : undefined })
2892
+ const creationBody = () => creationRequestBody(data.forms)
2693
2893
 
2694
2894
  async function submitCreation(attempt) {
2695
2895
  if (data.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL) {
@@ -2698,17 +2898,42 @@ window.__ModuleLoader__.load({
2698
2898
  return
2699
2899
  }
2700
2900
  setIn('busy', 'create', true)
2901
+ const attemptRoot = creationDraftRoot
2701
2902
  set({ creationWork: { status: 'busy', title: attempt.body.title }, previewResult: null })
2702
2903
  setIn('notices', 'projects', null)
2703
2904
  setIn('notices', 'preview', null)
2704
2905
  const outcome = await postJson(fetchImpl, ROUTES.projects, { ...editorClone(attempt.body), creationKey: attempt.key })
2705
2906
  setIn('busy', 'create', false)
2907
+ if (attemptRoot !== creationDraftRoot) { reload(); return }
2706
2908
  if (outcome.ok) {
2707
2909
  const sameInputs = creationSignature(creationBody()) === attempt.signature
2910
+ if (attempt.body.recipe) data.recipeChoices = data.recipeChoices.filter(choice =>
2911
+ recipeChoiceIdentity(choice.recipe) !== recipeChoiceIdentity(attempt.body.recipe)
2912
+ || creationSignature(choice.parameters) !== creationSignature(attempt.body.recipe.parameters))
2708
2913
  set({ ...(sameInputs ? { forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} } } : {}),
2709
2914
  creationWork: null, projectId: outcome.payload.project.projectId,
2710
2915
  ...(attempt.body.renderPreview ? { view: 'preview', compareMode: 'result' } : {}) })
2711
2916
  if (creationAttempt === attempt) creationAttempt = null
2917
+ if (restoredCreationSource && creationDraftRoot) {
2918
+ try {
2919
+ const sourceKey = creationDraftPrefix(creationDraftRoot) + restoredCreationSource.id
2920
+ const source = parseCreationDraft(restoredCreationSource.raw, creationDraftRoot, restoredCreationSource.id)
2921
+ if (source && creationSignature(creationRequestBody(source.forms)) === attempt.signature
2922
+ && draftStorage?.getItem(sourceKey) === restoredCreationSource.raw) {
2923
+ const remaining = (source.recipeChoices || []).filter(choice => !attempt.body.recipe
2924
+ || recipeChoiceIdentity(choice.recipe) !== recipeChoiceIdentity(attempt.body.recipe)
2925
+ || creationSignature(choice.parameters) !== creationSignature(attempt.body.recipe.parameters))
2926
+ if (remaining.length) {
2927
+ const raw = JSON.stringify({ ...source, savedAt: new Date().toISOString(), attempt: null, recipeChoices: remaining,
2928
+ forms: { title: '', goal: '', recipe: null, recipeParameters: {} } })
2929
+ draftStorage.setItem(sourceKey, raw)
2930
+ if (draftStorage.getItem(sourceKey) !== raw) throw new Error('Draft write did not persist')
2931
+ } else draftStorage.removeItem(sourceKey)
2932
+ }
2933
+ } catch { data.creationDraftStorageStatus = 'unavailable' }
2934
+ }
2935
+ restoredCreationSource = null
2936
+ persistedCreationSignature = null
2712
2937
  setIn('notices', attempt.body.renderPreview ? 'preview' : 'projects', { kind: 'creation', projectId: outcome.payload.project.projectId, ok: true, message: outcome.payload.project.creationReplayed
2713
2938
  ? t('projects.recovered', { id: outcome.payload.project.projectId })
2714
2939
  : t('projects.created', { id: outcome.payload.project.projectId }) })
@@ -3019,10 +3244,21 @@ window.__ModuleLoader__.load({
3019
3244
  } else assetWork(projectId, { error: `${outcome.error.code}: ${outcome.error.message}` })
3020
3245
  reload()
3021
3246
  },
3022
- selectRecipe: recipe => set({ forms: { ...data.forms, recipe,
3023
- recipeParameters: recipe ? Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, parameter.default])) : {},
3024
- title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
3025
- } }),
3247
+ selectRecipe: recipe => {
3248
+ if (recipe && data.forms.recipe && recipeChoiceIdentity(recipe) === recipeChoiceIdentity(data.forms.recipe)) return
3249
+ const saved = recipe ? data.recipeChoices.find(choice => recipeChoiceIdentity(choice.recipe) === recipeChoiceIdentity(recipe)) : null
3250
+ if (saved && !recipeChoiceFits(recipe, saved.parameters)) { set({ creationDraftStorageStatus: 'invalid' }); return }
3251
+ set({ creationDraftMissingRecipe: null, forms: { ...data.forms, recipe,
3252
+ recipeParameters: recipe ? saved ? copyRecipeParameters(saved.parameters)
3253
+ : Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, Array.isArray(parameter.default) ? [...parameter.default] : parameter.default])) : {},
3254
+ title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
3255
+ } })
3256
+ },
3257
+ resetRecipe: () => {
3258
+ const recipe = data.forms.recipe
3259
+ if (recipe) set({ forms: { ...data.forms, recipeParameters: Object.fromEntries(recipe.parameters.map(parameter =>
3260
+ [parameter.id, Array.isArray(parameter.default) ? [...parameter.default] : parameter.default])) } })
3261
+ },
3026
3262
  setRecipeParameter: (id, value) => setIn('forms', 'recipeParameters', { ...data.forms.recipeParameters, [id]: value }),
3027
3263
  setCompareMode: (compareMode) => set({ compareMode }),
3028
3264
  pickCompare: (side, revision) => set(side === 'left'
@@ -3366,6 +3602,7 @@ window.__ModuleLoader__.load({
3366
3602
 
3367
3603
  createProject: async () => {
3368
3604
  if (data.busy.create) return
3605
+ if (data.creationDraftMissingRecipe) return
3369
3606
  if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
3370
3607
  const body = creationBody()
3371
3608
  const signature = creationSignature(body)
@@ -3373,6 +3610,45 @@ window.__ModuleLoader__.load({
3373
3610
  return submitCreation(creationAttempt)
3374
3611
  },
3375
3612
  retryCreation: () => data.busy.create || !creationAttempt ? undefined : submitCreation(creationAttempt),
3613
+ restoreCreationDraft: id => {
3614
+ if (data.busy.create || !creationDraftRoot) return
3615
+ const reading = readCreationDrafts(draftStorage, creationDraftRoot), source = reading.entries.find(entry => entry.id === id)
3616
+ if (!source?.draft) { set({ creationDraftStorageStatus: 'invalid' }); return }
3617
+ const saved = source.draft, ref = saved.forms.recipe
3618
+ if ((saved.recipeChoices || []).some(choice => {
3619
+ const available = data.recipeCatalog.recipes.find(recipe => recipeChoiceIdentity(recipe) === recipeChoiceIdentity(choice.recipe))
3620
+ return available && !recipeChoiceFits(available, choice.parameters)
3621
+ })) { set({ creationDraftStorageStatus: 'invalid' }); return }
3622
+ const recipe = ref ? data.recipeCatalog.recipes.find(recipe => recipe.id === ref.id && recipe.version === ref.version && recipe.digest === ref.digest) : null
3623
+ creationDraftId = newCreationKey(); restoredCreationSource = { id, raw: source.raw }; persistedCreationSignature = null
3624
+ creationAttempt = saved.attempt ? editorClone(saved.attempt) : null
3625
+ set({ view: 'projects', recipeChoices: editorClone(saved.recipeChoices || []), forms: { ...data.forms, ...editorClone(saved.forms), recipe: recipe || null },
3626
+ creationDraftMissingRecipe: ref && !recipe ? ref : null,
3627
+ creationWork: creationAttempt ? { status: 'error', title: creationAttempt.body.title, code: 'UI_FETCH_FAILED' } : null,
3628
+ notices: { ...data.notices, projects: { kind: 'creation', ok: true, message: t('projects.draftRestored') } } })
3629
+ // A closed page's unchanged record can be consumed after the copy
3630
+ // persisted. Live pages retain their independent saved record.
3631
+ if (saved.ownerActive === false && data.creationDraftStorageStatus === 'saved') {
3632
+ try {
3633
+ const sourceKey = creationDraftPrefix(creationDraftRoot) + id
3634
+ if (draftStorage.getItem(sourceKey) === source.raw) draftStorage.removeItem(sourceKey)
3635
+ refreshCreationDrafts(); notify()
3636
+ } catch { set({ creationDraftStorageStatus: 'unavailable' }) }
3637
+ }
3638
+ },
3639
+ deleteCreationDraft: id => {
3640
+ if (!creationDraftRoot || id === creationDraftId) return
3641
+ const entry = readCreationDrafts(draftStorage, creationDraftRoot).entries.find(entry => entry.id === id)
3642
+ if (!entry) return
3643
+ try { draftStorage.removeItem(creationDraftPrefix(creationDraftRoot) + id); refreshCreationDrafts(); notify() }
3644
+ catch { set({ creationDraftStorageStatus: 'unavailable' }) }
3645
+ },
3646
+ clearCreationDraft: () => {
3647
+ if (data.busy.create) return
3648
+ creationAttempt = null; restoredCreationSource = null; persistedCreationSignature = null
3649
+ set({ recipeChoices: [], forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, creationWork: null,
3650
+ creationDraftMissingRecipe: null, notices: { ...data.notices, projects: null } })
3651
+ },
3376
3652
 
3377
3653
  applyPatch: async () => {
3378
3654
  const projectId = data.activeProjectId
@@ -3516,10 +3792,16 @@ window.__ModuleLoader__.load({
3516
3792
  start() {
3517
3793
  if (running) return
3518
3794
  running = true
3795
+ reopenCreationDraft()
3796
+ draftLifecycle?.addEventListener?.('pagehide', closeCreationDraft)
3797
+ draftLifecycle?.addEventListener?.('pageshow', reopenCreationDraft)
3519
3798
  fileChoicesStopped = false
3520
3799
  void load()
3521
3800
  },
3522
3801
  stop() {
3802
+ closeCreationDraft()
3803
+ draftLifecycle?.removeEventListener?.('pagehide', closeCreationDraft)
3804
+ draftLifecycle?.removeEventListener?.('pageshow', reopenCreationDraft)
3523
3805
  fileChoicesStopped = true
3524
3806
  data = { ...data, fileChoiceGeneration: data.fileChoiceGeneration + 1 }
3525
3807
  running = false
@@ -3549,6 +3831,35 @@ window.__ModuleLoader__.load({
3549
3831
  // touches React, the DOM, or the network.
3550
3832
 
3551
3833
  /** 项目: the list, the create form, and the selected project's summary. */
3834
+ function CreationDraftsView({ state, actions }) {
3835
+ const messages = { saved: t('projects.draftSaved'), unavailable: t('projects.draftUnavailable'), full: t('projects.draftFull'),
3836
+ tooLarge: t('projects.draftTooLarge'), invalid: t('projects.draftInvalid') }
3837
+ const hasInputs = Boolean(state.forms.title || state.forms.goal || state.forms.recipe || state.creationWork || state.creationDraftMissingRecipe || state.recipeChoices.length || Object.keys(state.forms.recipeParameters).length)
3838
+ return el('div', { 'data-creation-drafts': true },
3839
+ el('p', { className: 'db-muted' }, t('projects.draftHint')),
3840
+ (hasInputs || state.creationDraftStorageStatus !== 'saved') && messages[state.creationDraftStorageStatus] ? el('p', {
3841
+ className: state.creationDraftStorageStatus === 'saved' ? 'db-muted' : 'db-error',
3842
+ 'data-creation-draft-status': state.creationDraftStorageStatus,
3843
+ }, messages[state.creationDraftStorageStatus]) : null,
3844
+ state.creationDraftMissingRecipe ? el('div', { 'data-creation-draft-recipe-missing': true },
3845
+ el('p', { className: 'db-error' }, t('projects.draftMissingRecipe', state.creationDraftMissingRecipe)),
3846
+ el('details', null, el('summary', null, t('projects.draftOldParameters')),
3847
+ el('pre', { className: 'db-pre' }, JSON.stringify(state.forms.recipeParameters, null, 2)))) : null,
3848
+ hasInputs ? Button({ action: 'clear-creation-draft', disabled: state.busy.create, onClick: actions.clearCreationDraft, children: t('projects.draftClear') }) : null,
3849
+ state.creationDrafts.length ? el('div', { style: { marginTop: '8px' } },
3850
+ el('h5', null, t('projects.drafts')),
3851
+ ...state.creationDrafts.map(draft => el('div', { className: 'db-card', 'data-creation-draft': draft.id },
3852
+ el('strong', null, draft.invalid ? t('projects.damagedDraft') : draft.title),
3853
+ draft.savedAt ? el('span', { className: 'db-muted', style: { marginLeft: '8px' } }, formatTime(draft.savedAt)) : null,
3854
+ draft.uncertain ? el('p', { className: 'db-muted' }, t('projects.draftUncertain')) : null,
3855
+ el('div', { className: 'db-inline' },
3856
+ Button({ action: `restore-creation-draft:${draft.id}`, disabled: state.busy.create || draft.invalid,
3857
+ onClick: () => actions.restoreCreationDraft(draft.id), children: t('projects.draftRestore') }),
3858
+ Button({ action: `delete-creation-draft:${draft.id}`, onClick: () => actions.deleteCreationDraft(draft.id), children: t('projects.draftDelete') })),
3859
+ ))) : null,
3860
+ )
3861
+ }
3862
+
3552
3863
  function ProjectsView(ctx) {
3553
3864
  const state = ctx.state
3554
3865
  const actions = ctx.actions
@@ -3579,6 +3890,7 @@ window.__ModuleLoader__.load({
3579
3890
 
3580
3891
  el('div', { className: 'db-card db-project-create' },
3581
3892
  el('h4', null, t('projects.create')),
3893
+ CreationDraftsView(ctx),
3582
3894
  !state.projects.length ? el('p', { className: 'db-muted' }, t('projects.startHint')) : null,
3583
3895
  RecipesView(ctx),
3584
3896
  el('div', { className: 'db-create-footer' },
@@ -3602,7 +3914,7 @@ window.__ModuleLoader__.load({
3602
3914
  Button({
3603
3915
  tone: 'primary',
3604
3916
  action: 'create-project',
3605
- disabled: state.busy.create || state.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
3917
+ disabled: state.busy.create || Boolean(state.creationDraftMissingRecipe) || state.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
3606
3918
  onClick: actions.createProject,
3607
3919
  children: state.busy.create ? (state.forms.recipe ? t('recipes.creatingPreview') : t('projects.creating')) : (state.forms.recipe ? t('recipes.createPreview') : t('projects.createButton')),
3608
3920
  }),
@@ -3692,7 +4004,7 @@ window.__ModuleLoader__.load({
3692
4004
  })),
3693
4005
  selected ? el('fieldset', { className: 'db-recipe-parameters' },
3694
4006
  el('legend', null, t('recipes.parameters')),
3695
- Button({ action: 'reset-recipe', onClick: () => actions.selectRecipe(selected), children: t('recipes.reset') }),
4007
+ Button({ action: 'reset-recipe', onClick: actions.resetRecipe, children: t('recipes.reset') }),
3696
4008
  el('div', { className: 'db-inline', style: { flexWrap: 'wrap' } }, selected.parameters.map(parameter =>
3697
4009
  el('label', { key: parameter.id, style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
3698
4010
  parameter.title,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.3.5"
22
+ "@deepblend/dsh-blender-contracts": "0.3.7"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {