@kubohiroya/turbowarp-plus-editor-git 0.7.0 → 0.9.0
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/README.ja.md +22 -6
- package/README.md +9 -1
- package/dist/block-diff-preview.d.ts +18 -2
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/index.js +359 -15
- package/dist/{libesm-OWRWzmu_.js → project-asset-paths-KV715791.js} +23 -1
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +23 -0
- package/dist/project-change-list.d.ts +81 -0
- package/dist/project-change-model.d.ts +73 -0
- package/dist/project-source-diff.d.ts +7 -1
- package/dist/project-source-diff.js +10 -7
- package/dist/project-source-review-model.d.ts +4 -1
- package/dist/project-source-review.d.ts +11 -2
- package/dist/{repository-DplMQiuo.js → repository-VBp-UNWc.js} +134 -3
- package/dist/repository.d.ts +34 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-DZR8ep-T.js} +708 -90
- package/dist/scratch-block-diff-renderer.d.ts +69 -6
- package/dist/style.css +48 -2
- package/package.json +7 -2
- package/src/block-diff-preview.tsx +89 -8
- package/src/file-diff-view.tsx +4 -3
- package/src/github-pull-requests.ts +73 -6
- package/src/index.tsx +42 -12
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +78 -0
- package/src/project-change-list.tsx +210 -0
- package/src/project-change-model.ts +212 -0
- package/src/project-source-diff.css +31 -2
- package/src/project-source-diff.tsx +17 -7
- package/src/project-source-review-model.ts +4 -1
- package/src/project-source-review.tsx +67 -27
- package/src/repository.ts +107 -2
- package/src/scratch-block-diff-renderer.ts +129 -11
- package/src/style.css +17 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react';
|
|
2
|
+
import {isRecord} from './project-source-patch.js';
|
|
3
|
+
import type {ProjectTarget} from './project-source-patch.js';
|
|
4
|
+
|
|
5
|
+
export type ProjectVersion = 'before' | 'after';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads one asset of one version of a project file by its `md5ext` name (for example `0123…cdef.svg`), from the
|
|
9
|
+
* assets directory of that project. Resolves `null` when that version has no readable file of that name.
|
|
10
|
+
*/
|
|
11
|
+
export type ProjectAssetLoader = (request: {version: ProjectVersion; md5ext: string; signal: AbortSignal}) => Promise<Blob | null>;
|
|
12
|
+
|
|
13
|
+
const imageTypes: Record<string, string> = {
|
|
14
|
+
svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The costume a sprite shows, or the backdrop the stage shows, when the project data names an image file for it. */
|
|
18
|
+
export function currentCostume(target: ProjectTarget): {name: string; md5ext: string; type: string} | null {
|
|
19
|
+
const {costumes, currentCostume: index} = target as unknown as Record<string, unknown>;
|
|
20
|
+
if (!Array.isArray(costumes)) return null;
|
|
21
|
+
const costume: unknown = costumes[typeof index === 'number' && Number.isInteger(index) && index >= 0 ? index : 0];
|
|
22
|
+
if (!isRecord(costume)) return null;
|
|
23
|
+
const md5ext = typeof costume.md5ext === 'string' ? costume.md5ext :
|
|
24
|
+
typeof costume.assetId === 'string' && typeof costume.dataFormat === 'string' ? costume.assetId + '.' + costume.dataFormat : '';
|
|
25
|
+
// Asset names are plain file names; anything else could address a path outside the assets directory.
|
|
26
|
+
const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
|
|
27
|
+
const type = match ? imageTypes[match[1].toLowerCase()] : undefined;
|
|
28
|
+
if (!type) return null;
|
|
29
|
+
return {name: typeof costume.name === 'string' ? costume.name : '', md5ext, type};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Thumbnails of one target appear in its heading and in each of its frames, so one read serves all of them.
|
|
33
|
+
const reads = new WeakMap<ProjectAssetLoader, Map<string, Promise<Blob | null>>>();
|
|
34
|
+
function readAsset(loadAsset: ProjectAssetLoader, version: ProjectVersion, md5ext: string) {
|
|
35
|
+
let cache = reads.get(loadAsset);
|
|
36
|
+
if (!cache) reads.set(loadAsset, cache = new Map());
|
|
37
|
+
const key = version + ':' + md5ext;
|
|
38
|
+
let read = cache.get(key);
|
|
39
|
+
if (!read) {
|
|
40
|
+
read = loadAsset({version, md5ext, signal: new AbortController().signal}).catch(() => null);
|
|
41
|
+
cache.set(key, read);
|
|
42
|
+
}
|
|
43
|
+
return read;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The image type of an asset file name, or `undefined` for sounds and anything that is not an image. */
|
|
47
|
+
export function assetImageType(md5ext: string): string | undefined {
|
|
48
|
+
const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
|
|
49
|
+
return match ? imageTypes[match[1].toLowerCase()] : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Loads one image asset of one version as an object URL, or `null` while it is unavailable or not an image. */
|
|
53
|
+
export function useAssetImage(md5ext: string | undefined, version: ProjectVersion, loadAsset?: ProjectAssetLoader): string | null {
|
|
54
|
+
const type = md5ext ? assetImageType(md5ext) : undefined;
|
|
55
|
+
const key = md5ext && type && loadAsset ? version + ':' + md5ext : '';
|
|
56
|
+
const [image, setImage] = useState<{key: string; url: string}>();
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!key || !loadAsset) return;
|
|
59
|
+
let active = true;
|
|
60
|
+
let url: string | undefined;
|
|
61
|
+
void readAsset(loadAsset, version, md5ext!).then(blob => {
|
|
62
|
+
if (!blob || !active) return;
|
|
63
|
+
url = URL.createObjectURL(new Blob([blob], {type}));
|
|
64
|
+
setImage({key, url});
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
active = false;
|
|
68
|
+
if (url) URL.revokeObjectURL(url);
|
|
69
|
+
};
|
|
70
|
+
}, [key, loadAsset]);
|
|
71
|
+
return image?.key === key ? image.url : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Loads the current costume of one version of a target as an object URL for an image, or `null` while it is unavailable. */
|
|
75
|
+
export function useCostumeImage(target: ProjectTarget | undefined, version: ProjectVersion, loadAsset?: ProjectAssetLoader): string | null {
|
|
76
|
+
const costume = target && loadAsset ? currentCostume(target) : null;
|
|
77
|
+
return useAssetImage(costume?.md5ext, version, loadAsset);
|
|
78
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import React, {useEffect, useState} from 'react';
|
|
2
|
+
import {organizeProjectChanges, projectPropertyLabel} from './project-change-model.js';
|
|
3
|
+
import type {ChangedFile, ProjectChangeList, ProjectElementChange, ProjectTargetChanges} from './project-change-model.js';
|
|
4
|
+
import {parseProjectSource} from './project-source-patch.js';
|
|
5
|
+
import {useAssetImage, useCostumeImage} from './project-assets.js';
|
|
6
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
7
|
+
import {ProjectSourceComparison} from './project-source-review.js';
|
|
8
|
+
import type {ChangeGroup, FileChange, Repository, Snapshot} from './repository.js';
|
|
9
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
10
|
+
|
|
11
|
+
const en = {
|
|
12
|
+
title: 'Changes by project element', stage: 'Stage', code: (count: number) => `Code · ${count} ${count === 1 ? 'change' : 'changes'}`,
|
|
13
|
+
costumes: 'Costume', backdrops: 'Backdrop', sounds: 'Sound', data: 'Variables and messages', settings: 'Other settings',
|
|
14
|
+
project: 'Whole project', other: 'Files outside the project', added: 'Added', removed: 'Removed', updated: 'Changed',
|
|
15
|
+
files: 'Files and Git groups', before: 'Before', after: 'After', absent: 'Not in this version',
|
|
16
|
+
loading: 'Reading the project changes…', unreadable: 'The project data could not be read, so the changes are listed by file.',
|
|
17
|
+
fileList: 'List by file', property: 'Item', format: 'Format', rate: 'Sample rate', samples: 'Samples', file: 'File name',
|
|
18
|
+
noImage: 'No image', groups: {staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit'} as Record<ChangeGroup, string>
|
|
19
|
+
};
|
|
20
|
+
const ja: typeof en = {
|
|
21
|
+
title: '作品の要素ごとの変更', stage: 'ステージ', code: count => `コード · ${count} か所`,
|
|
22
|
+
costumes: 'コスチューム', backdrops: '背景', sounds: '音', data: '変数・リスト・メッセージ', settings: 'そのほかの設定',
|
|
23
|
+
project: '作品全体', other: '作品に属さないファイル', added: '追加', removed: '削除', updated: '変更',
|
|
24
|
+
files: 'ファイルと Git の区分', before: '変更前', after: '変更後', absent: 'この版にはありません',
|
|
25
|
+
loading: '作品の変更を読み込み中…', unreadable: '作品データを読み込めないため、ファイル単位で一覧にします。',
|
|
26
|
+
fileList: 'ファイル単位の一覧', property: '項目', format: '形式', rate: 'サンプリング周波数', samples: 'サンプル数', file: 'ファイル名',
|
|
27
|
+
noImage: '画像なし', groups: {staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミット'}
|
|
28
|
+
};
|
|
29
|
+
type Labels = typeof en;
|
|
30
|
+
export const projectChangeLabels = (locale: string): Labels => locale.startsWith('ja') ? ja : en;
|
|
31
|
+
|
|
32
|
+
export interface ProjectChanges {
|
|
33
|
+
list: ProjectChangeList;
|
|
34
|
+
sources: {before: string | null; after: string | null};
|
|
35
|
+
loadAsset: ProjectAssetLoader;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the selected project folder in the last commit and the working tree (or a commit and its parent) and arranges
|
|
40
|
+
* the changed files by stage, sprite and element. `undefined` while there is no project; `'loading'` and `'unreadable'`
|
|
41
|
+
* tell the panel to keep the list by file.
|
|
42
|
+
*/
|
|
43
|
+
export function useProjectChanges(repository: Repository | null, snapshot: Snapshot | null, project: string | null,
|
|
44
|
+
commit: string | null, changes: readonly FileChange[]): ProjectChanges | 'loading' | 'unreadable' | undefined {
|
|
45
|
+
const [result, setResult] = useState<{changes: readonly FileChange[]; value: ProjectChanges | 'unreadable'}>();
|
|
46
|
+
const active = !!repository && !!snapshot && project !== null && changes.length > 0;
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!repository || !snapshot || project === null || !changes.length) return;
|
|
49
|
+
let current = true;
|
|
50
|
+
const comparison = commit ? {kind: 'commit' as const, commit} : {kind: 'worktree' as const};
|
|
51
|
+
void repository.readProject(snapshot, project, comparison).then(read => {
|
|
52
|
+
if (!current) return;
|
|
53
|
+
let list: ProjectChangeList;
|
|
54
|
+
try {
|
|
55
|
+
list = organizeProjectChanges({folder: project,
|
|
56
|
+
before: read.before === null ? null : parseProjectSource(read.before),
|
|
57
|
+
after: read.after === null ? null : parseProjectSource(read.after),
|
|
58
|
+
assetsDirectory: read.assetsDirectory, files: changes.map(({path, group}) => ({path, group}))});
|
|
59
|
+
} catch {
|
|
60
|
+
setResult({changes, value: 'unreadable'});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setResult({changes, value: {list, sources: {before: read.before, after: read.after},
|
|
64
|
+
loadAsset: repository.projectAssetLoader(snapshot, project, comparison)}});
|
|
65
|
+
}, () => {if (current) setResult({changes, value: 'unreadable'});});
|
|
66
|
+
return () => {current = false;};
|
|
67
|
+
}, [repository, snapshot, project, commit, changes]);
|
|
68
|
+
if (!active) return undefined;
|
|
69
|
+
// Keep the last list while a refresh reads again, so the panel does not fall back to files and back.
|
|
70
|
+
if (result?.changes === changes || (result && typeof result.value === 'object')) return result!.value;
|
|
71
|
+
return 'loading';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function TargetHeading({target, labels, loadAsset}: {target: ProjectTargetChanges; labels: Labels; loadAsset: ProjectAssetLoader}) {
|
|
75
|
+
const version: ProjectVersion = target.after ? 'after' : 'before';
|
|
76
|
+
const image = useCostumeImage(target[version], version, loadAsset);
|
|
77
|
+
return <h4 className="twgit-element-target">
|
|
78
|
+
{image ? <img src={image} alt=""/> : <span className="twgit-element-thumbnail"/>}
|
|
79
|
+
<span>{target.isStage ? labels.stage : target.name}</span>
|
|
80
|
+
{target.status !== 'updated' && <small className="twgit-review-status">{labels[target.status]}</small>}
|
|
81
|
+
</h4>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function elementTitle(element: ProjectElementChange, target: ProjectTargetChanges | null, labels: Labels, locale: string) {
|
|
85
|
+
switch (element.kind) {
|
|
86
|
+
case 'code': return labels.code(element.scripts ?? 0);
|
|
87
|
+
case 'costumes': return `${target?.isStage ? labels.backdrops : labels.costumes} · ${element.name ?? ''}`;
|
|
88
|
+
case 'sounds': return `${labels.sounds} · ${element.name ?? ''}`;
|
|
89
|
+
default: {
|
|
90
|
+
const names = (element.properties ?? []).map(key => projectPropertyLabel(key, locale)).join('・');
|
|
91
|
+
return target ? `${element.kind === 'data' ? labels.data : labels.settings} · ${names}` : names;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ElementRow({element, target, labels, locale, loadAsset, selected, busy, onSelect}: {
|
|
97
|
+
element: ProjectElementChange; target: ProjectTargetChanges | null; labels: Labels; locale: string;
|
|
98
|
+
loadAsset: ProjectAssetLoader; selected: boolean; busy: boolean; onSelect(): void;
|
|
99
|
+
}) {
|
|
100
|
+
const version: ProjectVersion = element.md5ext?.after ? 'after' : 'before';
|
|
101
|
+
const image = useAssetImage(element.kind === 'costumes' ? element.md5ext?.[version] : undefined, version, loadAsset);
|
|
102
|
+
return <li><button className="twgit-file twgit-element" data-git-element={element.kind} disabled={busy} aria-pressed={selected}
|
|
103
|
+
onClick={onSelect}>
|
|
104
|
+
{element.kind === 'costumes' && (image ? <img src={image} alt=""/> : <span className="twgit-element-thumbnail"/>)}
|
|
105
|
+
<span className="twgit-element-name">{elementTitle(element, target, labels, locale)}</span>
|
|
106
|
+
{element.status !== 'updated' && <small className="twgit-review-status">{labels[element.status]}</small>}
|
|
107
|
+
</button></li>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The change list arranged by the project's stage, sprites and elements, with files outside the project after them. */
|
|
111
|
+
export function ProjectChangeListView({changes, selected, locale, busy, onSelect, onSelectFile, fileList, fileListOpen, setFileListOpen}: {
|
|
112
|
+
changes: ProjectChanges; selected: string | null; locale: string; busy: boolean;
|
|
113
|
+
onSelect(key: string): void; onSelectFile(file: ChangedFile): void; fileList: React.ReactNode;
|
|
114
|
+
fileListOpen: boolean; setFileListOpen(open: boolean): void;
|
|
115
|
+
}) {
|
|
116
|
+
const labels = projectChangeLabels(locale);
|
|
117
|
+
const {list, loadAsset} = changes;
|
|
118
|
+
return <section className="twgit-element-list" aria-label={labels.title}>
|
|
119
|
+
{list.targets.map(target => <section key={target.key}>
|
|
120
|
+
<TargetHeading target={target} labels={labels} loadAsset={loadAsset}/>
|
|
121
|
+
<ul>{target.elements.map(element => <ElementRow key={element.key} element={element} target={target} labels={labels}
|
|
122
|
+
locale={locale} loadAsset={loadAsset} selected={selected === element.key} busy={busy} onSelect={() => onSelect(element.key)}/>)}</ul>
|
|
123
|
+
</section>)}
|
|
124
|
+
{list.project.length > 0 && <section>
|
|
125
|
+
<h4 className="twgit-element-target"><span>{labels.project}</span></h4>
|
|
126
|
+
<ul>{list.project.map(element => <ElementRow key={element.key} element={element} target={null} labels={labels}
|
|
127
|
+
locale={locale} loadAsset={loadAsset} selected={selected === element.key} busy={busy} onSelect={() => onSelect(element.key)}/>)}</ul>
|
|
128
|
+
</section>}
|
|
129
|
+
{list.other.length > 0 && <section>
|
|
130
|
+
<h4 className="twgit-element-target"><span>{labels.other}</span></h4>
|
|
131
|
+
<ul>{list.other.map(file => <li key={file.group + ':' + file.path}><button className="twgit-file" data-git-other={file.group}
|
|
132
|
+
disabled={busy} onClick={() => onSelectFile(file)}>{file.path} <small>{labels.groups[file.group]}</small></button></li>)}</ul>
|
|
133
|
+
</section>}
|
|
134
|
+
<details className="twgit-file-list" open={fileListOpen}
|
|
135
|
+
onToggle={event => setFileListOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
136
|
+
<summary>{labels.fileList}</summary>{fileList}</details>
|
|
137
|
+
</section>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function findElement(list: ProjectChangeList, key: string | null) {
|
|
141
|
+
if (key === null) return null;
|
|
142
|
+
for (const target of list.targets) {
|
|
143
|
+
const element = target.elements.find(item => item.key === key);
|
|
144
|
+
if (element) return {element, target};
|
|
145
|
+
}
|
|
146
|
+
const element = list.project.find(item => item.key === key);
|
|
147
|
+
return element ? {element, target: null} : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function Picture({md5ext, version, loadAsset, labels, title}: {
|
|
151
|
+
md5ext?: string; version: ProjectVersion; loadAsset: ProjectAssetLoader; labels: Labels; title: string;
|
|
152
|
+
}) {
|
|
153
|
+
const image = useAssetImage(md5ext, version, loadAsset);
|
|
154
|
+
return <figure className="twgit-element-picture">
|
|
155
|
+
<figcaption>{title}</figcaption>
|
|
156
|
+
{!md5ext ? <p>{labels.absent}</p> : image ? <img src={image} alt={title}/> : <p>{labels.noImage}</p>}
|
|
157
|
+
</figure>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const show = (value: unknown) => {
|
|
161
|
+
if (value === undefined) return '—';
|
|
162
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
163
|
+
return text.length > 400 ? text.slice(0, 400) + '…' : text;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** The details of one selected row: its code, pictures or values in both versions, and the files behind it. */
|
|
167
|
+
export function ProjectElementView({changes, selection, locale, renderBlock, onSelectFile}: {
|
|
168
|
+
changes: ProjectChanges; selection: {element: ProjectElementChange; target: ProjectTargetChanges | null}; locale: string;
|
|
169
|
+
renderBlock?: ProjectBlockRenderer; onSelectFile(file: ChangedFile): void;
|
|
170
|
+
}) {
|
|
171
|
+
const labels = projectChangeLabels(locale);
|
|
172
|
+
const {element, target} = selection;
|
|
173
|
+
const record = (version: ProjectVersion): Record<string, unknown> | undefined => {
|
|
174
|
+
if (element.kind === 'costumes' || element.kind === 'sounds') return element[version];
|
|
175
|
+
if (!target) {
|
|
176
|
+
const source = changes.sources[version];
|
|
177
|
+
try {
|
|
178
|
+
return source === null ? undefined : parseProjectSource(source) as Record<string, unknown>;
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return target[version] as Record<string, unknown> | undefined;
|
|
184
|
+
};
|
|
185
|
+
const rows = element.kind === 'sounds' ? [['name', 'name'], ['dataFormat', labels.format], ['rate', labels.rate],
|
|
186
|
+
['sampleCount', labels.samples], ['md5ext', labels.file]] as const :
|
|
187
|
+
(element.properties ?? []).map(key => [key, projectPropertyLabel(key, locale)] as const);
|
|
188
|
+
return <section className="twgit-element-view" aria-label={elementTitle(element, target, labels, locale)}>
|
|
189
|
+
<h3>{target && <span>{target.isStage ? labels.stage : target.name} · </span>}{elementTitle(element, target, labels, locale)}</h3>
|
|
190
|
+
{element.kind === 'code' && target && renderBlock && <ProjectSourceComparison before={changes.sources.before}
|
|
191
|
+
after={changes.sources.after} renderBlock={renderBlock} locale={locale} loadAsset={changes.loadAsset}
|
|
192
|
+
target={{isStage: target.isStage, name: target.name}}/>}
|
|
193
|
+
{element.kind === 'costumes' && <div className="twgit-element-pictures">
|
|
194
|
+
{(['before', 'after'] as const).map(version => <Picture key={version} md5ext={element.md5ext?.[version]} version={version}
|
|
195
|
+
loadAsset={changes.loadAsset} labels={labels} title={element[version]?.name === undefined ? labels[version] : `${labels[version]} · ${String(element[version]!.name)}`}/>)}
|
|
196
|
+
</div>}
|
|
197
|
+
{element.kind !== 'code' && element.kind !== 'costumes' && <table className="twgit-element-values">
|
|
198
|
+
<thead><tr><th>{labels.property}</th><th>{labels.before}</th><th>{labels.after}</th></tr></thead>
|
|
199
|
+
<tbody>{rows.map(([key, name]) => <tr key={key}>
|
|
200
|
+
<th scope="row">{name}</th>
|
|
201
|
+
<td>{record('before') ? show(record('before')![key]) : labels.absent}</td>
|
|
202
|
+
<td>{record('after') ? show(record('after')![key]) : labels.absent}</td>
|
|
203
|
+
</tr>)}</tbody>
|
|
204
|
+
</table>}
|
|
205
|
+
<details className="twgit-element-files"><summary>{labels.files}</summary>
|
|
206
|
+
<ul>{element.files.map(file => <li key={file.group + ':' + file.path}><button className="twgit-file" type="button"
|
|
207
|
+
onClick={() => onSelectFile(file)}>{file.path} <small>{labels.groups[file.group]}</small></button></li>)}</ul>
|
|
208
|
+
</details>
|
|
209
|
+
</section>;
|
|
210
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type {ChangeGroup} from './repository.js';
|
|
2
|
+
import {equal, isRecord, targetKey} from './project-source-patch.js';
|
|
3
|
+
import type {ProjectSource, ProjectTarget} from './project-source-patch.js';
|
|
4
|
+
import {compareProjectForReview} from './project-source-review-model.js';
|
|
5
|
+
|
|
6
|
+
/** One changed file of the repository, with the Git group it was listed under. A path can appear in several groups. */
|
|
7
|
+
export interface ChangedFile {
|
|
8
|
+
path: string;
|
|
9
|
+
group: ChangeGroup;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ProjectElementKind = 'code' | 'costumes' | 'sounds' | 'data' | 'settings';
|
|
13
|
+
export type ProjectChangeStatus = 'added' | 'removed' | 'updated';
|
|
14
|
+
|
|
15
|
+
/** One row of the project-shaped change list: something a person recognizes in the Editor. */
|
|
16
|
+
export interface ProjectElementChange {
|
|
17
|
+
/** Stable within one list, for React keys and selection. */
|
|
18
|
+
key: string;
|
|
19
|
+
kind: ProjectElementKind;
|
|
20
|
+
status: ProjectChangeStatus;
|
|
21
|
+
/** Costume or sound name; absent for code, data and settings rows. */
|
|
22
|
+
name?: string;
|
|
23
|
+
/** The costume or sound entry in each version, for costume and sound rows. */
|
|
24
|
+
before?: Record<string, unknown>;
|
|
25
|
+
after?: Record<string, unknown>;
|
|
26
|
+
/** Asset file names (`md5ext`) of each version, for costume and sound rows. */
|
|
27
|
+
md5ext?: {before?: string; after?: string};
|
|
28
|
+
/**
|
|
29
|
+
* Changed property keys, for data and settings rows (for example `variables`, `x`, `visible`), and for
|
|
30
|
+
* project-level rows (`monitors`, `extensions`, `meta`).
|
|
31
|
+
*/
|
|
32
|
+
properties?: string[];
|
|
33
|
+
/** Number of changed code items (as counted by the review comparison), for code rows. */
|
|
34
|
+
scripts?: number;
|
|
35
|
+
/** The changed repository files behind this row, so the details view can show them with their Git groups. */
|
|
36
|
+
files: ChangedFile[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProjectTargetChanges {
|
|
40
|
+
key: string;
|
|
41
|
+
name: string;
|
|
42
|
+
isStage: boolean;
|
|
43
|
+
status: ProjectChangeStatus;
|
|
44
|
+
before?: ProjectTarget;
|
|
45
|
+
after?: ProjectTarget;
|
|
46
|
+
/** In the order code, costumes, sounds, data, settings; costumes and sounds in their order in the target. */
|
|
47
|
+
elements: ProjectElementChange[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ProjectChangeList {
|
|
51
|
+
/** The stage first, then sprites in project order; removed targets after the existing ones. */
|
|
52
|
+
targets: ProjectTargetChanges[];
|
|
53
|
+
/** Project-level changes outside the targets (monitors, extensions, meta and other top-level keys). */
|
|
54
|
+
project: ProjectElementChange[];
|
|
55
|
+
/** Changed files that belong to no element: sb3-source.json, embedded-extensions.json, unreferenced assets and the rest. */
|
|
56
|
+
other: ChangedFile[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OrganizeProjectChangesInput {
|
|
60
|
+
/** The project folder relative to the repository root, `''` for the root. */
|
|
61
|
+
folder: string;
|
|
62
|
+
/** The project data of each version, `null` where the version has no project file. */
|
|
63
|
+
before: ProjectSource | null;
|
|
64
|
+
after: ProjectSource | null;
|
|
65
|
+
/** The assets directory of each version, relative to the folder (from sb3-source.json; `assets` by default). */
|
|
66
|
+
assetsDirectory: {before: string; after: string};
|
|
67
|
+
/** Every changed file in the folder, from all Git groups. */
|
|
68
|
+
files: readonly ChangedFile[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const targetDataKeys = ['variables', 'lists', 'broadcasts'] as const;
|
|
72
|
+
const nonSettingKeys = new Set(['blocks', 'costumes', 'sounds', ...targetDataKeys]);
|
|
73
|
+
|
|
74
|
+
const propertyLabels = new Map<string, readonly [string, string]>([
|
|
75
|
+
['variables', ['変数', 'Variables']], ['lists', ['リスト', 'Lists']], ['broadcasts', ['メッセージ', 'Broadcast messages']],
|
|
76
|
+
['costumes', ['コスチューム・背景', 'Costumes and backdrops']], ['sounds', ['音', 'Sounds']], ['blocks', ['コード', 'Code']],
|
|
77
|
+
['x', ['X 座標', 'X position']], ['y', ['Y 座標', 'Y position']], ['size', ['大きさ', 'Size']],
|
|
78
|
+
['direction', ['向き', 'Direction']], ['visible', ['表示状態', 'Visibility']],
|
|
79
|
+
['currentCostume', ['表示するコスチューム', 'Selected costume']], ['layerOrder', ['重なり順', 'Layer order']],
|
|
80
|
+
['volume', ['音量', 'Volume']], ['comments', ['コメント', 'Comments']], ['draggable', ['ドラッグ可否', 'Draggable']],
|
|
81
|
+
['rotationStyle', ['回転方法', 'Rotation style']], ['tempo', ['テンポ', 'Tempo']],
|
|
82
|
+
['videoTransparency', ['ビデオの透明度', 'Video transparency']], ['videoState', ['ビデオの状態', 'Video state']],
|
|
83
|
+
['textToSpeechLanguage', ['音声合成の言語', 'Text to speech language']], ['monitors', ['モニター', 'Monitors']],
|
|
84
|
+
['extensions', ['拡張機能', 'Extensions']], ['meta', ['作品の情報', 'Project metadata']]
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** The Japanese or English name of a project or target property key; unknown keys are returned as they are. */
|
|
88
|
+
export function projectPropertyLabel(key: string, locale = 'en'): string {
|
|
89
|
+
return propertyLabels.get(key)?.[locale.startsWith('ja') ? 0 : 1] ?? key;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The asset file name of a costume or sound entry: `md5ext`, or `assetId.dataFormat`; `''` when it has neither. */
|
|
93
|
+
export function assetFileName(entry: Record<string, unknown>): string {
|
|
94
|
+
if (typeof entry.md5ext === 'string') return entry.md5ext;
|
|
95
|
+
return typeof entry.assetId === 'string' && typeof entry.dataFormat === 'string' ? entry.assetId + '.' + entry.dataFormat : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function joinPath(...parts: string[]) {
|
|
99
|
+
return parts.flatMap(part => part.split('/')).filter(part => part !== '' && part !== '.').join('/');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function statusOf(before: unknown, after: unknown): ProjectChangeStatus {
|
|
103
|
+
return before === undefined ? 'added' : after === undefined ? 'removed' : 'updated';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function entriesOf(target: ProjectTarget | undefined, kind: 'costumes' | 'sounds') {
|
|
107
|
+
const list = (target as Record<string, unknown> | undefined)?.[kind];
|
|
108
|
+
return Array.isArray(list) ? list.filter(isRecord) : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nameOf(entry: Record<string, unknown>) {
|
|
112
|
+
return typeof entry.name === 'string' ? entry.name : '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pairs the entries of two versions by name (repeated names pair in order): after's order first, then removed ones. */
|
|
116
|
+
function pairEntries(before: Record<string, unknown>[], after: Record<string, unknown>[]) {
|
|
117
|
+
const remaining = new Map<string, Record<string, unknown>[]>();
|
|
118
|
+
for (const entry of before) {
|
|
119
|
+
const queue = remaining.get(nameOf(entry));
|
|
120
|
+
if (queue) queue.push(entry);
|
|
121
|
+
else remaining.set(nameOf(entry), [entry]);
|
|
122
|
+
}
|
|
123
|
+
const pairs: {name: string; before?: Record<string, unknown>; after?: Record<string, unknown>}[] = [];
|
|
124
|
+
for (const entry of after) pairs.push({name: nameOf(entry), before: remaining.get(nameOf(entry))?.shift(), after: entry});
|
|
125
|
+
for (const entry of before) {
|
|
126
|
+
const queue = remaining.get(nameOf(entry));
|
|
127
|
+
if (queue?.[0] === entry) pairs.push({name: nameOf(entry), before: queue.shift()});
|
|
128
|
+
}
|
|
129
|
+
return pairs;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function changedKeys(before: Record<string, unknown>, after: Record<string, unknown>, skip: (key: string) => boolean) {
|
|
133
|
+
return [...new Set([...Object.keys(after), ...Object.keys(before)])].filter(key => !skip(key) && !equal(before[key], after[key]));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Arranges the changed files of one project folder by stage, sprite and element. Pure; reads nothing. */
|
|
137
|
+
export function organizeProjectChanges(input: OrganizeProjectChangesInput): ProjectChangeList {
|
|
138
|
+
const {folder, files, assetsDirectory} = input;
|
|
139
|
+
const normalize = (project: ProjectSource | null): ProjectSource => project === null ? {targets: []} :
|
|
140
|
+
{...project, targets: project.targets.map(target => ({...target, blocks: target.blocks ?? {}}))};
|
|
141
|
+
const before = normalize(input.before), after = normalize(input.after);
|
|
142
|
+
const projectPath = joinPath(folder, 'project.source.json');
|
|
143
|
+
const projectFiles = files.filter(file => file.path === projectPath);
|
|
144
|
+
const explained = new Set<string>();
|
|
145
|
+
let projectUsed = false;
|
|
146
|
+
const useProject = () => {
|
|
147
|
+
projectUsed = true;
|
|
148
|
+
return [...projectFiles];
|
|
149
|
+
};
|
|
150
|
+
const filesAt = (paths: (string | undefined)[]) => {
|
|
151
|
+
const wanted = new Set(paths.filter((path): path is string => path !== undefined));
|
|
152
|
+
const found = files.filter(file => wanted.has(file.path));
|
|
153
|
+
for (const file of found) explained.add(file.path);
|
|
154
|
+
return found;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const review = compareProjectForReview(before, after);
|
|
158
|
+
const scripts = new Map(review.targets.map(target => [targetKey((target.after ?? target.before)!), target.scripts.length]));
|
|
159
|
+
const oldTargets = new Map(before.targets.map(target => [targetKey(target), target]));
|
|
160
|
+
const newTargets = new Map(after.targets.map(target => [targetKey(target), target]));
|
|
161
|
+
const keys = [...new Set([...after.targets, ...before.targets].map(targetKey))];
|
|
162
|
+
keys.sort((a, b) => Number(b === 'stage') - Number(a === 'stage'));
|
|
163
|
+
|
|
164
|
+
const targets: ProjectTargetChanges[] = [];
|
|
165
|
+
for (const key of keys) {
|
|
166
|
+
const a = oldTargets.get(key), b = newTargets.get(key);
|
|
167
|
+
const status = statusOf(a, b);
|
|
168
|
+
const elements: ProjectElementChange[] = [];
|
|
169
|
+
const count = scripts.get(key) ?? 0;
|
|
170
|
+
if (count) {
|
|
171
|
+
elements.push({key: JSON.stringify([key, 'code', '']), kind: 'code', status, scripts: count, files: useProject()});
|
|
172
|
+
}
|
|
173
|
+
for (const kind of ['costumes', 'sounds'] as const) {
|
|
174
|
+
const seen = new Map<string, number>();
|
|
175
|
+
for (const pair of pairEntries(entriesOf(a, kind), entriesOf(b, kind))) {
|
|
176
|
+
const md5ext = {before: pair.before && assetFileName(pair.before) || undefined, after: pair.after && assetFileName(pair.after) || undefined};
|
|
177
|
+
const differs = !equal(pair.before, pair.after);
|
|
178
|
+
const assets = filesAt([md5ext.before && joinPath(folder, assetsDirectory.before, md5ext.before),
|
|
179
|
+
md5ext.after && joinPath(folder, assetsDirectory.after, md5ext.after)]);
|
|
180
|
+
if (!differs && !assets.length) continue;
|
|
181
|
+
const repeat = seen.get(pair.name) ?? 0;
|
|
182
|
+
seen.set(pair.name, repeat + 1);
|
|
183
|
+
elements.push({
|
|
184
|
+
key: JSON.stringify(repeat ? [key, kind, pair.name, repeat] : [key, kind, pair.name]), kind, status: statusOf(pair.before, pair.after),
|
|
185
|
+
name: pair.name, before: pair.before, after: pair.after, md5ext, files: [...(differs ? useProject() : []), ...assets]
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (a && b) {
|
|
190
|
+
const oldFields = a as unknown as Record<string, unknown>, newFields = b as unknown as Record<string, unknown>;
|
|
191
|
+
const data = changedKeys(oldFields, newFields, property => !(targetDataKeys as readonly string[]).includes(property));
|
|
192
|
+
if (data.length) elements.push({key: JSON.stringify([key, 'data', '']), kind: 'data', status, properties: data, files: useProject()});
|
|
193
|
+
const settings = changedKeys(oldFields, newFields, property => nonSettingKeys.has(property));
|
|
194
|
+
if (settings.length) {
|
|
195
|
+
elements.push({key: JSON.stringify([key, 'settings', '']), kind: 'settings', status, properties: settings, files: useProject()});
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
projectUsed = true;
|
|
199
|
+
}
|
|
200
|
+
if (status !== 'updated' || elements.length) {
|
|
201
|
+
const {name, isStage} = (b ?? a)!;
|
|
202
|
+
targets.push({key, name, isStage, status, before: a, after: b, elements});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const project = changedKeys(before, after, key => key === 'targets').map((property): ProjectElementChange => ({
|
|
207
|
+
key: JSON.stringify(['project', 'settings', property]), kind: 'settings', status: statusOf(before[property], after[property]),
|
|
208
|
+
properties: [property], files: useProject()
|
|
209
|
+
}));
|
|
210
|
+
if (projectUsed) explained.add(projectPath);
|
|
211
|
+
return {targets, project, other: files.filter(file => !explained.has(file.path))};
|
|
212
|
+
}
|
|
@@ -22,8 +22,26 @@
|
|
|
22
22
|
.twgit-block-diff-sides { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
23
23
|
.twgit-block-diff-side { padding: 10px 14px; min-width: 0; }
|
|
24
24
|
.twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
|
|
25
|
-
|
|
26
|
-
.twgit-block-diff-
|
|
25
|
+
/* A preview is a small workspace: it scrolls and zooms inside its frame, which people can make taller. */
|
|
26
|
+
.twgit-block-diff-preview {
|
|
27
|
+
position: relative;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
resize: vertical;
|
|
30
|
+
min-height: 120px;
|
|
31
|
+
max-height: 85vh;
|
|
32
|
+
border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas);
|
|
33
|
+
border-radius: 6px;
|
|
34
|
+
}
|
|
35
|
+
.twgit-block-diff-surface { position: absolute; inset: 0; }
|
|
36
|
+
.twgit-block-diff-watermark { position: absolute; z-index: 2; top: 10px; right: 12px; pointer-events: none; }
|
|
37
|
+
.twgit-block-diff-watermark[data-navigation] { top: auto; right: var(--plus-watermark-right, 12px); bottom: var(--plus-watermark-bottom, 140px); }
|
|
38
|
+
.twgit-block-diff-watermark > img {
|
|
39
|
+
display: block;
|
|
40
|
+
max-width: var(--plus-watermark-size, 48px);
|
|
41
|
+
max-height: var(--plus-watermark-size, 48px);
|
|
42
|
+
opacity: .6;
|
|
43
|
+
}
|
|
44
|
+
.twgit-block-diff-link { display: block; padding: 6px 12px 0; }
|
|
27
45
|
/* These markers also apply to the live Editor workspace returned by the host. */
|
|
28
46
|
.twgit-changed-block > .blocklyPath { stroke: var(--twgit-change, #635bce); stroke-width: 2.5px; }
|
|
29
47
|
.twgit-changed-block-part { outline: 2.5px solid #b4510a; outline-offset: 2px; }
|
|
@@ -77,3 +95,14 @@
|
|
|
77
95
|
.twgit-pr-paging, .twgit-pr-revision { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
78
96
|
.twgit-pr-revision code { overflow-wrap: anywhere; }
|
|
79
97
|
.twgit-review .twgit-block-diff-side h4 { font-size: 1em; }
|
|
98
|
+
|
|
99
|
+
/* Costume (or backdrop) thumbnails in stage and sprite headings. */
|
|
100
|
+
.twgit-review-thumbnails { display: inline-flex; align-items: center; gap: 4px; margin-inline-end: 6px; vertical-align: middle; }
|
|
101
|
+
.twgit-review-thumbnails img {
|
|
102
|
+
width: 30px;
|
|
103
|
+
height: 30px;
|
|
104
|
+
object-fit: contain;
|
|
105
|
+
background: Canvas;
|
|
106
|
+
border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
|
|
107
|
+
border-radius: 4px;
|
|
108
|
+
}
|
|
@@ -3,7 +3,7 @@ import {compareProjectBlocks, readProjectSourceDiff} from './project-source-patc
|
|
|
3
3
|
import type {BlockChange} from './project-source-patch.js';
|
|
4
4
|
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
5
5
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
6
|
-
import {BlockPreview} from './block-diff-preview.js';
|
|
6
|
+
import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
|
|
7
7
|
import type {LocationStatus} from './block-diff-preview.js';
|
|
8
8
|
import './project-source-diff.css';
|
|
9
9
|
|
|
@@ -15,17 +15,25 @@ export {createScratchBlocksDiffLocator} from './block-diff-location.js';
|
|
|
15
15
|
export type {ProjectBlockLocation, ProjectBlockLocator, ScratchBlockLocationWorkspace} from './block-diff-location.js';
|
|
16
16
|
export type {BlockPart} from './project-source-patch.js';
|
|
17
17
|
export {createProjectSourceDiff} from './project-source-patch.js';
|
|
18
|
+
export {currentCostume} from './project-assets.js';
|
|
19
|
+
export {assetFileName, organizeProjectChanges, projectPropertyLabel} from './project-change-model.js';
|
|
20
|
+
export type {ChangedFile, OrganizeProjectChangesInput, ProjectChangeList, ProjectChangeStatus, ProjectElementChange, ProjectElementKind,
|
|
21
|
+
ProjectTargetChanges} from './project-change-model.js';
|
|
22
|
+
export type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
18
23
|
export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
|
|
19
|
-
export type {
|
|
24
|
+
export type {BlockPreviewView, BlockPreviewViewport, ProjectBlockRenderer, ProjectBlockRendering, ScratchBlockDiffRendererOptions,
|
|
25
|
+
ScratchBlocksPort} from './scratch-block-diff-renderer.js';
|
|
26
|
+
export {createPreviewSync} from './block-diff-preview.js';
|
|
27
|
+
export type {PreviewSync} from './block-diff-preview.js';
|
|
20
28
|
|
|
21
29
|
const en = {
|
|
22
30
|
title: 'Block changes', before: 'Before', after: 'After', added: 'Added', removed: 'Removed', updated: 'Changed',
|
|
23
31
|
inputs: 'Inputs', fields: 'Fields', opcode: 'Block type', mutation: 'Block shape', position: 'Position',
|
|
24
32
|
connection: 'Connections', properties: 'Other block properties',
|
|
25
33
|
absent: 'No block on this side', omitted: (count: number) => `${count} unchanged blocks omitted from the change list`,
|
|
26
|
-
context: 'Highlight in the surrounding
|
|
34
|
+
context: 'Highlight in the surrounding code', more: 'Show more changes', noChanges: 'No block changes.',
|
|
27
35
|
locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
|
|
28
|
-
missingLocation: 'This version or block is unavailable in the Editor. Open its surrounding
|
|
36
|
+
missingLocation: 'This version or block is unavailable in the Editor. Open its surrounding code here.',
|
|
29
37
|
partLegend: 'Orange outlines mark changed inputs and fields.',
|
|
30
38
|
otherChanges: 'The project also has changes outside its block code.',
|
|
31
39
|
invalid: 'This diff could not be read. Use a full-context project diff, or provide the original project.',
|
|
@@ -36,9 +44,9 @@ const ja: typeof en = {
|
|
|
36
44
|
inputs: '入力値', fields: 'フィールド', opcode: 'ブロックの種類', mutation: 'ブロックの形', position: '位置',
|
|
37
45
|
connection: 'つなぎ方', properties: 'その他のブロック属性',
|
|
38
46
|
absent: 'この側にはブロックがありません', omitted: count => `変更のないブロック ${count} 個を一覧から省略`,
|
|
39
|
-
context: '
|
|
47
|
+
context: '元のコード内で強調', more: '続きを表示', noChanges: 'ブロックの変更はありません。',
|
|
40
48
|
locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
|
|
41
|
-
missingLocation: '
|
|
49
|
+
missingLocation: '編集画面に該当する版またはブロックがありません。元のコードをここで展開できます。',
|
|
42
50
|
partLegend: 'オレンジの枠は、変更された入力欄やフィールドです。',
|
|
43
51
|
otherChanges: 'ブロックのコード以外にも作品の変更があります。',
|
|
44
52
|
invalid: '差分を読み込めません。全体の文脈を含む差分を使うか、変更前の作品を指定してください。',
|
|
@@ -66,6 +74,7 @@ function ChangeCard({change, renderBlock, labels, onLocate, location}: {
|
|
|
66
74
|
onLocate?: (version: Version) => void; location?: {version: Version; status: LocationStatus};
|
|
67
75
|
}) {
|
|
68
76
|
const [withContext, setWithContext] = useState(false);
|
|
77
|
+
const {sync, toggle} = usePreviewSync(labels === en ? 'en' : 'ja');
|
|
69
78
|
const notes = [...new Set(change.fields.map(field => labels[propertyLabels.get(field) ?? 'properties']))];
|
|
70
79
|
const name = (change.after ?? change.before)!.target.name;
|
|
71
80
|
return <article className={`twgit-block-diff-card twgit-block-diff-${change.kind}`}>
|
|
@@ -77,9 +86,10 @@ function ChangeCard({change, renderBlock, labels, onLocate, location}: {
|
|
|
77
86
|
<div className="twgit-block-diff-sides">
|
|
78
87
|
{(['before', 'after'] as const).map(version => <BlockPreview key={version} side={change[version]} title={labels[version]}
|
|
79
88
|
renderBlock={renderBlock} withContext={withContext} labels={labels} parts={change.parts}
|
|
80
|
-
onLocate={onLocate && (() => onLocate(version))}
|
|
89
|
+
onLocate={onLocate && (() => onLocate(version))} version={version} sync={sync}
|
|
81
90
|
locationStatus={location?.version === version ? location.status : undefined}/>)}
|
|
82
91
|
</div>
|
|
92
|
+
{toggle}
|
|
83
93
|
<label><input type="checkbox" checked={withContext} onChange={event => setWithContext(event.currentTarget.checked)}/> {labels.context}</label>
|
|
84
94
|
</article>;
|
|
85
95
|
}
|
|
@@ -11,6 +11,9 @@ export interface ScriptReview {
|
|
|
11
11
|
export interface TargetReview {
|
|
12
12
|
name: string;
|
|
13
13
|
isStage: boolean;
|
|
14
|
+
/** The stage or sprite in each version, absent where it does not exist. */
|
|
15
|
+
before?: ProjectTarget;
|
|
16
|
+
after?: ProjectTarget;
|
|
14
17
|
scripts: ScriptReview[];
|
|
15
18
|
properties: string[];
|
|
16
19
|
}
|
|
@@ -106,7 +109,7 @@ export function compareProjectForReview(before: ProjectSource, after: ProjectSou
|
|
|
106
109
|
const properties = changedProperties({...a}, {...b}, 'blocks');
|
|
107
110
|
if (scripts.length || properties.length) {
|
|
108
111
|
const {name, isStage} = (b ?? a)!;
|
|
109
|
-
targets.push({name, isStage, scripts, properties});
|
|
112
|
+
targets.push({name, isStage, scripts, properties, before: a, after: b});
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
return {targets, properties: changedProperties(before, after, 'targets')};
|