@deepblend/dsh-blender-ui 0.3.6 → 0.3.8

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 +146 -30
  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': '选中的配方已更新或不可用,请重新选择。',
@@ -603,7 +603,9 @@ window.__ModuleLoader__.load({
603
603
  'visual.noSecondOpinion': '没有第二意见。',
604
604
  'visual.none': '这个 revision 还没有视觉评审。可以用 blender_visual_review 跑一次。',
605
605
  'revisions.current': '当前',
606
- 'revisions.compare': '对比',
606
+ 'revisions.compare': '与当前版对比',
607
+ 'revisions.compareCurrent': '与另一版对比',
608
+ 'revisions.compareHint': '选择已保存版本与当前版对比;选择当前版会对比相邻的另一版。需要至少两版,查看不会改动作品。',
607
609
  'revisions.restore': '恢复',
608
610
  'host.reading': '读取 Host 状态…',
609
611
  'diagnostics.exportHint': '导出一份可以附在问题里的诊断信息(版本、配置、项目与任务的摘要)',
@@ -1043,7 +1045,7 @@ window.__ModuleLoader__.load({
1043
1045
  'recipes.select': 'Use this recipe',
1044
1046
  'recipes.selected': 'Selected',
1045
1047
  'recipes.parameters': 'Customize recipe',
1046
- 'recipes.previewNote': 'Images show default parameters. Creating a project renders your settings; time depends on your device.',
1048
+ '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.',
1047
1049
  'recipes.createPreview': 'Create and preview',
1048
1050
  'recipes.creatingPreview': 'Creating and rendering…',
1049
1051
  'recipes.stale': 'The selected recipe changed or is unavailable. Please select it again.',
@@ -1168,7 +1170,9 @@ window.__ModuleLoader__.load({
1168
1170
  'visual.noSecondOpinion': 'No second opinion.',
1169
1171
  'visual.none': 'This revision has no visual review yet. Run one with blender_visual_review.',
1170
1172
  'revisions.current': 'current',
1171
- 'revisions.compare': 'compare',
1173
+ 'revisions.compare': 'Compare with current',
1174
+ 'revisions.compareCurrent': 'Compare with another version',
1175
+ 'revisions.compareHint': 'Compare a saved version with the current one. The current version uses its nearest other version. At least two versions are needed; viewing does not change the project.',
1172
1176
  'revisions.restore': 'restore',
1173
1177
  'host.reading': 'Reading Host state…',
1174
1178
  'diagnostics.exportHint': 'Export diagnostics you can attach to a bug report (versions, config, a summary of projects and jobs)',
@@ -1399,6 +1403,34 @@ window.__ModuleLoader__.load({
1399
1403
  const CREATION_DRAFT_BYTES = 262144
1400
1404
  const creationDraftPrefix = root => `${CREATION_DRAFT_VERSION}:${encodeURIComponent(root)}:`
1401
1405
  const creationRecipeReference = recipe => recipe ? { id: recipe.id, version: recipe.version, digest: recipe.digest } : null
1406
+ const recipeChoiceIdentity = recipe => creationSignature(creationRecipeReference(recipe))
1407
+ const copyRecipeParameters = values => Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]))
1408
+ const rememberRecipeChoice = (choices, recipe, parameters) => !recipe ? choices : [
1409
+ ...choices.filter(choice => recipeChoiceIdentity(choice.recipe) !== recipeChoiceIdentity(recipe)),
1410
+ { recipe: creationRecipeReference(recipe), parameters: copyRecipeParameters(parameters) },
1411
+ ]
1412
+ function validRecipeChoices(choices) {
1413
+ if (choices === undefined) return true // Existing 0.3.6 browser drafts contain only their selected recipe.
1414
+ if (!Array.isArray(choices)) return false
1415
+ const seen = new Set()
1416
+ return choices.every(choice => {
1417
+ if (!choice || !choice.recipe || !validCreationRecipeReference(choice.recipe)
1418
+ || Object.keys(choice).some(key => !['recipe', 'parameters'].includes(key))
1419
+ || !choice.parameters || typeof choice.parameters !== 'object' || Array.isArray(choice.parameters)) return false
1420
+ const key = recipeChoiceIdentity(choice.recipe)
1421
+ if (seen.has(key)) return false
1422
+ seen.add(key)
1423
+ return Object.values(choice.parameters).every(value => value === '' || Number.isFinite(value)
1424
+ || Array.isArray(value) && value.length === 3 && value.every(v => Number.isFinite(v) && v >= 0 && v <= 1))
1425
+ })
1426
+ }
1427
+ function recipeChoiceFits(recipe, parameters) {
1428
+ return Object.keys(parameters).length === recipe.parameters.length && recipe.parameters.every(parameter => {
1429
+ const value = parameters[parameter.id]
1430
+ return parameter.type === 'color' ? Array.isArray(value) && value.length === 3 && value.every(v => Number.isFinite(v) && v >= 0 && v <= 1)
1431
+ : value === '' || Number.isFinite(value)
1432
+ })
1433
+ }
1402
1434
  function creationDraftJson(value) {
1403
1435
  if (value === null || typeof value === 'string' || typeof value === 'boolean') return
1404
1436
  if (typeof value === 'number' && Number.isFinite(value)) return
@@ -1429,7 +1461,8 @@ window.__ModuleLoader__.load({
1429
1461
  || !Number.isFinite(Date.parse(draft.savedAt)) || !forms || typeof forms.title !== 'string'
1430
1462
  || typeof forms.goal !== 'string' || !validCreationRecipeReference(forms.recipe)
1431
1463
  || Object.keys(forms).some(key => !['title', 'goal', 'recipe', 'recipeParameters'].includes(key))
1432
- || !forms.recipeParameters || typeof forms.recipeParameters !== 'object' || Array.isArray(forms.recipeParameters)) return null
1464
+ || !forms.recipeParameters || typeof forms.recipeParameters !== 'object' || Array.isArray(forms.recipeParameters)
1465
+ || !validRecipeChoices(draft.recipeChoices)) return null
1433
1466
  const attempt = draft.attempt
1434
1467
  if (attempt !== null) {
1435
1468
  const body = attempt?.body
@@ -2607,6 +2640,7 @@ window.__ModuleLoader__.load({
2607
2640
  editorLastEdits: {},
2608
2641
  editorComparison: null,
2609
2642
  patchDrafts: {},
2643
+ recipeChoices: [],
2610
2644
  forms: { title: '', goal: '', recipe: null, recipeParameters: {}, patch: null, frameStart: '', frameEnd: '', profile: 'preview' },
2611
2645
  busy: { create: false, patch: false, editor: false, render: false, restore: false },
2612
2646
  notices: { projects: null, scene: null, preview: null, jobs: null, revisions: null },
@@ -2627,6 +2661,22 @@ window.__ModuleLoader__.load({
2627
2661
  try { const value = storage?.getItem(APPEARANCE_KEY); return APPEARANCES.includes(value) ? value : 'system' } catch { return 'system' }
2628
2662
  }
2629
2663
 
2664
+ function previewRevisionPair(state) {
2665
+ const revisions = state.previews?.revisions || []
2666
+ const pick = wanted => revisions.some(entry => entry.revision === wanted) ? wanted : revisions.at(-1)?.revision || null
2667
+ return { left: pick(state.compareLeft || state.currentRevision), right: pick(state.compareRight || state.currentRevision) }
2668
+ }
2669
+ function revisionComparisonTarget(state, revision) {
2670
+ if (!state.activeProjectId) return null
2671
+ const saved = new Set((state.selected?.revisions || []).map(entry => entry.revision))
2672
+ const available = [...new Set((state.previews?.revisions || []).map(entry => entry.revision).filter(id => saved.has(id)))]
2673
+ .sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)))
2674
+ if (!available.includes(revision) || !available.includes(state.currentRevision)) return null
2675
+ const index = available.indexOf(revision)
2676
+ const left = revision === state.currentRevision ? available[index - 1] || available[index + 1] : revision
2677
+ return left ? { left, right: state.currentRevision } : null
2678
+ }
2679
+
2630
2680
  function createWorkbenchStore(options) {
2631
2681
  const settings = options || {}
2632
2682
  const fetchImpl = settings.fetch ?? ((...args) => fetch(...args))
@@ -2644,6 +2694,7 @@ window.__ModuleLoader__.load({
2644
2694
  let tick = 0
2645
2695
  let live = false
2646
2696
  let loadSequence = 0
2697
+ let diffSequence = 0
2647
2698
  let creationAttempt = null
2648
2699
  const draftStorage = creationDraftStorage(settings)
2649
2700
  let creationDraftId = newCreationKey(), creationDraftRoot = null, persistedCreationSignature = null
@@ -2693,6 +2744,8 @@ window.__ModuleLoader__.load({
2693
2744
  const assetWork = (projectId, changes) => set({ assetWork: { ...data.assetWork, [projectId]: { ...data.assetWork[projectId], ...changes } } })
2694
2745
 
2695
2746
  const notify = () => {
2747
+ if (!creationDraftClosed) data.recipeChoices = rememberRecipeChoice(data.recipeChoices,
2748
+ data.forms.recipe || data.creationDraftMissingRecipe, data.forms.recipeParameters)
2696
2749
  persistCreationDraft()
2697
2750
  const signature = JSON.stringify([data.activeProjectId, data.selected?.scene?.revision, data.view, ...FILE_FIELDS.map(field => fileChoiceAllowed(data, field))])
2698
2751
  if (signature !== fileChoiceSignature) { data.fileChoiceGeneration += 1; fileChoiceSignature = signature }
@@ -2700,7 +2753,14 @@ window.__ModuleLoader__.load({
2700
2753
  for (const listener of [...listeners]) listener(snapshot)
2701
2754
  }
2702
2755
  /** Change the snapshot and tell everyone. @param {object} patch */
2703
- const set = (patch) => { data = { ...data, ...patch }; notify() }
2756
+ const set = (patch) => {
2757
+ if (['projectsRoot', 'activeProjectId', 'currentRevision', 'view', 'compareMode', 'compareLeft', 'compareRight']
2758
+ .some(key => Object.hasOwn(patch, key) && patch[key] !== data[key])) {
2759
+ diffSequence += 1
2760
+ patch = { ...patch, diff: null, diffError: null }
2761
+ }
2762
+ data = { ...data, ...patch }; notify()
2763
+ }
2704
2764
  /** Change one nested table. @param {'forms'|'busy'|'notices'} table @param {string} key @param {any} value */
2705
2765
  const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
2706
2766
 
@@ -2715,19 +2775,21 @@ window.__ModuleLoader__.load({
2715
2775
  if (creationDraftClosed || creationDraftWriting || !creationDraftRoot || data.projectsRoot !== creationDraftRoot) return
2716
2776
  creationDraftWriting = true
2717
2777
  try {
2718
- const forms = { ...creationDraftForms(data.forms), recipe: creationRecipeReference(data.forms.recipe) || data.creationDraftMissingRecipe }, signature = JSON.stringify([forms, creationAttempt])
2778
+ const forms = { ...creationDraftForms(data.forms), recipe: creationRecipeReference(data.forms.recipe) || data.creationDraftMissingRecipe }
2779
+ creationDraftJson(data.recipeChoices)
2780
+ const signature = JSON.stringify([forms, creationAttempt, data.recipeChoices])
2719
2781
  if (signature === persistedCreationSignature) return
2720
2782
  const reading = refreshCreationDrafts()
2721
2783
  if (!reading.available) { data.creationDraftStorageStatus = 'unavailable'; return }
2722
2784
  const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2723
- if (!forms.title && !forms.goal && !forms.recipe && !Object.keys(forms.recipeParameters).length && !creationAttempt) {
2785
+ if (!forms.title && !forms.goal && !forms.recipe && !Object.keys(forms.recipeParameters).length && !creationAttempt && !data.recipeChoices.length) {
2724
2786
  draftStorage.removeItem(key); data.creationDraftStorageStatus = null
2725
2787
  } else {
2726
2788
  if (!reading.entries.some(entry => entry.id === creationDraftId) && reading.entries.length >= 20) {
2727
2789
  data.creationDraftStorageStatus = 'full'; return
2728
2790
  }
2729
2791
  const raw = JSON.stringify({ schemaVersion: CREATION_DRAFT_VERSION, projectsRoot: creationDraftRoot,
2730
- id: creationDraftId, ownerActive: true, savedAt: new Date().toISOString(), forms, attempt: creationAttempt })
2792
+ id: creationDraftId, ownerActive: true, savedAt: new Date().toISOString(), forms, attempt: creationAttempt, recipeChoices: data.recipeChoices })
2731
2793
  if (new Blob([raw]).size > CREATION_DRAFT_BYTES) { data.creationDraftStorageStatus = 'tooLarge'; return }
2732
2794
  draftStorage.setItem(key, raw)
2733
2795
  if (draftStorage.getItem(key) !== raw) throw new Error('Draft write did not persist')
@@ -2832,7 +2894,7 @@ window.__ModuleLoader__.load({
2832
2894
  // Switching stores cannot carry an old store's request identity into a new one.
2833
2895
  if (creationDraftRoot !== null) {
2834
2896
  creationAttempt = null; restoredCreationSource = null; creationDraftId = newCreationKey()
2835
- patch.creationWork = null; patch.creationDraftMissingRecipe = null
2897
+ patch.creationWork = null; patch.creationDraftMissingRecipe = null; patch.recipeChoices = []
2836
2898
  patch.forms = { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }
2837
2899
  }
2838
2900
  creationDraftRoot = typeof patch.projectsRoot === 'string' && patch.projectsRoot ? patch.projectsRoot : null
@@ -2873,6 +2935,9 @@ window.__ModuleLoader__.load({
2873
2935
  if (attemptRoot !== creationDraftRoot) { reload(); return }
2874
2936
  if (outcome.ok) {
2875
2937
  const sameInputs = creationSignature(creationBody()) === attempt.signature
2938
+ if (attempt.body.recipe) data.recipeChoices = data.recipeChoices.filter(choice =>
2939
+ recipeChoiceIdentity(choice.recipe) !== recipeChoiceIdentity(attempt.body.recipe)
2940
+ || creationSignature(choice.parameters) !== creationSignature(attempt.body.recipe.parameters))
2876
2941
  set({ ...(sameInputs ? { forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} } } : {}),
2877
2942
  creationWork: null, projectId: outcome.payload.project.projectId,
2878
2943
  ...(attempt.body.renderPreview ? { view: 'preview', compareMode: 'result' } : {}) })
@@ -2882,7 +2947,17 @@ window.__ModuleLoader__.load({
2882
2947
  const sourceKey = creationDraftPrefix(creationDraftRoot) + restoredCreationSource.id
2883
2948
  const source = parseCreationDraft(restoredCreationSource.raw, creationDraftRoot, restoredCreationSource.id)
2884
2949
  if (source && creationSignature(creationRequestBody(source.forms)) === attempt.signature
2885
- && draftStorage?.getItem(sourceKey) === restoredCreationSource.raw) draftStorage.removeItem(sourceKey)
2950
+ && draftStorage?.getItem(sourceKey) === restoredCreationSource.raw) {
2951
+ const remaining = (source.recipeChoices || []).filter(choice => !attempt.body.recipe
2952
+ || recipeChoiceIdentity(choice.recipe) !== recipeChoiceIdentity(attempt.body.recipe)
2953
+ || creationSignature(choice.parameters) !== creationSignature(attempt.body.recipe.parameters))
2954
+ if (remaining.length) {
2955
+ const raw = JSON.stringify({ ...source, savedAt: new Date().toISOString(), attempt: null, recipeChoices: remaining,
2956
+ forms: { title: '', goal: '', recipe: null, recipeParameters: {} } })
2957
+ draftStorage.setItem(sourceKey, raw)
2958
+ if (draftStorage.getItem(sourceKey) !== raw) throw new Error('Draft write did not persist')
2959
+ } else draftStorage.removeItem(sourceKey)
2960
+ }
2886
2961
  } catch { data.creationDraftStorageStatus = 'unavailable' }
2887
2962
  }
2888
2963
  restoredCreationSource = null
@@ -3197,17 +3272,35 @@ window.__ModuleLoader__.load({
3197
3272
  } else assetWork(projectId, { error: `${outcome.error.code}: ${outcome.error.message}` })
3198
3273
  reload()
3199
3274
  },
3200
- selectRecipe: recipe => set({ creationDraftMissingRecipe: null, forms: { ...data.forms, recipe,
3201
- recipeParameters: recipe ? Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, parameter.default])) : {},
3202
- title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
3203
- } }),
3275
+ selectRecipe: recipe => {
3276
+ if (recipe && data.forms.recipe && recipeChoiceIdentity(recipe) === recipeChoiceIdentity(data.forms.recipe)) return
3277
+ const saved = recipe ? data.recipeChoices.find(choice => recipeChoiceIdentity(choice.recipe) === recipeChoiceIdentity(recipe)) : null
3278
+ if (saved && !recipeChoiceFits(recipe, saved.parameters)) { set({ creationDraftStorageStatus: 'invalid' }); return }
3279
+ set({ creationDraftMissingRecipe: null, forms: { ...data.forms, recipe,
3280
+ recipeParameters: recipe ? saved ? copyRecipeParameters(saved.parameters)
3281
+ : Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, Array.isArray(parameter.default) ? [...parameter.default] : parameter.default])) : {},
3282
+ title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
3283
+ } })
3284
+ },
3285
+ resetRecipe: () => {
3286
+ const recipe = data.forms.recipe
3287
+ if (recipe) set({ forms: { ...data.forms, recipeParameters: Object.fromEntries(recipe.parameters.map(parameter =>
3288
+ [parameter.id, Array.isArray(parameter.default) ? [...parameter.default] : parameter.default])) } })
3289
+ },
3204
3290
  setRecipeParameter: (id, value) => setIn('forms', 'recipeParameters', { ...data.forms.recipeParameters, [id]: value }),
3205
3291
  setCompareMode: (compareMode) => set({ compareMode }),
3206
3292
  pickCompare: (side, revision) => set(side === 'left'
3207
3293
  ? { compareLeft: revision, diff: null, diffError: null }
3208
3294
  : { compareRight: revision, diff: null, diffError: null }),
3209
- /** Hand off from 版本 to 预览对比 with that revision on the left. */
3210
- compareFrom: (revision) => set({ compareLeft: revision, view: 'preview' }),
3295
+ /** View a saved version against the current one, without changing the project. */
3296
+ compareFrom: (revision) => {
3297
+ const pair = revisionComparisonTarget(data, revision)
3298
+ if (pair) {
3299
+ diffSequence += 1
3300
+ set({ compareLeft: pair.left, compareRight: pair.right, compareMode: 'revisions', view: 'preview',
3301
+ diff: null, diffError: null, editorComparison: null })
3302
+ }
3303
+ },
3211
3304
 
3212
3305
  updateBrief: (field, value, referenceId) => {
3213
3306
  const projectId = data.activeProjectId, current = data.briefDrafts[projectId]
@@ -3507,14 +3600,32 @@ window.__ModuleLoader__.load({
3507
3600
  },
3508
3601
 
3509
3602
  diff: async (from, to) => {
3510
- set({ diffError: null })
3603
+ const projectId = data.activeProjectId, pair = previewRevisionPair(data)
3604
+ if (!running || !projectId || data.view !== 'preview' || data.compareMode !== 'revisions' || pair.left !== from || pair.right !== to) return
3605
+ const sequence = ++diffSequence
3606
+ const stamp = JSON.stringify([data.projectsRoot, projectId, data.currentRevision, from, to])
3607
+ const current = () => {
3608
+ const selected = previewRevisionPair(data)
3609
+ return running && sequence === diffSequence && data.view === 'preview' && data.compareMode === 'revisions'
3610
+ && selected.left === from && selected.right === to
3611
+ && JSON.stringify([data.projectsRoot, data.activeProjectId, data.currentRevision, from, to]) === stamp
3612
+ }
3613
+ set({ diff: null, diffError: null })
3511
3614
  try {
3512
- const response = await fetchImpl(`${projectRoute(target(), '/diff')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { headers: { accept: 'application/json' } })
3615
+ const response = await fetchImpl(`${projectRoute(projectId, '/diff')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { headers: { accept: 'application/json' } })
3513
3616
  const parsed = await response.json()
3514
- if (parsed && parsed.ok) set({ diff: parsed.diff })
3515
- else set({ diff: null, diffError: (parsed && parsed.error) || { code: `HTTP_${response.status}`, message: t('preview.diffFailed') } })
3617
+ if (!current()) return
3618
+ if (response.ok && parsed?.ok) {
3619
+ if (parsed.projectId !== undefined && parsed.projectId !== projectId
3620
+ || parsed.diff?.projectId !== undefined && parsed.diff.projectId !== projectId
3621
+ || parsed.diff?.fromRevision !== from || parsed.diff?.toRevision !== to
3622
+ || parsed.fromRevision !== undefined && parsed.fromRevision !== from
3623
+ || parsed.toRevision !== undefined && parsed.toRevision !== to) {
3624
+ set({ diff: null, diffError: { code: 'UI_DIFF_MISMATCH', message: t('preview.diffFailed') } })
3625
+ } else set({ diff: parsed.diff })
3626
+ } else set({ diff: null, diffError: parsed?.error || { code: `HTTP_${response.status}`, message: t('preview.diffFailed') } })
3516
3627
  } catch (error) {
3517
- set({ diff: null, diffError: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } })
3628
+ if (current()) set({ diff: null, diffError: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } })
3518
3629
  }
3519
3630
  },
3520
3631
 
@@ -3557,10 +3668,14 @@ window.__ModuleLoader__.load({
3557
3668
  const reading = readCreationDrafts(draftStorage, creationDraftRoot), source = reading.entries.find(entry => entry.id === id)
3558
3669
  if (!source?.draft) { set({ creationDraftStorageStatus: 'invalid' }); return }
3559
3670
  const saved = source.draft, ref = saved.forms.recipe
3671
+ if ((saved.recipeChoices || []).some(choice => {
3672
+ const available = data.recipeCatalog.recipes.find(recipe => recipeChoiceIdentity(recipe) === recipeChoiceIdentity(choice.recipe))
3673
+ return available && !recipeChoiceFits(available, choice.parameters)
3674
+ })) { set({ creationDraftStorageStatus: 'invalid' }); return }
3560
3675
  const recipe = ref ? data.recipeCatalog.recipes.find(recipe => recipe.id === ref.id && recipe.version === ref.version && recipe.digest === ref.digest) : null
3561
3676
  creationDraftId = newCreationKey(); restoredCreationSource = { id, raw: source.raw }; persistedCreationSignature = null
3562
3677
  creationAttempt = saved.attempt ? editorClone(saved.attempt) : null
3563
- set({ view: 'projects', forms: { ...data.forms, ...editorClone(saved.forms), recipe: recipe || null },
3678
+ set({ view: 'projects', recipeChoices: editorClone(saved.recipeChoices || []), forms: { ...data.forms, ...editorClone(saved.forms), recipe: recipe || null },
3564
3679
  creationDraftMissingRecipe: ref && !recipe ? ref : null,
3565
3680
  creationWork: creationAttempt ? { status: 'error', title: creationAttempt.body.title, code: 'UI_FETCH_FAILED' } : null,
3566
3681
  notices: { ...data.notices, projects: { kind: 'creation', ok: true, message: t('projects.draftRestored') } } })
@@ -3584,7 +3699,7 @@ window.__ModuleLoader__.load({
3584
3699
  clearCreationDraft: () => {
3585
3700
  if (data.busy.create) return
3586
3701
  creationAttempt = null; restoredCreationSource = null; persistedCreationSignature = null
3587
- set({ forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, creationWork: null,
3702
+ set({ recipeChoices: [], forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, creationWork: null,
3588
3703
  creationDraftMissingRecipe: null, notices: { ...data.notices, projects: null } })
3589
3704
  },
3590
3705
 
@@ -3737,6 +3852,7 @@ window.__ModuleLoader__.load({
3737
3852
  void load()
3738
3853
  },
3739
3854
  stop() {
3855
+ diffSequence += 1
3740
3856
  closeCreationDraft()
3741
3857
  draftLifecycle?.removeEventListener?.('pagehide', closeCreationDraft)
3742
3858
  draftLifecycle?.removeEventListener?.('pageshow', reopenCreationDraft)
@@ -3772,7 +3888,7 @@ window.__ModuleLoader__.load({
3772
3888
  function CreationDraftsView({ state, actions }) {
3773
3889
  const messages = { saved: t('projects.draftSaved'), unavailable: t('projects.draftUnavailable'), full: t('projects.draftFull'),
3774
3890
  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)
3891
+ 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)
3776
3892
  return el('div', { 'data-creation-drafts': true },
3777
3893
  el('p', { className: 'db-muted' }, t('projects.draftHint')),
3778
3894
  (hasInputs || state.creationDraftStorageStatus !== 'saved') && messages[state.creationDraftStorageStatus] ? el('p', {
@@ -3942,7 +4058,7 @@ window.__ModuleLoader__.load({
3942
4058
  })),
3943
4059
  selected ? el('fieldset', { className: 'db-recipe-parameters' },
3944
4060
  el('legend', null, t('recipes.parameters')),
3945
- Button({ action: 'reset-recipe', onClick: () => actions.selectRecipe(selected), children: t('recipes.reset') }),
4061
+ Button({ action: 'reset-recipe', onClick: actions.resetRecipe, children: t('recipes.reset') }),
3946
4062
  el('div', { className: 'db-inline', style: { flexWrap: 'wrap' } }, selected.parameters.map(parameter =>
3947
4063
  el('label', { key: parameter.id, style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
3948
4064
  parameter.title,
@@ -4763,9 +4879,7 @@ window.__ModuleLoader__.load({
4763
4879
  const previews = state.previews
4764
4880
  if (previews === null) return el('div', { 'data-view': 'preview', className: 'db-muted' }, t('common.noProject'))
4765
4881
  const revisions = previews.revisions
4766
- const pick = wanted => (revisions.some(entry => entry.revision === wanted) ? wanted : (((revisions[revisions.length - 1] || {}).revision) || null))
4767
- const left = pick(state.compareLeft || state.currentRevision)
4768
- const right = pick(state.compareRight || state.currentRevision)
4882
+ const { left, right } = previewRevisionPair(state)
4769
4883
  const entryOf = id => revisions.find(entry => entry.revision === id) || null
4770
4884
 
4771
4885
  /** One image (or the reason there is none), with its digest and its time. */
@@ -5112,6 +5226,7 @@ window.__ModuleLoader__.load({
5112
5226
  ErrorBox({ error: state.error }),
5113
5227
  el('div', { className: 'db-card' },
5114
5228
  el('h4', null, t('revisions.title', { count: revisions.length })),
5229
+ el('p', { className: 'db-muted' }, t('revisions.compareHint')),
5115
5230
  el('ul', { className: 'db-list' }, revisions.map(entry => el('li', { key: entry.revision, 'data-revision': entry.revision },
5116
5231
  el('div', { className: 'db-inline' },
5117
5232
  el('span', { className: 'db-mono' }, entry.revision),
@@ -5119,7 +5234,8 @@ window.__ModuleLoader__.load({
5119
5234
  el('span', { className: 'db-muted' }, entry.kind || '—'),
5120
5235
  el('span', { className: 'db-muted' }, formatTime(entry.createdAt)),
5121
5236
  el('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(entry.digest)}`),
5122
- Button({ action: `compare-from:${entry.revision}`, onClick: () => actions.compareFrom(entry.revision), children: t('revisions.compare') }),
5237
+ Button({ action: `compare-from:${entry.revision}`, disabled: revisionComparisonTarget(state, entry.revision) === null,
5238
+ onClick: () => actions.compareFrom(entry.revision), children: entry.isCurrent ? t('revisions.compareCurrent') : t('revisions.compare') }),
5123
5239
  entry.isCurrent ? null : Button({ action: `restore:${entry.revision}`, disabled: state.busy.restore, onClick: () => actions.restoreRevision(entry.revision), children: t('revisions.restore') }),
5124
5240
  ),
5125
5241
  el('div', { className: 'db-muted' }, entry.summary || t('common.noNote')),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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.6"
22
+ "@deepblend/dsh-blender-contracts": "0.3.8"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {