@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.
- package/lib/client.js +326 -14
- 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 = () =>
|
|
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 =>
|
|
3023
|
-
|
|
3024
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
22
|
+
"@deepblend/dsh-blender-contracts": "0.3.7"
|
|
23
23
|
},
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"repository": {
|