@kubohiroya/turbowarp-plus-editor-project-model 0.1.1 → 0.2.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 +7 -2
- package/README.md +7 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/project-search-connections.d.ts +62 -0
- package/dist/project-search-connections.js +87 -0
- package/dist/project-search-replace.d.ts +50 -0
- package/dist/project-search-replace.js +183 -0
- package/dist/project-search.d.ts +136 -0
- package/dist/project-search.js +282 -0
- package/dist/sprite-variable-edit.d.ts +80 -3
- package/dist/sprite-variable-edit.js +55 -4
- package/package.json +13 -1
- package/src/index.ts +3 -0
- package/src/project-search-connections.ts +127 -0
- package/src/project-search-replace.ts +189 -0
- package/src/project-search.ts +328 -0
- package/src/sprite-variable-edit.ts +94 -7
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// The judgement for broadcast blocks lives in project-messages, which the layout's message
|
|
2
|
+
// section also uses, so the section and this search agree.
|
|
3
|
+
import { messageReference } from "./project-messages.js";
|
|
4
|
+
export const variableAccessLabels = {
|
|
5
|
+
read: ['読み取り', 'Read'],
|
|
6
|
+
write: ['変更', 'Write']
|
|
7
|
+
};
|
|
8
|
+
export const literalSearchPattern = (query, flags) => new RegExp(query.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
|
|
9
|
+
const headings = {
|
|
10
|
+
block: ['ブロック', 'Blocks'],
|
|
11
|
+
variable: ['変数の使用箇所', 'Variable uses'],
|
|
12
|
+
list: ['リストの使用箇所', 'List uses'],
|
|
13
|
+
message: ['メッセージの送受信', 'Broadcasts'],
|
|
14
|
+
argument: ['ブロックの引数値', 'Block argument values'],
|
|
15
|
+
extension: ['機能拡張の使用箇所', 'Extension uses'],
|
|
16
|
+
comment: ['コメント', 'Comments'],
|
|
17
|
+
costume: ['コスチューム', 'Costumes'],
|
|
18
|
+
backdrop: ['背景', 'Backdrops'],
|
|
19
|
+
sound: ['音', 'Sounds']
|
|
20
|
+
};
|
|
21
|
+
function* walkBlocks(target) {
|
|
22
|
+
const pending = target.blocks.getScripts().slice().reverse();
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
while (pending.length) {
|
|
25
|
+
const id = pending.pop();
|
|
26
|
+
if (seen.has(id))
|
|
27
|
+
continue;
|
|
28
|
+
seen.add(id);
|
|
29
|
+
const block = target.blocks.getBlock(id);
|
|
30
|
+
if (!block || block.shadow)
|
|
31
|
+
continue;
|
|
32
|
+
const inputs = [];
|
|
33
|
+
for (const [key, input] of Object.entries(block.inputs ?? {})) {
|
|
34
|
+
const child = input.block && target.blocks.getBlock(input.block);
|
|
35
|
+
if (child)
|
|
36
|
+
inputs.push([key, child]);
|
|
37
|
+
}
|
|
38
|
+
if (block.next)
|
|
39
|
+
pending.push(block.next);
|
|
40
|
+
for (let i = inputs.length - 1; i >= 0; i--) {
|
|
41
|
+
if (!inputs[i][1].shadow)
|
|
42
|
+
pending.push(inputs[i][1].id);
|
|
43
|
+
}
|
|
44
|
+
yield { block, inputs };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const stageProperties = new Set(['background #', 'backdrop #', 'backdrop name', 'volume']);
|
|
48
|
+
const spriteProperties = new Set(['x position', 'y position', 'direction', 'costume #', 'costume name', 'size', 'volume']);
|
|
49
|
+
const listAccesses = new Map([
|
|
50
|
+
['data_listcontents', 'read'], ['data_itemoflist', 'read'], ['data_itemnumoflist', 'read'],
|
|
51
|
+
['data_lengthoflist', 'read'], ['data_listcontainsitem', 'read'],
|
|
52
|
+
['data_addtolist', 'write'], ['data_deleteoflist', 'write'], ['data_deletealloflist', 'write'],
|
|
53
|
+
['data_insertatlist', 'write'], ['data_replaceitemoflist', 'write']
|
|
54
|
+
]);
|
|
55
|
+
function variableAccess(target, block, inputs, stage, sprites) {
|
|
56
|
+
if (block.opcode === 'sensing_of') {
|
|
57
|
+
const menu = inputs.find(([key]) => key === 'OBJECT')?.[1];
|
|
58
|
+
if (!menu?.shadow || menu.opcode !== 'sensing_of_object_menu')
|
|
59
|
+
return;
|
|
60
|
+
const object = menu.fields?.OBJECT?.value;
|
|
61
|
+
const name = block.fields?.PROPERTY?.value;
|
|
62
|
+
if (typeof object !== 'string' || typeof name !== 'string')
|
|
63
|
+
return;
|
|
64
|
+
const owner = object === '_stage_' ? stage : sprites.get(object);
|
|
65
|
+
if (!owner || (owner.isStage ? stageProperties : spriteProperties).has(name))
|
|
66
|
+
return;
|
|
67
|
+
const variableId = Object.keys(owner.variables).find(id => {
|
|
68
|
+
const variable = owner.variables[id];
|
|
69
|
+
return variable.type === '' && variable.name === name;
|
|
70
|
+
});
|
|
71
|
+
if (variableId)
|
|
72
|
+
return { owner, variableId, access: 'read' };
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const listAccess = listAccesses.get(block.opcode);
|
|
76
|
+
const access = listAccess ?? (block.opcode === 'data_variable' ? 'read' :
|
|
77
|
+
block.opcode === 'data_setvariableto' || block.opcode === 'data_changevariableby' ? 'write' : undefined);
|
|
78
|
+
const variableId = block.fields?.[listAccess ? 'LIST' : 'VARIABLE']?.id;
|
|
79
|
+
if (!access || !variableId)
|
|
80
|
+
return;
|
|
81
|
+
const owner = Object.hasOwn(target.variables, variableId) ? target : stage;
|
|
82
|
+
if (owner?.variables[variableId]?.type === (listAccess ? 'list' : ''))
|
|
83
|
+
return { owner, variableId, access };
|
|
84
|
+
}
|
|
85
|
+
const originalSprites = (project) => new Map(project.targets.filter(target => target.isOriginal && !target.isStage).map(target => [target.getName(), target]));
|
|
86
|
+
export function countVariableAccesses(project, ownerId) {
|
|
87
|
+
const stage = project.targets.find(target => target.isStage);
|
|
88
|
+
const sprites = originalSprites(project);
|
|
89
|
+
const counts = new Map();
|
|
90
|
+
for (const target of project.targets) {
|
|
91
|
+
if (!target.isOriginal)
|
|
92
|
+
continue;
|
|
93
|
+
for (const { block, inputs } of walkBlocks(target)) {
|
|
94
|
+
const use = variableAccess(target, block, inputs, stage, sprites);
|
|
95
|
+
if (use?.owner.id !== ownerId)
|
|
96
|
+
continue;
|
|
97
|
+
const count = counts.get(use.variableId) ?? { read: 0, write: 0 };
|
|
98
|
+
count[use.access]++;
|
|
99
|
+
counts.set(use.variableId, count);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return counts;
|
|
103
|
+
}
|
|
104
|
+
export function searchProject(project, messages, query, japanese, kinds = new Set(Object.keys(headings)), variableSearch) {
|
|
105
|
+
const groups = new Map(Object.entries(headings).map(([kind, titles]) => [
|
|
106
|
+
kind, { kind: kind, title: titles[japanese ? 0 : 1], results: [] }
|
|
107
|
+
]));
|
|
108
|
+
const search = query.trim().toLocaleLowerCase();
|
|
109
|
+
if (!search && !variableSearch)
|
|
110
|
+
return [];
|
|
111
|
+
const stage = project.targets.find(target => target.isStage);
|
|
112
|
+
const sprites = variableSearch || kinds.has('variable') ? originalSprites(project) : undefined;
|
|
113
|
+
const searchBlocks = variableSearch || ['block', 'variable', 'list', 'message', 'argument', 'extension']
|
|
114
|
+
.some(kind => kinds.has(kind));
|
|
115
|
+
const templates = new Map();
|
|
116
|
+
const matches = (value) => value.toLocaleLowerCase().includes(search);
|
|
117
|
+
const describeOpcode = (opcode) => {
|
|
118
|
+
const cached = templates.get(opcode);
|
|
119
|
+
if (cached)
|
|
120
|
+
return cached;
|
|
121
|
+
const key = opcode.toUpperCase();
|
|
122
|
+
const alternate = key.replace(/^OPERATOR_/, 'OPERATORS_').replace(/_AS_CLONE$/, 'ASCLONE');
|
|
123
|
+
const vmLabel = project.getLabelForOpcode?.(opcode);
|
|
124
|
+
const vmText = vmLabel?.label;
|
|
125
|
+
const separator = vmLabel?.category === 'extension' ? vmText?.indexOf(': ') ?? -1 : -1;
|
|
126
|
+
const template = {
|
|
127
|
+
text: messages[key] ?? messages[alternate] ??
|
|
128
|
+
(vmText && separator > 0 ? vmText.slice(separator + 2) : vmText) ?? opcode.replaceAll('_', ' '),
|
|
129
|
+
extension: vmText && separator > 0 ? vmText.slice(0, separator) : undefined
|
|
130
|
+
};
|
|
131
|
+
templates.set(opcode, template);
|
|
132
|
+
return template;
|
|
133
|
+
};
|
|
134
|
+
const fieldText = (target, key, field) => {
|
|
135
|
+
const variable = field.id && (key === 'VARIABLE' || key === 'LIST') ?
|
|
136
|
+
target.variables[field.id] ?? stage?.variables[field.id] : undefined;
|
|
137
|
+
return String(variable?.name ?? field.value ?? '');
|
|
138
|
+
};
|
|
139
|
+
const describe = (target, block, inputs, template) => {
|
|
140
|
+
const values = [
|
|
141
|
+
...Object.entries(block.fields ?? {}).map(([key, field]) => fieldText(target, key, field)),
|
|
142
|
+
...inputs.filter(([key]) => !key.startsWith('SUBSTACK')).map(([, child]) => child.shadow ? Object.values(child.fields ?? {}).map(field => String(field.value ?? '')).join(' ') :
|
|
143
|
+
Object.entries(child.fields ?? {}).map(([key, field]) => fieldText(target, key, field)).join(' ') || '…')
|
|
144
|
+
];
|
|
145
|
+
if (block.opcode === 'event_whenflagclicked')
|
|
146
|
+
values.unshift(japanese ? '緑の旗' : 'green flag');
|
|
147
|
+
if (/%\d+/.test(template))
|
|
148
|
+
return template.replace(/%(\d+)/g, (_, index) => values[Number(index) - 1] ?? '…').trim();
|
|
149
|
+
return [template, ...values].filter(Boolean).join(' ');
|
|
150
|
+
};
|
|
151
|
+
for (const target of project.targets) {
|
|
152
|
+
if (!target.isOriginal)
|
|
153
|
+
continue;
|
|
154
|
+
for (const { block, inputs } of searchBlocks ? walkBlocks(target) : []) {
|
|
155
|
+
const use = sprites && variableAccess(target, block, inputs, stage, sprites);
|
|
156
|
+
if (variableSearch && (!use || use.owner.id !== variableSearch.ownerId ||
|
|
157
|
+
use.variableId !== variableSearch.variableId || use.access !== variableSearch.access))
|
|
158
|
+
continue;
|
|
159
|
+
const variable = block.fields?.VARIABLE;
|
|
160
|
+
const list = block.fields?.LIST;
|
|
161
|
+
const message = messageReference(target, block, stage);
|
|
162
|
+
const kind = variable ? 'variable' : list ? 'list' : 'message';
|
|
163
|
+
const reference = variable ?? list ?? (message && { id: message.id, value: message.fieldValue });
|
|
164
|
+
const name = variable || list ? fieldText(target, variable ? 'VARIABLE' : 'LIST', reference) : message?.name;
|
|
165
|
+
const opcode = describeOpcode(block.opcode);
|
|
166
|
+
const procedure = block.mutation?.proccode ??
|
|
167
|
+
inputs.find(([key]) => key === 'custom_block')?.[1].mutation?.proccode;
|
|
168
|
+
const customLabel = block.mutation?.blockInfo?.text;
|
|
169
|
+
const template = procedure ? procedure.replace(/%[sbn]/g, '…') :
|
|
170
|
+
typeof customLabel === 'string' ? customLabel : opcode.text;
|
|
171
|
+
const label = procedure ? template : describe(target, block, inputs, template);
|
|
172
|
+
const add = (kind, label, detail, edit, access) => groups.get(kind).results.push({ kind, target, block, label, detail, edit, variableAccess: access });
|
|
173
|
+
if (variableSearch && use) {
|
|
174
|
+
add(list ? 'list' : 'variable', label, use.owner.variables[use.variableId].name, undefined, use.access);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (kinds.has('block') && (matches(template) || matches(block.opcode) ||
|
|
178
|
+
(block.opcode === 'event_whenflagclicked' && matches(japanese ? '緑の旗' : 'green flag')))) {
|
|
179
|
+
add('block', label, undefined, procedure ? { type: 'procedure', name: procedure } : undefined);
|
|
180
|
+
}
|
|
181
|
+
if (kinds.has(kind) && name && matches(name)) {
|
|
182
|
+
const referenceId = reference?.id;
|
|
183
|
+
const owner = referenceId &&
|
|
184
|
+
(target.variables[referenceId] ? target : stage?.variables[referenceId] ? stage : undefined);
|
|
185
|
+
const scope = (variable || list) && owner &&
|
|
186
|
+
(owner.isStage ? (japanese ? '全体' : 'Global') : (japanese ? 'このスプライト' : 'Local'));
|
|
187
|
+
const model = owner && referenceId ? owner.variables[referenceId] : undefined;
|
|
188
|
+
const edit = owner && referenceId && model && !model.isCloud ? {
|
|
189
|
+
type: 'name', id: referenceId, owner, name: model.name,
|
|
190
|
+
variableType: model.type ?? (list ? 'list' : message ? 'broadcast_msg' : '')
|
|
191
|
+
} : undefined;
|
|
192
|
+
add(kind, name, scope || label, edit, use?.access);
|
|
193
|
+
}
|
|
194
|
+
else if (use && !variable && !list && kinds.has('variable') && matches(use.owner.variables[use.variableId].name)) {
|
|
195
|
+
add('variable', use.owner.variables[use.variableId].name, label, undefined, use.access);
|
|
196
|
+
}
|
|
197
|
+
if (kinds.has('extension') && opcode.extension && matches(opcode.extension))
|
|
198
|
+
add('extension', opcode.extension, label);
|
|
199
|
+
if (kinds.has('argument')) {
|
|
200
|
+
const addArguments = (owner) => {
|
|
201
|
+
for (const [key, field] of Object.entries(owner.fields ?? {})) {
|
|
202
|
+
if (field === reference || (message && key === 'BROADCAST_OPTION'))
|
|
203
|
+
continue;
|
|
204
|
+
const value = String(field.value ?? '');
|
|
205
|
+
if (value && matches(value))
|
|
206
|
+
add('argument', value, label, { type: 'field', id: owner.id, field: key, value });
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
addArguments(block);
|
|
210
|
+
for (const [, child] of inputs) {
|
|
211
|
+
if (child.shadow)
|
|
212
|
+
addArguments(child);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (variableSearch)
|
|
217
|
+
continue;
|
|
218
|
+
if (kinds.has('comment')) {
|
|
219
|
+
for (const [commentId, comment] of Object.entries(target.comments ?? {})) {
|
|
220
|
+
if (matches(comment.text))
|
|
221
|
+
groups.get('comment').results.push({
|
|
222
|
+
kind: 'comment', target, comment, commentId, label: comment.text, edit: { type: 'comment', text: comment.text }
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const assets = [
|
|
227
|
+
[target.isStage ? 'backdrop' : 'costume', target.getCostumes()],
|
|
228
|
+
['sound', target.getSounds()]
|
|
229
|
+
];
|
|
230
|
+
for (const [kind, items] of assets) {
|
|
231
|
+
if (!kinds.has(kind))
|
|
232
|
+
continue;
|
|
233
|
+
for (const asset of items) {
|
|
234
|
+
if (matches(asset.name))
|
|
235
|
+
groups.get(kind).results.push({ kind, asset, label: asset.name, target });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return [...groups.values()].filter(group => group.results.length);
|
|
240
|
+
}
|
|
241
|
+
export function searchYamlDocuments(documents, query, japanese) {
|
|
242
|
+
const results = [];
|
|
243
|
+
const search = query.trim();
|
|
244
|
+
if (!search)
|
|
245
|
+
return results;
|
|
246
|
+
const pattern = literalSearchPattern(search, 'giu');
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
const duplicates = new Set();
|
|
249
|
+
for (const { filename } of documents) {
|
|
250
|
+
if (seen.has(filename))
|
|
251
|
+
duplicates.add(filename);
|
|
252
|
+
seen.add(filename);
|
|
253
|
+
}
|
|
254
|
+
for (const document of documents) {
|
|
255
|
+
let line = 1;
|
|
256
|
+
let lineStart = 0;
|
|
257
|
+
let newline = document.source.indexOf('\n');
|
|
258
|
+
for (const match of document.source.matchAll(pattern)) {
|
|
259
|
+
const start = match.index;
|
|
260
|
+
const end = start + match[0].length;
|
|
261
|
+
while (newline >= 0 && newline < start) {
|
|
262
|
+
line++;
|
|
263
|
+
lineStart = newline + 1;
|
|
264
|
+
newline = document.source.indexOf('\n', lineStart);
|
|
265
|
+
}
|
|
266
|
+
const column = start - lineStart + 1;
|
|
267
|
+
const nextNewline = document.source.indexOf('\n', end);
|
|
268
|
+
const before = Math.max(lineStart, start - 48);
|
|
269
|
+
const lineEnd = nextNewline < 0 ? document.source.length : nextNewline;
|
|
270
|
+
const after = Math.min(lineEnd, end + 48);
|
|
271
|
+
const position = japanese ? `${line} 行 ${column} 列` : `Line ${line}, column ${column}`;
|
|
272
|
+
const identity = duplicates.has(document.filename) ?
|
|
273
|
+
`${document.reference.providerId}/${document.reference.documentId}` : '';
|
|
274
|
+
results.push({
|
|
275
|
+
kind: 'yaml', document, label: `${before > lineStart ? '…' : ''}${document.source.slice(before, after)}${after < lineEnd ? '…' : ''}`,
|
|
276
|
+
detail: [position, identity].filter(Boolean).join(' · '),
|
|
277
|
+
range: { start, end, expectedText: match[0] }
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
type VariableType = '' | 'list';
|
|
1
2
|
export type SpriteVariableEdit = {
|
|
2
3
|
type: 'create';
|
|
3
4
|
name: string;
|
|
5
|
+
variableType?: '';
|
|
6
|
+
isCloud?: boolean;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'create';
|
|
9
|
+
name: string;
|
|
10
|
+
variableType: 'list';
|
|
11
|
+
isCloud?: false;
|
|
4
12
|
} | {
|
|
5
13
|
type: 'rename';
|
|
6
14
|
id: string;
|
|
@@ -9,7 +17,7 @@ export type SpriteVariableEdit = {
|
|
|
9
17
|
type: 'delete';
|
|
10
18
|
id: string;
|
|
11
19
|
};
|
|
12
|
-
export type SpriteVariableEditError = 'emptyName' | 'duplicateName' | 'inUse' | 'unavailable';
|
|
20
|
+
export type SpriteVariableEditError = 'emptyName' | 'duplicateName' | 'inUse' | 'unavailable' | 'cloudLimit';
|
|
13
21
|
type Variable = {
|
|
14
22
|
name: string;
|
|
15
23
|
type: string;
|
|
@@ -29,9 +37,9 @@ type VariableEvent = {
|
|
|
29
37
|
type: 'var_create';
|
|
30
38
|
varId: string;
|
|
31
39
|
varName: string;
|
|
32
|
-
varType:
|
|
40
|
+
varType: VariableType;
|
|
33
41
|
isLocal: boolean;
|
|
34
|
-
isCloud:
|
|
42
|
+
isCloud: boolean;
|
|
35
43
|
} | {
|
|
36
44
|
type: 'var_rename';
|
|
37
45
|
varId: string;
|
|
@@ -58,9 +66,78 @@ type VariableProject = {
|
|
|
58
66
|
targets: readonly VariableTarget[];
|
|
59
67
|
getEditingTarget(): VariableTarget | null;
|
|
60
68
|
getTargetForStage(): VariableTarget | null;
|
|
69
|
+
canAddCloudVariable?(): boolean;
|
|
61
70
|
};
|
|
62
71
|
refreshWorkspace(): void;
|
|
63
72
|
};
|
|
73
|
+
export type SpriteListItem = string | number | boolean;
|
|
74
|
+
export type SpriteVariableValue = string | number | readonly SpriteListItem[];
|
|
75
|
+
type VariableValue = {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
type: string;
|
|
79
|
+
value: unknown;
|
|
80
|
+
isCloud?: boolean;
|
|
81
|
+
_monitorUpToDate?: boolean;
|
|
82
|
+
};
|
|
83
|
+
export declare function readSpriteVariable(variable: VariableValue): {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
value: readonly SpriteListItem[];
|
|
87
|
+
isCloud?: undefined;
|
|
88
|
+
} | {
|
|
89
|
+
id: string;
|
|
90
|
+
name: string;
|
|
91
|
+
value: string | number;
|
|
92
|
+
isCloud: boolean | undefined;
|
|
93
|
+
} | undefined;
|
|
94
|
+
export declare function writeSpriteVariableValue(vm: {
|
|
95
|
+
runtime: {
|
|
96
|
+
getTargetById(id: string): {
|
|
97
|
+
variables: Record<string, VariableValue>;
|
|
98
|
+
} | undefined;
|
|
99
|
+
};
|
|
100
|
+
setVariableValue(targetId: string, variableId: string, value: SpriteVariableValue): boolean;
|
|
101
|
+
}, targetId: string, id: string, value: SpriteVariableValue): boolean;
|
|
64
102
|
export declare const cloudVariablePrefix = "\u2601 ";
|
|
103
|
+
type VariableMonitorBlock = {
|
|
104
|
+
id: string;
|
|
105
|
+
opcode: string;
|
|
106
|
+
isMonitored?: boolean;
|
|
107
|
+
targetId: string | null;
|
|
108
|
+
};
|
|
109
|
+
type VariableMonitorRuntime = {
|
|
110
|
+
getTargetById(id: string): VariableTarget | undefined;
|
|
111
|
+
getMonitorState(): {
|
|
112
|
+
get(id: string): {
|
|
113
|
+
visible: boolean;
|
|
114
|
+
} | undefined;
|
|
115
|
+
};
|
|
116
|
+
monitorBlocks: {
|
|
117
|
+
getBlock(id: string): VariableMonitorBlock | undefined;
|
|
118
|
+
createBlock(block: VariableMonitorBlock & {
|
|
119
|
+
fields: Record<string, {
|
|
120
|
+
id: string;
|
|
121
|
+
name: string;
|
|
122
|
+
value: string;
|
|
123
|
+
variableType: '' | 'list';
|
|
124
|
+
}>;
|
|
125
|
+
inputs: Record<string, never>;
|
|
126
|
+
next: null;
|
|
127
|
+
parent: null;
|
|
128
|
+
topLevel: true;
|
|
129
|
+
shadow: false;
|
|
130
|
+
x: number;
|
|
131
|
+
y: number;
|
|
132
|
+
}): void;
|
|
133
|
+
changeBlock(change: {
|
|
134
|
+
id: string;
|
|
135
|
+
element: 'checkbox';
|
|
136
|
+
value: boolean;
|
|
137
|
+
}): void;
|
|
138
|
+
};
|
|
139
|
+
emitProjectChanged(): void;
|
|
140
|
+
};
|
|
141
|
+
export declare function showSpriteVariableOnStage(runtime: VariableMonitorRuntime, targetId: string, id: string, visible: boolean): void;
|
|
65
142
|
export declare function editSpriteVariable(vm: VariableProject, targetId: string, edit: SpriteVariableEdit): SpriteVariableEditError | void;
|
|
66
143
|
export {};
|
|
@@ -1,4 +1,46 @@
|
|
|
1
|
+
export function readSpriteVariable(variable) {
|
|
2
|
+
const { id, name, type, value, isCloud } = variable;
|
|
3
|
+
if (type === 'list' && Array.isArray(value))
|
|
4
|
+
return { id, name, value: value };
|
|
5
|
+
if (type === '' && (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')) {
|
|
6
|
+
return { id, name, value: typeof value === 'boolean' ? String(value) : value, isCloud };
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function writeSpriteVariableValue(vm, targetId, id, value) {
|
|
10
|
+
const variable = vm.runtime.getTargetById(targetId)?.variables[id];
|
|
11
|
+
const list = typeof value === 'object';
|
|
12
|
+
if (!variable || variable.type !== (list ? 'list' : '') || !vm.setVariableValue(targetId, id, value))
|
|
13
|
+
return false;
|
|
14
|
+
if (list)
|
|
15
|
+
variable._monitorUpToDate = false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
1
18
|
export const cloudVariablePrefix = '☁ ';
|
|
19
|
+
export function showSpriteVariableOnStage(runtime, targetId, id, visible) {
|
|
20
|
+
const owner = runtime.getTargetById(targetId);
|
|
21
|
+
const variable = owner?.variables[id];
|
|
22
|
+
if (!owner?.isOriginal || !variable || !['', 'list'].includes(variable.type) ||
|
|
23
|
+
Boolean(runtime.getMonitorState().get(id)?.visible) === visible)
|
|
24
|
+
return;
|
|
25
|
+
const list = variable.type === 'list';
|
|
26
|
+
const opcode = list ? 'data_listcontents' : 'data_variable';
|
|
27
|
+
const field = list ? 'LIST' : 'VARIABLE';
|
|
28
|
+
const { monitorBlocks } = runtime;
|
|
29
|
+
let block = monitorBlocks.getBlock(id);
|
|
30
|
+
if (!block) {
|
|
31
|
+
monitorBlocks.createBlock({
|
|
32
|
+
id, opcode, targetId: owner.isStage ? null : owner.id, inputs: {},
|
|
33
|
+
fields: { [field]: { id, name: field, value: variable.name, variableType: list ? 'list' : '' } },
|
|
34
|
+
next: null, parent: null, topLevel: true, shadow: false, x: 0, y: 0
|
|
35
|
+
});
|
|
36
|
+
block = monitorBlocks.getBlock(id);
|
|
37
|
+
}
|
|
38
|
+
if (block?.opcode !== opcode)
|
|
39
|
+
return;
|
|
40
|
+
block.isMonitored = !visible;
|
|
41
|
+
monitorBlocks.changeBlock({ id, element: 'checkbox', value: visible });
|
|
42
|
+
runtime.emitProjectChanged();
|
|
43
|
+
}
|
|
2
44
|
function isUsed(target, targets, id, name) {
|
|
3
45
|
const targetName = target.isStage ? '_stage_' : target.getName();
|
|
4
46
|
return targets.some(({ blocks }) => {
|
|
@@ -30,20 +72,29 @@ export function editSpriteVariable(vm, targetId, edit) {
|
|
|
30
72
|
target.blocks.blocklyListen({ type: 'var_delete', varId: edit.id });
|
|
31
73
|
}
|
|
32
74
|
else {
|
|
33
|
-
const
|
|
75
|
+
const variableType = edit.type === 'create' ? edit.variableType ?? '' : '';
|
|
76
|
+
const cloud = edit.type === 'create' ? variableType === '' && edit.isCloud === true : variable?.isCloud === true;
|
|
77
|
+
const name = (cloud && edit.name.startsWith(cloudVariablePrefix) ?
|
|
78
|
+
edit.name.slice(cloudVariablePrefix.length) : edit.name).trim();
|
|
34
79
|
if (!name)
|
|
35
80
|
return 'emptyName';
|
|
36
|
-
const fullName =
|
|
81
|
+
const fullName = cloud ? cloudVariablePrefix + name : name;
|
|
37
82
|
if (fullName === variable?.name)
|
|
38
83
|
return;
|
|
39
84
|
const scope = target.isStage ? runtime.targets.filter(item => item.isStage || item.isOriginal) :
|
|
40
85
|
[target, runtime.getTargetForStage()];
|
|
41
|
-
if (scope.some(owner => owner && Object.values(owner.variables).some(item => item.type ===
|
|
86
|
+
if (scope.some(owner => owner && Object.values(owner.variables).some(item => item.type === variableType && item.name === fullName))) {
|
|
42
87
|
return 'duplicateName';
|
|
43
88
|
}
|
|
44
89
|
if (edit.type === 'create') {
|
|
90
|
+
if (cloud && !target.isStage)
|
|
91
|
+
return 'unavailable';
|
|
92
|
+
if (cloud && !runtime.canAddCloudVariable?.())
|
|
93
|
+
return 'cloudLimit';
|
|
45
94
|
const varId = Array.from(crypto.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('');
|
|
46
|
-
target.blocks.blocklyListen({
|
|
95
|
+
target.blocks.blocklyListen({
|
|
96
|
+
type: 'var_create', varId, varName: fullName, varType: variableType, isLocal: !target.isStage, isCloud: cloud
|
|
97
|
+
});
|
|
47
98
|
}
|
|
48
99
|
else {
|
|
49
100
|
target.blocks.blocklyListen({ type: 'var_rename', varId: edit.id, newName: fullName });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubohiroya/turbowarp-plus-editor-project-model",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "UI-free reads and edits of a running Scratch project for TurboWarp Plus Editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -21,6 +21,18 @@
|
|
|
21
21
|
"./sprite-variable-edit": {
|
|
22
22
|
"types": "./dist/sprite-variable-edit.d.ts",
|
|
23
23
|
"default": "./dist/sprite-variable-edit.js"
|
|
24
|
+
},
|
|
25
|
+
"./project-search": {
|
|
26
|
+
"types": "./dist/project-search.d.ts",
|
|
27
|
+
"default": "./dist/project-search.js"
|
|
28
|
+
},
|
|
29
|
+
"./project-search-replace": {
|
|
30
|
+
"types": "./dist/project-search-replace.d.ts",
|
|
31
|
+
"default": "./dist/project-search-replace.js"
|
|
32
|
+
},
|
|
33
|
+
"./project-search-connections": {
|
|
34
|
+
"types": "./dist/project-search-connections.d.ts",
|
|
35
|
+
"default": "./dist/project-search-connections.js"
|
|
24
36
|
}
|
|
25
37
|
},
|
|
26
38
|
"files": [
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {ProjectSearchResult, SearchProject, VariableSearch, YamlSearchDocument, YamlSearchResult} from './project-search.ts';
|
|
2
|
+
import type {ReplaceBlockly, ReplaceWorkspace} from './project-search-replace.ts';
|
|
3
|
+
|
|
4
|
+
export type SearchDocuments = {
|
|
5
|
+
getSearchableYamlDocuments(): readonly YamlSearchDocument[];
|
|
6
|
+
openYamlPreview(reference: YamlSearchDocument['reference'], range: YamlSearchResult['range']): boolean;
|
|
7
|
+
stageYamlReplacement?(reference: YamlSearchDocument['reference'], range: YamlSearchResult['range'],
|
|
8
|
+
source: string, text: string): boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type SearchView = {id: string; groupId: string; targetId: string; section: number};
|
|
12
|
+
type SearchViews = {
|
|
13
|
+
views(): SearchView[];
|
|
14
|
+
currentView(): SearchView | undefined;
|
|
15
|
+
activateView(id: string): boolean;
|
|
16
|
+
select(id: string): void;
|
|
17
|
+
setViewSection(id: string, section: number): boolean;
|
|
18
|
+
selectAsset(id: string, kind: 'costumes' | 'sounds', index: number): void;
|
|
19
|
+
};
|
|
20
|
+
type Target = ProjectSearchResult['target'];
|
|
21
|
+
export type SearchNavigation = {
|
|
22
|
+
getViews(): SearchViews | null;
|
|
23
|
+
getTarget(id: string): Target | undefined;
|
|
24
|
+
getEditingTarget(): Target | undefined;
|
|
25
|
+
centerBlock(id: string): void;
|
|
26
|
+
centerComment(id: string, blockId?: string | null): void;
|
|
27
|
+
replacementContext?(): {workspace: ReplaceWorkspace; Blockly: ReplaceBlockly} | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type SearchConnection = {
|
|
31
|
+
navigation?: SearchNavigation;
|
|
32
|
+
documents?: SearchDocuments;
|
|
33
|
+
openReplace?: () => void;
|
|
34
|
+
openVariable?: (filter: VariableSearch) => void;
|
|
35
|
+
listeners: Set<() => void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const connections = new WeakMap<SearchProject, SearchConnection>();
|
|
39
|
+
|
|
40
|
+
export function connectionFor(project: SearchProject) {
|
|
41
|
+
let connection = connections.get(project);
|
|
42
|
+
if (!connection) {
|
|
43
|
+
connection = {listeners: new Set()};
|
|
44
|
+
connections.set(project, connection);
|
|
45
|
+
}
|
|
46
|
+
return connection;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Open a target's view in the active group and switch it to a section. */
|
|
50
|
+
export function activateTargetView(navigation: SearchNavigation, targetId: string, section: number) {
|
|
51
|
+
const views = navigation.getViews();
|
|
52
|
+
if (!views) return undefined;
|
|
53
|
+
const groupId = views.currentView()?.groupId;
|
|
54
|
+
const local = views.views().find(view => view.groupId === groupId && view.targetId === targetId);
|
|
55
|
+
if (local) views.activateView(local.id);
|
|
56
|
+
else views.select(targetId);
|
|
57
|
+
const destination = views.currentView();
|
|
58
|
+
if (destination?.targetId !== targetId) return undefined;
|
|
59
|
+
views.setViewSection(destination.id, section);
|
|
60
|
+
return {views, viewId: destination.id, section};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function connectProjectSearchNavigation(project: SearchProject, navigation: SearchNavigation) {
|
|
64
|
+
const connection = connectionFor(project);
|
|
65
|
+
connection.navigation = navigation;
|
|
66
|
+
connection.listeners.forEach(listener => listener());
|
|
67
|
+
return () => {
|
|
68
|
+
if (connection.navigation !== navigation) return;
|
|
69
|
+
connection.navigation = undefined;
|
|
70
|
+
connection.listeners.forEach(listener => listener());
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function connectProjectSearchDocuments(project: SearchProject, documents: SearchDocuments) {
|
|
75
|
+
const connection = connectionFor(project);
|
|
76
|
+
connection.documents = documents;
|
|
77
|
+
connection.listeners.forEach(listener => listener());
|
|
78
|
+
return () => {
|
|
79
|
+
if (connection.documents !== documents) return;
|
|
80
|
+
connection.documents = undefined;
|
|
81
|
+
connection.listeners.forEach(listener => listener());
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function connectProjectReplaceOpener(project: SearchProject, open: () => void) {
|
|
86
|
+
const connection = connectionFor(project);
|
|
87
|
+
connection.openReplace = open;
|
|
88
|
+
return () => {
|
|
89
|
+
if (connection.openReplace === open) connection.openReplace = undefined;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function projectReplaceOpener(project?: SearchProject) {
|
|
94
|
+
return project && connections.get(project)?.openReplace;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function connectVariableSearchOpener(project: SearchProject, open: (filter: VariableSearch) => void) {
|
|
98
|
+
const connection = connectionFor(project);
|
|
99
|
+
connection.openVariable = open;
|
|
100
|
+
return () => {
|
|
101
|
+
if (connection.openVariable === open) connection.openVariable = undefined;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function openVariableSearch(project: SearchProject, filter: VariableSearch) {
|
|
106
|
+
connections.get(project)?.openVariable?.(filter);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const replaceKey = 'F';
|
|
110
|
+
|
|
111
|
+
export function isMacPlatform() {
|
|
112
|
+
return typeof navigator !== 'undefined' && /^(Mac|iP)/.test(navigator.platform ?? '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Search is the platform's own find shortcut, so replace is the shifted form of it: ⇧⌘F on macOS
|
|
116
|
+
// and Ctrl+Shift+F elsewhere.
|
|
117
|
+
export const projectReplaceShortcut = {
|
|
118
|
+
keys: `Control+Shift+${replaceKey}`,
|
|
119
|
+
macKeys: `Meta+Shift+${replaceKey}`,
|
|
120
|
+
label: `Ctrl+Shift+${replaceKey}`,
|
|
121
|
+
macLabel: `⇧⌘${replaceKey}`,
|
|
122
|
+
matches(event: KeyboardEvent) {
|
|
123
|
+
const primary = isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
|
|
124
|
+
return primary && event.shiftKey && !event.altKey && !event.isComposing &&
|
|
125
|
+
event.key.toUpperCase() === replaceKey;
|
|
126
|
+
}
|
|
127
|
+
};
|