@kubohiroya/turbowarp-plus-editor-project-model 0.1.2 → 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/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
|
@@ -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
|
+
}
|
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
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {literalSearchPattern, type ProjectSearchResult, type SearchProject, type SearchResult} from './project-search.ts';
|
|
2
|
+
|
|
3
|
+
type Field = {
|
|
4
|
+
getValue(): unknown;
|
|
5
|
+
setValue(value: string): void;
|
|
6
|
+
callValidator(value: string): unknown;
|
|
7
|
+
getOptions?(): ReadonlyArray<readonly [unknown, unknown]>;
|
|
8
|
+
};
|
|
9
|
+
type Comment = {getText(): string; setText(value: string): void};
|
|
10
|
+
type Block = {
|
|
11
|
+
isEditable(): boolean;
|
|
12
|
+
getField(name: string): Field | undefined;
|
|
13
|
+
comment?: Comment;
|
|
14
|
+
mutationToDom(): Element;
|
|
15
|
+
};
|
|
16
|
+
type RenameEvent = {type: string; varId?: string; newName?: string};
|
|
17
|
+
export type ReplaceWorkspace = {
|
|
18
|
+
options: {readOnly: boolean};
|
|
19
|
+
isDragging(): boolean;
|
|
20
|
+
getBlockById(id: string): Block | null;
|
|
21
|
+
getCommentById(id: string): Comment | null;
|
|
22
|
+
getVariableById(id: string): {name: string} | null;
|
|
23
|
+
renameVariableById(id: string, name: string): void;
|
|
24
|
+
addChangeListener(listener: (event: RenameEvent) => void): void;
|
|
25
|
+
};
|
|
26
|
+
export type ReplaceBlockly = {
|
|
27
|
+
FieldTextInput: abstract new (...args: never[]) => Field;
|
|
28
|
+
FieldDropdown: abstract new (...args: never[]) => Field;
|
|
29
|
+
FieldNumber: abstract new (...args: never[]) => Field;
|
|
30
|
+
Procedures: {
|
|
31
|
+
getPrototypeBlock(name: string, workspace: ReplaceWorkspace): Block | null;
|
|
32
|
+
mutateCallersAndPrototype(name: string, workspace: ReplaceWorkspace, mutation: Element): void;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export type ReplaceOutcome = 'applied' | 'staged' | 'stale' | 'conflict' | 'invalid' | 'read-only';
|
|
36
|
+
|
|
37
|
+
function spliceMatch(value: string, pattern: RegExp, replacement: string) {
|
|
38
|
+
const match = pattern.exec(value);
|
|
39
|
+
if (match) return value.slice(0, match.index) + replacement + value.slice(match.index + match[0].length);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function replaceProcedureLabel(name: string, pattern: RegExp, replacement: string) {
|
|
43
|
+
if (/%[sbn]/.test(replacement)) return;
|
|
44
|
+
const parts = name.split(/(%[sbn])/);
|
|
45
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
46
|
+
const changed = spliceMatch(parts[i], pattern, replacement);
|
|
47
|
+
if (changed === undefined) continue;
|
|
48
|
+
parts[i] = changed;
|
|
49
|
+
const next = parts.join('');
|
|
50
|
+
if (JSON.stringify(next.match(/%[sbn]/g)) === JSON.stringify(name.match(/%[sbn]/g))) return next;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function replacementText(result: SearchResult, query: string, replacement: string) {
|
|
56
|
+
if (!query.trim()) return;
|
|
57
|
+
if (result.kind === 'yaml') return replacement !== result.range.expectedText ? replacement : undefined;
|
|
58
|
+
const edit = result.edit;
|
|
59
|
+
if (!edit) return;
|
|
60
|
+
const pattern = literalSearchPattern(query, 'iu');
|
|
61
|
+
const source = edit.type === 'name' || edit.type === 'procedure' ? edit.name : edit.type === 'comment' ? edit.text : edit.value;
|
|
62
|
+
const next = edit.type === 'procedure' ? replaceProcedureLabel(source, pattern, replacement) :
|
|
63
|
+
spliceMatch(source, pattern, replacement);
|
|
64
|
+
return next !== source ? next : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const watchedBroadcasts = new WeakMap<ReplaceWorkspace, Set<string>>();
|
|
68
|
+
function maintainBroadcastReferences(project: SearchProject, workspace: ReplaceWorkspace, id: string) {
|
|
69
|
+
let ids = watchedBroadcasts.get(workspace);
|
|
70
|
+
if (!ids) {
|
|
71
|
+
const watched = new Set<string>();
|
|
72
|
+
watchedBroadcasts.set(workspace, watched);
|
|
73
|
+
ids = watched;
|
|
74
|
+
workspace.addChangeListener(event => {
|
|
75
|
+
if (event.type !== 'var_rename' || !event.varId || !watched.has(event.varId)) return;
|
|
76
|
+
const variable = project.targets.find(target => target.isStage)?.variables[event.varId];
|
|
77
|
+
if (variable?.type !== 'broadcast_msg' || variable.name !== event.newName || variable.value === variable.name) return;
|
|
78
|
+
variable.value = variable.name;
|
|
79
|
+
for (const blocks of new Set(project.targets.map(target => target.blocks))) {
|
|
80
|
+
const pending = [...blocks.getScripts()];
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
while (pending.length) {
|
|
83
|
+
const id = pending.pop();
|
|
84
|
+
if (!id || seen.has(id)) continue;
|
|
85
|
+
seen.add(id);
|
|
86
|
+
const block = blocks.getBlock(id);
|
|
87
|
+
if (!block) continue;
|
|
88
|
+
const field = block.fields?.BROADCAST_OPTION;
|
|
89
|
+
if (field?.id === event.varId) field.value = variable.name;
|
|
90
|
+
if (block.next) pending.push(block.next);
|
|
91
|
+
for (const input of Object.values(block.inputs ?? {})) {
|
|
92
|
+
if (input.block) pending.push(input.block);
|
|
93
|
+
if (input.shadow) pending.push(input.shadow);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
project.resetAllCaches?.();
|
|
98
|
+
project.emitProjectChanged?.();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
ids.add(id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validName(value: string) {
|
|
105
|
+
return Boolean(value.trim()) && value === value.trim() && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Rename a message from the message section, where no block result carries the edit. */
|
|
109
|
+
export function renameProjectMessage(project: SearchProject, workspace: ReplaceWorkspace,
|
|
110
|
+
id: string, next: string): ReplaceOutcome {
|
|
111
|
+
if (workspace.options.readOnly || workspace.isDragging()) return 'read-only';
|
|
112
|
+
const owner = project.targets.find(target => Object.hasOwn(target.variables, id));
|
|
113
|
+
const variable = owner?.variables[id];
|
|
114
|
+
if (variable?.type !== 'broadcast_msg' || workspace.getVariableById(id)?.name !== variable.name) return 'stale';
|
|
115
|
+
if (!validName(next)) return 'invalid';
|
|
116
|
+
const match = next.toLocaleLowerCase();
|
|
117
|
+
if (project.targets.some(target => Object.entries(target.variables).some(([other, candidate]) =>
|
|
118
|
+
other !== id && candidate.type === 'broadcast_msg' && candidate.name.toLocaleLowerCase() === match))) {
|
|
119
|
+
return 'conflict';
|
|
120
|
+
}
|
|
121
|
+
maintainBroadcastReferences(project, workspace, id);
|
|
122
|
+
workspace.renameVariableById(id, next);
|
|
123
|
+
return workspace.getVariableById(id)?.name === next ? 'applied' : 'conflict';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function applyProjectSearchReplacement(project: SearchProject, workspace: ReplaceWorkspace,
|
|
127
|
+
Blockly: ReplaceBlockly, result: ProjectSearchResult, next: string): ReplaceOutcome {
|
|
128
|
+
if (workspace.options.readOnly || workspace.isDragging() || !result.edit) return 'read-only';
|
|
129
|
+
const {target, edit} = result;
|
|
130
|
+
if (!project.targets.includes(target) || ('block' in result && target.blocks.getBlock(result.block.id) !== result.block)) return 'stale';
|
|
131
|
+
if (edit.type === 'comment' && result.kind === 'comment') {
|
|
132
|
+
const current = target.comments?.[result.commentId];
|
|
133
|
+
if (current !== result.comment || current.text !== edit.text) return 'stale';
|
|
134
|
+
const comment = current.blockId ? workspace.getBlockById(current.blockId)?.comment : workspace.getCommentById(result.commentId);
|
|
135
|
+
if (!comment || comment.getText() !== edit.text) return 'stale';
|
|
136
|
+
comment.setText(next);
|
|
137
|
+
return 'applied';
|
|
138
|
+
}
|
|
139
|
+
if (edit.type === 'name') {
|
|
140
|
+
if (!('block' in result)) return 'stale';
|
|
141
|
+
const fieldName = result.kind === 'variable' ? 'VARIABLE' : result.kind === 'list' ? 'LIST' : 'BROADCAST_OPTION';
|
|
142
|
+
const field = result.block.fields?.[fieldName] ??
|
|
143
|
+
target.blocks.getBlock(result.block.inputs?.BROADCAST_INPUT?.block)?.fields?.[fieldName];
|
|
144
|
+
if (field?.id !== edit.id) return 'stale';
|
|
145
|
+
const variable = edit.owner.variables[edit.id];
|
|
146
|
+
const model = workspace.getVariableById(edit.id);
|
|
147
|
+
if (!project.targets.includes(edit.owner) || !variable || variable.name !== edit.name || model?.name !== edit.name) return 'stale';
|
|
148
|
+
if (!validName(next) || variable.isCloud) return 'invalid';
|
|
149
|
+
const scope = edit.owner.isStage ? project.targets.filter(target => target.isOriginal) :
|
|
150
|
+
project.targets.filter(target => target === edit.owner || target.isStage);
|
|
151
|
+
const match = next.toLocaleLowerCase();
|
|
152
|
+
if (scope.some(target => Object.entries(target.variables).some(([id, candidate]) =>
|
|
153
|
+
id !== edit.id && (candidate.type ?? '') === edit.variableType && candidate.name.toLocaleLowerCase() === match))) return 'conflict';
|
|
154
|
+
if (edit.variableType === 'broadcast_msg') maintainBroadcastReferences(project, workspace, edit.id);
|
|
155
|
+
workspace.renameVariableById(edit.id, next);
|
|
156
|
+
return workspace.getVariableById(edit.id)?.name === next ? 'applied' : 'conflict';
|
|
157
|
+
}
|
|
158
|
+
if (edit.type === 'procedure') {
|
|
159
|
+
if (!('block' in result)) return 'stale';
|
|
160
|
+
const procedure = result.block.mutation?.proccode ??
|
|
161
|
+
target.blocks.getBlock(result.block.inputs?.custom_block?.block)?.mutation?.proccode;
|
|
162
|
+
if (procedure !== edit.name) return 'stale';
|
|
163
|
+
if (!validName(next) || !next.replace(/%[sbn]/g, '').trim()) return 'invalid';
|
|
164
|
+
const prototype = Blockly.Procedures.getPrototypeBlock(edit.name, workspace);
|
|
165
|
+
if (!prototype) return 'stale';
|
|
166
|
+
if (next !== edit.name && Blockly.Procedures.getPrototypeBlock(next, workspace)) return 'conflict';
|
|
167
|
+
const mutation = prototype.mutationToDom();
|
|
168
|
+
mutation.setAttribute('proccode', next);
|
|
169
|
+
Blockly.Procedures.mutateCallersAndPrototype(edit.name, workspace, mutation);
|
|
170
|
+
return 'applied';
|
|
171
|
+
}
|
|
172
|
+
if (edit.type === 'field' && 'block' in result) {
|
|
173
|
+
const {id, field: name, value} = edit;
|
|
174
|
+
if (id !== result.block.id && !Object.values(result.block.inputs ?? {}).some(input => input.block === id)) return 'stale';
|
|
175
|
+
const block = workspace.getBlockById(id);
|
|
176
|
+
const field = block?.getField(name);
|
|
177
|
+
if (!field || String(field.getValue()) !== value || String(target.blocks.getBlock(id)?.fields?.[name]?.value) !== value) return 'stale';
|
|
178
|
+
const text = Boolean(field instanceof Blockly.FieldTextInput);
|
|
179
|
+
const dropdown = Boolean(field instanceof Blockly.FieldDropdown);
|
|
180
|
+
if (!block?.isEditable() || !(text || dropdown)) return 'read-only';
|
|
181
|
+
if (field instanceof Blockly.FieldNumber && (!next.trim() || !Number.isFinite(Number(next)))) return 'invalid';
|
|
182
|
+
if (dropdown && !field.getOptions?.().some(option => String(option[1]) === next)) return 'invalid';
|
|
183
|
+
const validated = field.callValidator(next);
|
|
184
|
+
if (validated === null || (validated !== undefined && String(validated) !== next)) return 'invalid';
|
|
185
|
+
field.setValue(next);
|
|
186
|
+
return String(field.getValue()) === next ? 'applied' : 'invalid';
|
|
187
|
+
}
|
|
188
|
+
return 'read-only';
|
|
189
|
+
}
|