@deepblend/dsh-blender-ui 0.3.5 → 0.3.6

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 +258 -8
  2. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -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': '查看环境检查指南',
@@ -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,75 @@ 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
+ function creationDraftJson(value) {
1403
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return
1404
+ if (typeof value === 'number' && Number.isFinite(value)) return
1405
+ if (!value || typeof value !== 'object') throw new Error('Draft input is not JSON')
1406
+ for (const item of Object.values(value)) creationDraftJson(item)
1407
+ }
1408
+ function creationDraftForms(forms) {
1409
+ const snapshot = { title: forms.title, goal: forms.goal,
1410
+ recipe: creationRecipeReference(forms.recipe), recipeParameters: forms.recipeParameters }
1411
+ creationDraftJson(snapshot)
1412
+ return editorClone(snapshot)
1413
+ }
1414
+ const creationRequestBody = forms => editorClone({ title: forms.title.trim(),
1415
+ goal: forms.goal.length > 0 ? forms.goal : undefined, renderPreview: Boolean(forms.recipe),
1416
+ recipe: forms.recipe ? { ...creationRecipeReference(forms.recipe), parameters: forms.recipeParameters } : undefined })
1417
+ function validCreationRecipeReference(recipe) {
1418
+ return recipe === null || Boolean(recipe && typeof recipe === 'object' && !Array.isArray(recipe)
1419
+ && typeof recipe.id === 'string' && recipe.id && typeof recipe.version === 'string' && recipe.version
1420
+ && /^[a-f0-9]{64}$/.test(recipe.digest || ''))
1421
+ }
1422
+ function parseCreationDraft(raw, root, id) {
1423
+ try {
1424
+ if (typeof raw !== 'string' || new Blob([raw]).size > CREATION_DRAFT_BYTES) return null
1425
+ const draft = JSON.parse(raw), forms = draft.forms
1426
+ creationDraftJson(forms)
1427
+ if (draft.schemaVersion !== CREATION_DRAFT_VERSION || draft.projectsRoot !== root || draft.id !== id
1428
+ || (draft.ownerActive !== undefined && typeof draft.ownerActive !== 'boolean')
1429
+ || !Number.isFinite(Date.parse(draft.savedAt)) || !forms || typeof forms.title !== 'string'
1430
+ || typeof forms.goal !== 'string' || !validCreationRecipeReference(forms.recipe)
1431
+ || Object.keys(forms).some(key => !['title', 'goal', 'recipe', 'recipeParameters'].includes(key))
1432
+ || !forms.recipeParameters || typeof forms.recipeParameters !== 'object' || Array.isArray(forms.recipeParameters)) return null
1433
+ const attempt = draft.attempt
1434
+ if (attempt !== null) {
1435
+ const body = attempt?.body
1436
+ creationDraftJson(body)
1437
+ if (!attempt || typeof attempt.key !== 'string' || !attempt.key.trim() || attempt.key.length > 128 || /[\x00-\x1f\x7f]/.test(attempt.key)
1438
+ || !body || typeof body.title !== 'string' || !body.title.trim() || typeof body.renderPreview !== 'boolean'
1439
+ || (body.goal !== undefined && typeof body.goal !== 'string')
1440
+ || (body.recipe !== undefined && (!body.recipe || !validCreationRecipeReference(body.recipe)
1441
+ || !body.recipe.parameters || typeof body.recipe.parameters !== 'object' || Array.isArray(body.recipe.parameters)))
1442
+ || body.renderPreview !== Boolean(body.recipe)
1443
+ || Object.keys(body).some(key => !['title', 'goal', 'recipe', 'renderPreview'].includes(key))
1444
+ || attempt.signature !== creationSignature(body)) return null
1445
+ }
1446
+ return draft
1447
+ } catch { return null }
1448
+ }
1449
+ function creationDraftStorage(settings) {
1450
+ try { return settings.creationDraftStorage ?? globalThis.localStorage } catch { return undefined }
1451
+ }
1452
+ function readCreationDrafts(storage, root) {
1453
+ if (!root) return { entries: [], available: false }
1454
+ try {
1455
+ if (!storage || typeof storage.key !== 'function' || !Number.isSafeInteger(storage.length)) return { entries: [], available: false }
1456
+ const prefix = creationDraftPrefix(root), entries = []
1457
+ for (let index = 0; index < storage.length; index++) {
1458
+ const key = storage.key(index)
1459
+ if (typeof key !== 'string' || !key.startsWith(prefix)) continue
1460
+ const id = key.slice(prefix.length), raw = storage.getItem(key), draft = parseCreationDraft(raw, root, id)
1461
+ entries.push({ id, raw, draft })
1462
+ }
1463
+ entries.sort((a, b) => String(b.draft?.savedAt || '').localeCompare(String(a.draft?.savedAt || '')))
1464
+ return { entries, available: true }
1465
+ } catch { return { entries: [], available: false } }
1466
+ }
1366
1467
  function creationErrorMessage(code) {
1367
1468
  if (code === 'UI_FETCH_FAILED' || /^HTTP_/.test(code)) return t('projects.replyMissing')
1368
1469
  if (code === 'BLENDER_NOT_FOUND' || code === 'RUNTIME_UNAVAILABLE') return t('projects.environmentFailed')
@@ -2480,6 +2581,9 @@ window.__ModuleLoader__.load({
2480
2581
  imageDownloads: {},
2481
2582
  creationWork: null,
2482
2583
  creationRequestProtocol: null,
2584
+ creationDrafts: [],
2585
+ creationDraftStorageStatus: null,
2586
+ creationDraftMissingRecipe: null,
2483
2587
  inspectionForms: {},
2484
2588
  inspectionWork: {},
2485
2589
  inspectionRevisions: {},
@@ -2541,6 +2645,11 @@ window.__ModuleLoader__.load({
2541
2645
  let live = false
2542
2646
  let loadSequence = 0
2543
2647
  let creationAttempt = null
2648
+ const draftStorage = creationDraftStorage(settings)
2649
+ let creationDraftId = newCreationKey(), creationDraftRoot = null, persistedCreationSignature = null
2650
+ let restoredCreationSource = null, creationDraftWriting = false
2651
+ let creationDraftClosed = false
2652
+ const draftLifecycle = settings.creationDraftLifecycle ?? globalThis.window
2544
2653
  let fileChoiceSignature = null, fileChoicesStopped = false
2545
2654
  const photographyControllers = new Map()
2546
2655
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
@@ -2584,6 +2693,7 @@ window.__ModuleLoader__.load({
2584
2693
  const assetWork = (projectId, changes) => set({ assetWork: { ...data.assetWork, [projectId]: { ...data.assetWork[projectId], ...changes } } })
2585
2694
 
2586
2695
  const notify = () => {
2696
+ persistCreationDraft()
2587
2697
  const signature = JSON.stringify([data.activeProjectId, data.selected?.scene?.revision, data.view, ...FILE_FIELDS.map(field => fileChoiceAllowed(data, field))])
2588
2698
  if (signature !== fileChoiceSignature) { data.fileChoiceGeneration += 1; fileChoiceSignature = signature }
2589
2699
  snapshot = { ...data, forms: { ...data.forms }, busy: { ...data.busy }, notices: { ...data.notices } }
@@ -2594,6 +2704,56 @@ window.__ModuleLoader__.load({
2594
2704
  /** Change one nested table. @param {'forms'|'busy'|'notices'} table @param {string} key @param {any} value */
2595
2705
  const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
2596
2706
 
2707
+ function refreshCreationDrafts() {
2708
+ const reading = readCreationDrafts(draftStorage, creationDraftRoot)
2709
+ data.creationDrafts = reading.entries.filter(entry => entry.id !== creationDraftId).map(entry => ({ id: entry.id,
2710
+ title: entry.draft?.forms.title || t('projects.untitledDraft'), savedAt: entry.draft?.savedAt || null,
2711
+ uncertain: Boolean(entry.draft?.attempt), invalid: !entry.draft }))
2712
+ return reading
2713
+ }
2714
+ function persistCreationDraft() {
2715
+ if (creationDraftClosed || creationDraftWriting || !creationDraftRoot || data.projectsRoot !== creationDraftRoot) return
2716
+ creationDraftWriting = true
2717
+ try {
2718
+ const forms = { ...creationDraftForms(data.forms), recipe: creationRecipeReference(data.forms.recipe) || data.creationDraftMissingRecipe }, signature = JSON.stringify([forms, creationAttempt])
2719
+ if (signature === persistedCreationSignature) return
2720
+ const reading = refreshCreationDrafts()
2721
+ if (!reading.available) { data.creationDraftStorageStatus = 'unavailable'; return }
2722
+ const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2723
+ if (!forms.title && !forms.goal && !forms.recipe && !Object.keys(forms.recipeParameters).length && !creationAttempt) {
2724
+ draftStorage.removeItem(key); data.creationDraftStorageStatus = null
2725
+ } else {
2726
+ if (!reading.entries.some(entry => entry.id === creationDraftId) && reading.entries.length >= 20) {
2727
+ data.creationDraftStorageStatus = 'full'; return
2728
+ }
2729
+ const raw = JSON.stringify({ schemaVersion: CREATION_DRAFT_VERSION, projectsRoot: creationDraftRoot,
2730
+ id: creationDraftId, ownerActive: true, savedAt: new Date().toISOString(), forms, attempt: creationAttempt })
2731
+ if (new Blob([raw]).size > CREATION_DRAFT_BYTES) { data.creationDraftStorageStatus = 'tooLarge'; return }
2732
+ draftStorage.setItem(key, raw)
2733
+ if (draftStorage.getItem(key) !== raw) throw new Error('Draft write did not persist')
2734
+ data.creationDraftStorageStatus = 'saved'
2735
+ }
2736
+ persistedCreationSignature = signature
2737
+ refreshCreationDrafts()
2738
+ } catch { data.creationDraftStorageStatus = 'unavailable' }
2739
+ finally { creationDraftWriting = false }
2740
+ }
2741
+ function closeCreationDraft() {
2742
+ if (creationDraftClosed) return
2743
+ persistCreationDraft()
2744
+ creationDraftClosed = true
2745
+ if (!creationDraftRoot) return
2746
+ try {
2747
+ const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2748
+ const saved = parseCreationDraft(draftStorage?.getItem(key), creationDraftRoot, creationDraftId)
2749
+ if (saved) draftStorage.setItem(key, JSON.stringify({ ...saved, ownerActive: false }))
2750
+ } catch { /* Current inputs were already saved or visibly reported as unsaved. */ }
2751
+ }
2752
+ function reopenCreationDraft() {
2753
+ creationDraftClosed = false; persistedCreationSignature = null
2754
+ persistCreationDraft()
2755
+ }
2756
+
2597
2757
  /** The project every per-project route is addressed to. */
2598
2758
  const target = () => data.projectId ?? data.projects.find(project => !project.creationPending)?.projectId ?? null
2599
2759
 
@@ -2668,6 +2828,17 @@ window.__ModuleLoader__.load({
2668
2828
  const next = { ...comparison, afterPreviews: editorClone(afterPreviews) }
2669
2829
  patch.editorComparison = { ...next, ...editorPreviewPair(next), resolved: afterPreviews.length > 0 }
2670
2830
  }
2831
+ if (patch.projectsRoot !== creationDraftRoot) {
2832
+ // Switching stores cannot carry an old store's request identity into a new one.
2833
+ if (creationDraftRoot !== null) {
2834
+ creationAttempt = null; restoredCreationSource = null; creationDraftId = newCreationKey()
2835
+ patch.creationWork = null; patch.creationDraftMissingRecipe = null
2836
+ patch.forms = { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }
2837
+ }
2838
+ creationDraftRoot = typeof patch.projectsRoot === 'string' && patch.projectsRoot ? patch.projectsRoot : null
2839
+ persistedCreationSignature = null
2840
+ }
2841
+ refreshCreationDrafts()
2671
2842
  set(patch)
2672
2843
  arm()
2673
2844
  }
@@ -2684,12 +2855,7 @@ window.__ModuleLoader__.load({
2684
2855
  /** A write just happened: re-run every read, including the view-specific ones. */
2685
2856
  const reload = () => { tick += 1; void load() }
2686
2857
 
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 })
2858
+ const creationBody = () => creationRequestBody(data.forms)
2693
2859
 
2694
2860
  async function submitCreation(attempt) {
2695
2861
  if (data.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL) {
@@ -2698,17 +2864,29 @@ window.__ModuleLoader__.load({
2698
2864
  return
2699
2865
  }
2700
2866
  setIn('busy', 'create', true)
2867
+ const attemptRoot = creationDraftRoot
2701
2868
  set({ creationWork: { status: 'busy', title: attempt.body.title }, previewResult: null })
2702
2869
  setIn('notices', 'projects', null)
2703
2870
  setIn('notices', 'preview', null)
2704
2871
  const outcome = await postJson(fetchImpl, ROUTES.projects, { ...editorClone(attempt.body), creationKey: attempt.key })
2705
2872
  setIn('busy', 'create', false)
2873
+ if (attemptRoot !== creationDraftRoot) { reload(); return }
2706
2874
  if (outcome.ok) {
2707
2875
  const sameInputs = creationSignature(creationBody()) === attempt.signature
2708
2876
  set({ ...(sameInputs ? { forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} } } : {}),
2709
2877
  creationWork: null, projectId: outcome.payload.project.projectId,
2710
2878
  ...(attempt.body.renderPreview ? { view: 'preview', compareMode: 'result' } : {}) })
2711
2879
  if (creationAttempt === attempt) creationAttempt = null
2880
+ if (restoredCreationSource && creationDraftRoot) {
2881
+ try {
2882
+ const sourceKey = creationDraftPrefix(creationDraftRoot) + restoredCreationSource.id
2883
+ const source = parseCreationDraft(restoredCreationSource.raw, creationDraftRoot, restoredCreationSource.id)
2884
+ if (source && creationSignature(creationRequestBody(source.forms)) === attempt.signature
2885
+ && draftStorage?.getItem(sourceKey) === restoredCreationSource.raw) draftStorage.removeItem(sourceKey)
2886
+ } catch { data.creationDraftStorageStatus = 'unavailable' }
2887
+ }
2888
+ restoredCreationSource = null
2889
+ persistedCreationSignature = null
2712
2890
  setIn('notices', attempt.body.renderPreview ? 'preview' : 'projects', { kind: 'creation', projectId: outcome.payload.project.projectId, ok: true, message: outcome.payload.project.creationReplayed
2713
2891
  ? t('projects.recovered', { id: outcome.payload.project.projectId })
2714
2892
  : t('projects.created', { id: outcome.payload.project.projectId }) })
@@ -3019,7 +3197,7 @@ window.__ModuleLoader__.load({
3019
3197
  } else assetWork(projectId, { error: `${outcome.error.code}: ${outcome.error.message}` })
3020
3198
  reload()
3021
3199
  },
3022
- selectRecipe: recipe => set({ forms: { ...data.forms, recipe,
3200
+ selectRecipe: recipe => set({ creationDraftMissingRecipe: null, forms: { ...data.forms, recipe,
3023
3201
  recipeParameters: recipe ? Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, parameter.default])) : {},
3024
3202
  title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
3025
3203
  } }),
@@ -3366,6 +3544,7 @@ window.__ModuleLoader__.load({
3366
3544
 
3367
3545
  createProject: async () => {
3368
3546
  if (data.busy.create) return
3547
+ if (data.creationDraftMissingRecipe) return
3369
3548
  if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
3370
3549
  const body = creationBody()
3371
3550
  const signature = creationSignature(body)
@@ -3373,6 +3552,41 @@ window.__ModuleLoader__.load({
3373
3552
  return submitCreation(creationAttempt)
3374
3553
  },
3375
3554
  retryCreation: () => data.busy.create || !creationAttempt ? undefined : submitCreation(creationAttempt),
3555
+ restoreCreationDraft: id => {
3556
+ if (data.busy.create || !creationDraftRoot) return
3557
+ const reading = readCreationDrafts(draftStorage, creationDraftRoot), source = reading.entries.find(entry => entry.id === id)
3558
+ if (!source?.draft) { set({ creationDraftStorageStatus: 'invalid' }); return }
3559
+ const saved = source.draft, ref = saved.forms.recipe
3560
+ const recipe = ref ? data.recipeCatalog.recipes.find(recipe => recipe.id === ref.id && recipe.version === ref.version && recipe.digest === ref.digest) : null
3561
+ creationDraftId = newCreationKey(); restoredCreationSource = { id, raw: source.raw }; persistedCreationSignature = null
3562
+ creationAttempt = saved.attempt ? editorClone(saved.attempt) : null
3563
+ set({ view: 'projects', forms: { ...data.forms, ...editorClone(saved.forms), recipe: recipe || null },
3564
+ creationDraftMissingRecipe: ref && !recipe ? ref : null,
3565
+ creationWork: creationAttempt ? { status: 'error', title: creationAttempt.body.title, code: 'UI_FETCH_FAILED' } : null,
3566
+ notices: { ...data.notices, projects: { kind: 'creation', ok: true, message: t('projects.draftRestored') } } })
3567
+ // A closed page's unchanged record can be consumed after the copy
3568
+ // persisted. Live pages retain their independent saved record.
3569
+ if (saved.ownerActive === false && data.creationDraftStorageStatus === 'saved') {
3570
+ try {
3571
+ const sourceKey = creationDraftPrefix(creationDraftRoot) + id
3572
+ if (draftStorage.getItem(sourceKey) === source.raw) draftStorage.removeItem(sourceKey)
3573
+ refreshCreationDrafts(); notify()
3574
+ } catch { set({ creationDraftStorageStatus: 'unavailable' }) }
3575
+ }
3576
+ },
3577
+ deleteCreationDraft: id => {
3578
+ if (!creationDraftRoot || id === creationDraftId) return
3579
+ const entry = readCreationDrafts(draftStorage, creationDraftRoot).entries.find(entry => entry.id === id)
3580
+ if (!entry) return
3581
+ try { draftStorage.removeItem(creationDraftPrefix(creationDraftRoot) + id); refreshCreationDrafts(); notify() }
3582
+ catch { set({ creationDraftStorageStatus: 'unavailable' }) }
3583
+ },
3584
+ clearCreationDraft: () => {
3585
+ if (data.busy.create) return
3586
+ creationAttempt = null; restoredCreationSource = null; persistedCreationSignature = null
3587
+ set({ forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, creationWork: null,
3588
+ creationDraftMissingRecipe: null, notices: { ...data.notices, projects: null } })
3589
+ },
3376
3590
 
3377
3591
  applyPatch: async () => {
3378
3592
  const projectId = data.activeProjectId
@@ -3516,10 +3730,16 @@ window.__ModuleLoader__.load({
3516
3730
  start() {
3517
3731
  if (running) return
3518
3732
  running = true
3733
+ reopenCreationDraft()
3734
+ draftLifecycle?.addEventListener?.('pagehide', closeCreationDraft)
3735
+ draftLifecycle?.addEventListener?.('pageshow', reopenCreationDraft)
3519
3736
  fileChoicesStopped = false
3520
3737
  void load()
3521
3738
  },
3522
3739
  stop() {
3740
+ closeCreationDraft()
3741
+ draftLifecycle?.removeEventListener?.('pagehide', closeCreationDraft)
3742
+ draftLifecycle?.removeEventListener?.('pageshow', reopenCreationDraft)
3523
3743
  fileChoicesStopped = true
3524
3744
  data = { ...data, fileChoiceGeneration: data.fileChoiceGeneration + 1 }
3525
3745
  running = false
@@ -3549,6 +3769,35 @@ window.__ModuleLoader__.load({
3549
3769
  // touches React, the DOM, or the network.
3550
3770
 
3551
3771
  /** 项目: the list, the create form, and the selected project's summary. */
3772
+ function CreationDraftsView({ state, actions }) {
3773
+ const messages = { saved: t('projects.draftSaved'), unavailable: t('projects.draftUnavailable'), full: t('projects.draftFull'),
3774
+ tooLarge: t('projects.draftTooLarge'), invalid: t('projects.draftInvalid') }
3775
+ const hasInputs = Boolean(state.forms.title || state.forms.goal || state.forms.recipe || state.creationWork || state.creationDraftMissingRecipe || Object.keys(state.forms.recipeParameters).length)
3776
+ return el('div', { 'data-creation-drafts': true },
3777
+ el('p', { className: 'db-muted' }, t('projects.draftHint')),
3778
+ (hasInputs || state.creationDraftStorageStatus !== 'saved') && messages[state.creationDraftStorageStatus] ? el('p', {
3779
+ className: state.creationDraftStorageStatus === 'saved' ? 'db-muted' : 'db-error',
3780
+ 'data-creation-draft-status': state.creationDraftStorageStatus,
3781
+ }, messages[state.creationDraftStorageStatus]) : null,
3782
+ state.creationDraftMissingRecipe ? el('div', { 'data-creation-draft-recipe-missing': true },
3783
+ el('p', { className: 'db-error' }, t('projects.draftMissingRecipe', state.creationDraftMissingRecipe)),
3784
+ el('details', null, el('summary', null, t('projects.draftOldParameters')),
3785
+ el('pre', { className: 'db-pre' }, JSON.stringify(state.forms.recipeParameters, null, 2)))) : null,
3786
+ hasInputs ? Button({ action: 'clear-creation-draft', disabled: state.busy.create, onClick: actions.clearCreationDraft, children: t('projects.draftClear') }) : null,
3787
+ state.creationDrafts.length ? el('div', { style: { marginTop: '8px' } },
3788
+ el('h5', null, t('projects.drafts')),
3789
+ ...state.creationDrafts.map(draft => el('div', { className: 'db-card', 'data-creation-draft': draft.id },
3790
+ el('strong', null, draft.invalid ? t('projects.damagedDraft') : draft.title),
3791
+ draft.savedAt ? el('span', { className: 'db-muted', style: { marginLeft: '8px' } }, formatTime(draft.savedAt)) : null,
3792
+ draft.uncertain ? el('p', { className: 'db-muted' }, t('projects.draftUncertain')) : null,
3793
+ el('div', { className: 'db-inline' },
3794
+ Button({ action: `restore-creation-draft:${draft.id}`, disabled: state.busy.create || draft.invalid,
3795
+ onClick: () => actions.restoreCreationDraft(draft.id), children: t('projects.draftRestore') }),
3796
+ Button({ action: `delete-creation-draft:${draft.id}`, onClick: () => actions.deleteCreationDraft(draft.id), children: t('projects.draftDelete') })),
3797
+ ))) : null,
3798
+ )
3799
+ }
3800
+
3552
3801
  function ProjectsView(ctx) {
3553
3802
  const state = ctx.state
3554
3803
  const actions = ctx.actions
@@ -3579,6 +3828,7 @@ window.__ModuleLoader__.load({
3579
3828
 
3580
3829
  el('div', { className: 'db-card db-project-create' },
3581
3830
  el('h4', null, t('projects.create')),
3831
+ CreationDraftsView(ctx),
3582
3832
  !state.projects.length ? el('p', { className: 'db-muted' }, t('projects.startHint')) : null,
3583
3833
  RecipesView(ctx),
3584
3834
  el('div', { className: 'db-create-footer' },
@@ -3602,7 +3852,7 @@ window.__ModuleLoader__.load({
3602
3852
  Button({
3603
3853
  tone: 'primary',
3604
3854
  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),
3855
+ 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
3856
  onClick: actions.createProject,
3607
3857
  children: state.busy.create ? (state.forms.recipe ? t('recipes.creatingPreview') : t('projects.creating')) : (state.forms.recipe ? t('recipes.createPreview') : t('projects.createButton')),
3608
3858
  }),
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.6",
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.6"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {