@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,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
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
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.ts';
|
|
4
|
+
|
|
5
|
+
type Field = {id?: string; value: unknown};
|
|
6
|
+
type Block = {
|
|
7
|
+
id: string;
|
|
8
|
+
opcode: string;
|
|
9
|
+
next?: string;
|
|
10
|
+
shadow?: boolean;
|
|
11
|
+
fields?: Record<string, Field>;
|
|
12
|
+
inputs?: Record<string, {block?: string; shadow?: string}>;
|
|
13
|
+
mutation?: {proccode?: string; blockInfo?: {text?: string}};
|
|
14
|
+
};
|
|
15
|
+
type Asset = {name: string};
|
|
16
|
+
type Comment = {text: string; blockId?: string | null};
|
|
17
|
+
type Variable = {name: string; type?: string; isCloud?: boolean; value?: unknown};
|
|
18
|
+
type Target = {
|
|
19
|
+
id: string;
|
|
20
|
+
isStage: boolean;
|
|
21
|
+
isOriginal: boolean;
|
|
22
|
+
getName(): string;
|
|
23
|
+
getCostumes(): Asset[];
|
|
24
|
+
getSounds(): Asset[];
|
|
25
|
+
variables: Record<string, Variable>;
|
|
26
|
+
comments?: Record<string, Comment>;
|
|
27
|
+
// The VM answers `undefined` for a missing id, including an input with no block.
|
|
28
|
+
blocks: {getScripts(): string[]; getBlock(id: string | undefined): Block | undefined};
|
|
29
|
+
};
|
|
30
|
+
export type SearchProject = {
|
|
31
|
+
targets: Target[];
|
|
32
|
+
getLabelForOpcode?(opcode: string): {category?: string; label?: string} | undefined;
|
|
33
|
+
resetAllCaches?(): void;
|
|
34
|
+
emitProjectChanged?(): void;
|
|
35
|
+
on?(event: 'PROJECT_CHANGED', listener: () => void): void;
|
|
36
|
+
removeListener?(event: 'PROJECT_CHANGED', listener: () => void): void;
|
|
37
|
+
};
|
|
38
|
+
type BlockKind = 'block' | 'variable' | 'list' | 'message' | 'argument' | 'extension';
|
|
39
|
+
type AssetKind = 'costume' | 'backdrop' | 'sound';
|
|
40
|
+
export type SearchKind = BlockKind | AssetKind | 'comment' | 'yaml';
|
|
41
|
+
export type VariableAccess = 'read' | 'write';
|
|
42
|
+
export type VariableSearch = {ownerId: string; variableId: string; access: VariableAccess};
|
|
43
|
+
export const variableAccessLabels = {
|
|
44
|
+
read: ['読み取り', 'Read'],
|
|
45
|
+
write: ['変更', 'Write']
|
|
46
|
+
} as const;
|
|
47
|
+
export type SearchEdit =
|
|
48
|
+
| {type: 'field'; id: string; field: string; value: string}
|
|
49
|
+
| {type: 'name'; id: string; owner: Target; name: string; variableType: string}
|
|
50
|
+
| {type: 'procedure'; name: string}
|
|
51
|
+
| {type: 'comment'; text: string};
|
|
52
|
+
export type ProjectSearchResult = {label: string; target: Target; detail?: string; edit?: SearchEdit} & (
|
|
53
|
+
{kind: BlockKind; block: Block; variableAccess?: VariableAccess} | {kind: AssetKind; asset: Asset} |
|
|
54
|
+
{kind: 'comment'; comment: Comment; commentId: string}
|
|
55
|
+
);
|
|
56
|
+
export type YamlSearchDocument = {reference: {providerId: string; documentId: string}; filename: string; source: string};
|
|
57
|
+
export type YamlSearchResult = {
|
|
58
|
+
kind: 'yaml'; document: YamlSearchDocument; label: string; detail: string;
|
|
59
|
+
range: {start: number; end: number; expectedText: string};
|
|
60
|
+
};
|
|
61
|
+
export type SearchResult = ProjectSearchResult | YamlSearchResult;
|
|
62
|
+
export const literalSearchPattern = (query: string, flags: string) =>
|
|
63
|
+
new RegExp(query.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
|
|
64
|
+
|
|
65
|
+
const headings: Record<Exclude<SearchKind, 'yaml'>, [string, string]> = {
|
|
66
|
+
block: ['ブロック', 'Blocks'],
|
|
67
|
+
variable: ['変数の使用箇所', 'Variable uses'],
|
|
68
|
+
list: ['リストの使用箇所', 'List uses'],
|
|
69
|
+
message: ['メッセージの送受信', 'Broadcasts'],
|
|
70
|
+
argument: ['ブロックの引数値', 'Block argument values'],
|
|
71
|
+
extension: ['機能拡張の使用箇所', 'Extension uses'],
|
|
72
|
+
comment: ['コメント', 'Comments'],
|
|
73
|
+
costume: ['コスチューム', 'Costumes'],
|
|
74
|
+
backdrop: ['背景', 'Backdrops'],
|
|
75
|
+
sound: ['音', 'Sounds']
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function* walkBlocks(target: Target) {
|
|
79
|
+
const pending = target.blocks.getScripts().slice().reverse();
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
while (pending.length) {
|
|
82
|
+
const id = pending.pop()!;
|
|
83
|
+
if (seen.has(id)) continue;
|
|
84
|
+
seen.add(id);
|
|
85
|
+
const block = target.blocks.getBlock(id);
|
|
86
|
+
if (!block || block.shadow) continue;
|
|
87
|
+
const inputs: [string, Block][] = [];
|
|
88
|
+
for (const [key, input] of Object.entries(block.inputs ?? {})) {
|
|
89
|
+
const child = input.block && target.blocks.getBlock(input.block);
|
|
90
|
+
if (child) inputs.push([key, child]);
|
|
91
|
+
}
|
|
92
|
+
if (block.next) pending.push(block.next);
|
|
93
|
+
for (let i = inputs.length - 1; i >= 0; i--) {
|
|
94
|
+
if (!inputs[i][1].shadow) pending.push(inputs[i][1].id);
|
|
95
|
+
}
|
|
96
|
+
yield {block, inputs};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stageProperties = new Set(['background #', 'backdrop #', 'backdrop name', 'volume']);
|
|
101
|
+
const spriteProperties = new Set(['x position', 'y position', 'direction', 'costume #', 'costume name', 'size', 'volume']);
|
|
102
|
+
|
|
103
|
+
const listAccesses = new Map<string, VariableAccess>([
|
|
104
|
+
['data_listcontents', 'read'], ['data_itemoflist', 'read'], ['data_itemnumoflist', 'read'],
|
|
105
|
+
['data_lengthoflist', 'read'], ['data_listcontainsitem', 'read'],
|
|
106
|
+
['data_addtolist', 'write'], ['data_deleteoflist', 'write'], ['data_deletealloflist', 'write'],
|
|
107
|
+
['data_insertatlist', 'write'], ['data_replaceitemoflist', 'write']
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function variableAccess(target: Target, block: Block, inputs: [string, Block][],
|
|
111
|
+
stage: Target | undefined, sprites: ReadonlyMap<string, Target>):
|
|
112
|
+
{owner: Target; variableId: string; access: VariableAccess} | undefined {
|
|
113
|
+
if (block.opcode === 'sensing_of') {
|
|
114
|
+
const menu = inputs.find(([key]) => key === 'OBJECT')?.[1];
|
|
115
|
+
if (!menu?.shadow || menu.opcode !== 'sensing_of_object_menu') return;
|
|
116
|
+
const object = menu.fields?.OBJECT?.value;
|
|
117
|
+
const name = block.fields?.PROPERTY?.value;
|
|
118
|
+
if (typeof object !== 'string' || typeof name !== 'string') return;
|
|
119
|
+
const owner = object === '_stage_' ? stage : sprites.get(object);
|
|
120
|
+
if (!owner || (owner.isStage ? stageProperties : spriteProperties).has(name)) return;
|
|
121
|
+
const variableId = Object.keys(owner.variables).find(id => {
|
|
122
|
+
const variable = owner.variables[id];
|
|
123
|
+
return variable.type === '' && variable.name === name;
|
|
124
|
+
});
|
|
125
|
+
if (variableId) return {owner, variableId, access: 'read'};
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const listAccess = listAccesses.get(block.opcode);
|
|
129
|
+
const access = listAccess ?? (block.opcode === 'data_variable' ? 'read' :
|
|
130
|
+
block.opcode === 'data_setvariableto' || block.opcode === 'data_changevariableby' ? 'write' : undefined);
|
|
131
|
+
const variableId = block.fields?.[listAccess ? 'LIST' : 'VARIABLE']?.id;
|
|
132
|
+
if (!access || !variableId) return;
|
|
133
|
+
const owner = Object.hasOwn(target.variables, variableId) ? target : stage;
|
|
134
|
+
if (owner?.variables[variableId]?.type === (listAccess ? 'list' : '')) return {owner, variableId, access};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const originalSprites = (project: SearchProject) =>
|
|
138
|
+
new Map(project.targets.filter(target => target.isOriginal && !target.isStage).map(target => [target.getName(), target]));
|
|
139
|
+
|
|
140
|
+
export function countVariableAccesses(project: SearchProject, ownerId: string) {
|
|
141
|
+
const stage = project.targets.find(target => target.isStage);
|
|
142
|
+
const sprites = originalSprites(project);
|
|
143
|
+
const counts = new Map<string, Record<VariableAccess, number>>();
|
|
144
|
+
for (const target of project.targets) {
|
|
145
|
+
if (!target.isOriginal) continue;
|
|
146
|
+
for (const {block, inputs} of walkBlocks(target)) {
|
|
147
|
+
const use = variableAccess(target, block, inputs, stage, sprites);
|
|
148
|
+
if (use?.owner.id !== ownerId) continue;
|
|
149
|
+
const count = counts.get(use.variableId) ?? {read: 0, write: 0};
|
|
150
|
+
count[use.access]++;
|
|
151
|
+
counts.set(use.variableId, count);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return counts;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function searchProject(project: SearchProject, messages: Record<string, string>, query: string, japanese: boolean,
|
|
158
|
+
kinds: ReadonlySet<SearchKind> = new Set(Object.keys(headings) as SearchKind[]), variableSearch?: VariableSearch) {
|
|
159
|
+
const groups = new Map(Object.entries(headings).map(([kind, titles]) => [
|
|
160
|
+
kind as SearchKind, {kind: kind as SearchKind, title: titles[japanese ? 0 : 1], results: [] as ProjectSearchResult[]}
|
|
161
|
+
]));
|
|
162
|
+
const search = query.trim().toLocaleLowerCase();
|
|
163
|
+
if (!search && !variableSearch) return [];
|
|
164
|
+
|
|
165
|
+
const stage = project.targets.find(target => target.isStage);
|
|
166
|
+
const sprites = variableSearch || kinds.has('variable') ? originalSprites(project) : undefined;
|
|
167
|
+
const searchBlocks = variableSearch || (['block', 'variable', 'list', 'message', 'argument', 'extension'] as const)
|
|
168
|
+
.some(kind => kinds.has(kind));
|
|
169
|
+
const templates = new Map<string, {text: string; extension?: string}>();
|
|
170
|
+
const matches = (value: string) => value.toLocaleLowerCase().includes(search);
|
|
171
|
+
const describeOpcode = (opcode: string) => {
|
|
172
|
+
const cached = templates.get(opcode);
|
|
173
|
+
if (cached) return cached;
|
|
174
|
+
const key = opcode.toUpperCase();
|
|
175
|
+
const alternate = key.replace(/^OPERATOR_/, 'OPERATORS_').replace(/_AS_CLONE$/, 'ASCLONE');
|
|
176
|
+
const vmLabel = project.getLabelForOpcode?.(opcode);
|
|
177
|
+
const vmText = vmLabel?.label;
|
|
178
|
+
const separator = vmLabel?.category === 'extension' ? vmText?.indexOf(': ') ?? -1 : -1;
|
|
179
|
+
const template = {
|
|
180
|
+
text: messages[key] ?? messages[alternate] ??
|
|
181
|
+
(vmText && separator > 0 ? vmText.slice(separator + 2) : vmText) ?? opcode.replaceAll('_', ' '),
|
|
182
|
+
extension: vmText && separator > 0 ? vmText.slice(0, separator) : undefined
|
|
183
|
+
};
|
|
184
|
+
templates.set(opcode, template);
|
|
185
|
+
return template;
|
|
186
|
+
};
|
|
187
|
+
const fieldText = (target: Target, key: string, field: Field) => {
|
|
188
|
+
const variable = field.id && (key === 'VARIABLE' || key === 'LIST') ?
|
|
189
|
+
target.variables[field.id] ?? stage?.variables[field.id] : undefined;
|
|
190
|
+
return String(variable?.name ?? field.value ?? '');
|
|
191
|
+
};
|
|
192
|
+
const describe = (target: Target, block: Block, inputs: [string, Block][], template: string) => {
|
|
193
|
+
const values = [
|
|
194
|
+
...Object.entries(block.fields ?? {}).map(([key, field]) => fieldText(target, key, field)),
|
|
195
|
+
...inputs.filter(([key]) => !key.startsWith('SUBSTACK')).map(([, child]) =>
|
|
196
|
+
child.shadow ? Object.values(child.fields ?? {}).map(field => String(field.value ?? '')).join(' ') :
|
|
197
|
+
Object.entries(child.fields ?? {}).map(([key, field]) => fieldText(target, key, field)).join(' ') || '…')
|
|
198
|
+
];
|
|
199
|
+
if (block.opcode === 'event_whenflagclicked') values.unshift(japanese ? '緑の旗' : 'green flag');
|
|
200
|
+
if (/%\d+/.test(template)) return template.replace(/%(\d+)/g, (_, index) => values[Number(index) - 1] ?? '…').trim();
|
|
201
|
+
return [template, ...values].filter(Boolean).join(' ');
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
for (const target of project.targets) {
|
|
205
|
+
if (!target.isOriginal) continue;
|
|
206
|
+
for (const {block, inputs} of searchBlocks ? walkBlocks(target) : []) {
|
|
207
|
+
const use = sprites && variableAccess(target, block, inputs, stage, sprites);
|
|
208
|
+
if (variableSearch && (!use || use.owner.id !== variableSearch.ownerId ||
|
|
209
|
+
use.variableId !== variableSearch.variableId || use.access !== variableSearch.access)) continue;
|
|
210
|
+
|
|
211
|
+
const variable = block.fields?.VARIABLE;
|
|
212
|
+
const list = block.fields?.LIST;
|
|
213
|
+
const message = messageReference(target, block, stage);
|
|
214
|
+
const kind = variable ? 'variable' : list ? 'list' : 'message';
|
|
215
|
+
const reference = variable ?? list ?? (message && {id: message.id, value: message.fieldValue});
|
|
216
|
+
const name = variable || list ? fieldText(target, variable ? 'VARIABLE' : 'LIST', reference!) : message?.name;
|
|
217
|
+
const opcode = describeOpcode(block.opcode);
|
|
218
|
+
const procedure = block.mutation?.proccode ??
|
|
219
|
+
inputs.find(([key]) => key === 'custom_block')?.[1].mutation?.proccode;
|
|
220
|
+
const customLabel = block.mutation?.blockInfo?.text;
|
|
221
|
+
const template = procedure ? procedure.replace(/%[sbn]/g, '…') :
|
|
222
|
+
typeof customLabel === 'string' ? customLabel : opcode.text;
|
|
223
|
+
const label = procedure ? template : describe(target, block, inputs, template);
|
|
224
|
+
const add = (kind: BlockKind, label: string, detail?: string, edit?: SearchEdit, access?: VariableAccess) =>
|
|
225
|
+
groups.get(kind)!.results.push({kind, target, block, label, detail, edit, variableAccess: access});
|
|
226
|
+
if (variableSearch && use) {
|
|
227
|
+
add(list ? 'list' : 'variable', label, use.owner.variables[use.variableId].name, undefined, use.access);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (kinds.has('block') && (matches(template) || matches(block.opcode) ||
|
|
231
|
+
(block.opcode === 'event_whenflagclicked' && matches(japanese ? '緑の旗' : 'green flag')))) {
|
|
232
|
+
add('block', label, undefined, procedure ? {type: 'procedure', name: procedure} : undefined);
|
|
233
|
+
}
|
|
234
|
+
if (kinds.has(kind) && name && matches(name)) {
|
|
235
|
+
const referenceId = reference?.id;
|
|
236
|
+
const owner = referenceId &&
|
|
237
|
+
(target.variables[referenceId] ? target : stage?.variables[referenceId] ? stage : undefined);
|
|
238
|
+
const scope = (variable || list) && owner &&
|
|
239
|
+
(owner.isStage ? (japanese ? '全体' : 'Global') : (japanese ? 'このスプライト' : 'Local'));
|
|
240
|
+
const model = owner && referenceId ? owner.variables[referenceId] : undefined;
|
|
241
|
+
const edit = owner && referenceId && model && !model.isCloud ? {
|
|
242
|
+
type: 'name' as const, id: referenceId, owner, name: model.name,
|
|
243
|
+
variableType: model.type ?? (list ? 'list' : message ? 'broadcast_msg' : '')
|
|
244
|
+
} : undefined;
|
|
245
|
+
add(kind, name, scope || label, edit, use?.access);
|
|
246
|
+
} else if (use && !variable && !list && kinds.has('variable') && matches(use.owner.variables[use.variableId].name)) {
|
|
247
|
+
add('variable', use.owner.variables[use.variableId].name, label, undefined, use.access);
|
|
248
|
+
}
|
|
249
|
+
if (kinds.has('extension') && opcode.extension && matches(opcode.extension)) add('extension', opcode.extension, label);
|
|
250
|
+
if (kinds.has('argument')) {
|
|
251
|
+
const addArguments = (owner: Block) => {
|
|
252
|
+
for (const [key, field] of Object.entries(owner.fields ?? {})) {
|
|
253
|
+
if (field === reference || (message && key === 'BROADCAST_OPTION')) continue;
|
|
254
|
+
const value = String(field.value ?? '');
|
|
255
|
+
if (value && matches(value)) add('argument', value, label,
|
|
256
|
+
{type: 'field', id: owner.id, field: key, value});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
addArguments(block);
|
|
260
|
+
for (const [, child] of inputs) {
|
|
261
|
+
if (child.shadow) addArguments(child);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (variableSearch) continue;
|
|
266
|
+
|
|
267
|
+
if (kinds.has('comment')) {
|
|
268
|
+
for (const [commentId, comment] of Object.entries(target.comments ?? {})) {
|
|
269
|
+
if (matches(comment.text)) groups.get('comment')!.results.push({
|
|
270
|
+
kind: 'comment', target, comment, commentId, label: comment.text, edit: {type: 'comment', text: comment.text}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const assets: [AssetKind, Asset[]][] = [
|
|
276
|
+
[target.isStage ? 'backdrop' : 'costume', target.getCostumes()],
|
|
277
|
+
['sound', target.getSounds()]
|
|
278
|
+
];
|
|
279
|
+
for (const [kind, items] of assets) {
|
|
280
|
+
if (!kinds.has(kind)) continue;
|
|
281
|
+
for (const asset of items) {
|
|
282
|
+
if (matches(asset.name)) groups.get(kind)!.results.push({kind, asset, label: asset.name, target});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return [...groups.values()].filter(group => group.results.length);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function searchYamlDocuments(documents: readonly YamlSearchDocument[], query: string, japanese: boolean) {
|
|
290
|
+
const results: YamlSearchResult[] = [];
|
|
291
|
+
const search = query.trim();
|
|
292
|
+
if (!search) return results;
|
|
293
|
+
const pattern = literalSearchPattern(search, 'giu');
|
|
294
|
+
const seen = new Set<string>();
|
|
295
|
+
const duplicates = new Set<string>();
|
|
296
|
+
for (const {filename} of documents) {
|
|
297
|
+
if (seen.has(filename)) duplicates.add(filename);
|
|
298
|
+
seen.add(filename);
|
|
299
|
+
}
|
|
300
|
+
for (const document of documents) {
|
|
301
|
+
let line = 1;
|
|
302
|
+
let lineStart = 0;
|
|
303
|
+
let newline = document.source.indexOf('\n');
|
|
304
|
+
for (const match of document.source.matchAll(pattern)) {
|
|
305
|
+
const start = match.index;
|
|
306
|
+
const end = start + match[0].length;
|
|
307
|
+
while (newline >= 0 && newline < start) {
|
|
308
|
+
line++;
|
|
309
|
+
lineStart = newline + 1;
|
|
310
|
+
newline = document.source.indexOf('\n', lineStart);
|
|
311
|
+
}
|
|
312
|
+
const column = start - lineStart + 1;
|
|
313
|
+
const nextNewline = document.source.indexOf('\n', end);
|
|
314
|
+
const before = Math.max(lineStart, start - 48);
|
|
315
|
+
const lineEnd = nextNewline < 0 ? document.source.length : nextNewline;
|
|
316
|
+
const after = Math.min(lineEnd, end + 48);
|
|
317
|
+
const position = japanese ? `${line} 行 ${column} 列` : `Line ${line}, column ${column}`;
|
|
318
|
+
const identity = duplicates.has(document.filename) ?
|
|
319
|
+
`${document.reference.providerId}/${document.reference.documentId}` : '';
|
|
320
|
+
results.push({
|
|
321
|
+
kind: 'yaml', document, label: `${before > lineStart ? '…' : ''}${document.source.slice(before, after)}${after < lineEnd ? '…' : ''}`,
|
|
322
|
+
detail: [position, identity].filter(Boolean).join(' · '),
|
|
323
|
+
range: {start, end, expectedText: match[0]}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return results;
|
|
328
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
type VariableType = '' | 'list';
|
|
2
|
+
|
|
1
3
|
export type SpriteVariableEdit =
|
|
2
|
-
{type: 'create'; name: string} |
|
|
4
|
+
{type: 'create'; name: string; variableType?: ''; isCloud?: boolean} |
|
|
5
|
+
{type: 'create'; name: string; variableType: 'list'; isCloud?: false} |
|
|
3
6
|
{type: 'rename'; id: string; name: string} |
|
|
4
7
|
{type: 'delete'; id: string};
|
|
5
8
|
|
|
6
|
-
export type SpriteVariableEditError = 'emptyName' | 'duplicateName' | 'inUse' | 'unavailable';
|
|
9
|
+
export type SpriteVariableEditError = 'emptyName' | 'duplicateName' | 'inUse' | 'unavailable' | 'cloudLimit';
|
|
7
10
|
|
|
8
11
|
type Variable = {name: string; type: string; isCloud?: boolean};
|
|
9
12
|
type Block = {
|
|
@@ -12,7 +15,7 @@ type Block = {
|
|
|
12
15
|
inputs: Record<string, {block: string | null; shadow: string | null}>;
|
|
13
16
|
};
|
|
14
17
|
type VariableEvent =
|
|
15
|
-
{type: 'var_create'; varId: string; varName: string; varType:
|
|
18
|
+
{type: 'var_create'; varId: string; varName: string; varType: VariableType; isLocal: boolean; isCloud: boolean} |
|
|
16
19
|
{type: 'var_rename'; varId: string; newName: string} |
|
|
17
20
|
{type: 'var_delete'; varId: string};
|
|
18
21
|
type VariableTarget = {
|
|
@@ -33,12 +36,88 @@ type VariableProject = {
|
|
|
33
36
|
targets: readonly VariableTarget[];
|
|
34
37
|
getEditingTarget(): VariableTarget | null;
|
|
35
38
|
getTargetForStage(): VariableTarget | null;
|
|
39
|
+
canAddCloudVariable?(): boolean;
|
|
36
40
|
};
|
|
37
41
|
refreshWorkspace(): void;
|
|
38
42
|
};
|
|
39
43
|
|
|
44
|
+
export type SpriteListItem = string | number | boolean;
|
|
45
|
+
export type SpriteVariableValue = string | number | readonly SpriteListItem[];
|
|
46
|
+
|
|
47
|
+
type VariableValue = {id: string; name: string; type: string; value: unknown; isCloud?: boolean; _monitorUpToDate?: boolean};
|
|
48
|
+
|
|
49
|
+
export function readSpriteVariable(variable: VariableValue) {
|
|
50
|
+
const {id, name, type, value, isCloud} = variable;
|
|
51
|
+
if (type === 'list' && Array.isArray(value)) return {id, name, value: value as readonly SpriteListItem[]};
|
|
52
|
+
if (type === '' && (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')) {
|
|
53
|
+
return {id, name, value: typeof value === 'boolean' ? String(value) : value, isCloud};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function writeSpriteVariableValue(vm: {
|
|
58
|
+
runtime: {getTargetById(id: string): {variables: Record<string, VariableValue>} | undefined};
|
|
59
|
+
setVariableValue(targetId: string, variableId: string, value: SpriteVariableValue): boolean;
|
|
60
|
+
}, targetId: string, id: string, value: SpriteVariableValue) {
|
|
61
|
+
const variable = vm.runtime.getTargetById(targetId)?.variables[id];
|
|
62
|
+
const list = typeof value === 'object';
|
|
63
|
+
if (!variable || variable.type !== (list ? 'list' : '') || !vm.setVariableValue(targetId, id, value)) return false;
|
|
64
|
+
if (list) variable._monitorUpToDate = false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
export const cloudVariablePrefix = '☁ ';
|
|
41
69
|
|
|
70
|
+
type VariableMonitorBlock = {
|
|
71
|
+
id: string;
|
|
72
|
+
opcode: string;
|
|
73
|
+
isMonitored?: boolean;
|
|
74
|
+
targetId: string | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type VariableMonitorRuntime = {
|
|
78
|
+
getTargetById(id: string): VariableTarget | undefined;
|
|
79
|
+
getMonitorState(): {get(id: string): {visible: boolean} | undefined};
|
|
80
|
+
monitorBlocks: {
|
|
81
|
+
getBlock(id: string): VariableMonitorBlock | undefined;
|
|
82
|
+
createBlock(block: VariableMonitorBlock & {
|
|
83
|
+
fields: Record<string, {id: string; name: string; value: string; variableType: '' | 'list'}>;
|
|
84
|
+
inputs: Record<string, never>;
|
|
85
|
+
next: null;
|
|
86
|
+
parent: null;
|
|
87
|
+
topLevel: true;
|
|
88
|
+
shadow: false;
|
|
89
|
+
x: number;
|
|
90
|
+
y: number;
|
|
91
|
+
}): void;
|
|
92
|
+
changeBlock(change: {id: string; element: 'checkbox'; value: boolean}): void;
|
|
93
|
+
};
|
|
94
|
+
emitProjectChanged(): void;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function showSpriteVariableOnStage(runtime: VariableMonitorRuntime, targetId: string, id: string, visible: boolean) {
|
|
98
|
+
const owner = runtime.getTargetById(targetId);
|
|
99
|
+
const variable = owner?.variables[id];
|
|
100
|
+
if (!owner?.isOriginal || !variable || !['', 'list'].includes(variable.type) ||
|
|
101
|
+
Boolean(runtime.getMonitorState().get(id)?.visible) === visible) return;
|
|
102
|
+
const list = variable.type === 'list';
|
|
103
|
+
const opcode = list ? 'data_listcontents' : 'data_variable';
|
|
104
|
+
const field = list ? 'LIST' : 'VARIABLE';
|
|
105
|
+
const {monitorBlocks} = runtime;
|
|
106
|
+
let block = monitorBlocks.getBlock(id);
|
|
107
|
+
if (!block) {
|
|
108
|
+
monitorBlocks.createBlock({
|
|
109
|
+
id, opcode, targetId: owner.isStage ? null : owner.id, inputs: {},
|
|
110
|
+
fields: {[field]: {id, name: field, value: variable.name, variableType: list ? 'list' : ''}},
|
|
111
|
+
next: null, parent: null, topLevel: true, shadow: false, x: 0, y: 0
|
|
112
|
+
});
|
|
113
|
+
block = monitorBlocks.getBlock(id);
|
|
114
|
+
}
|
|
115
|
+
if (block?.opcode !== opcode) return;
|
|
116
|
+
block.isMonitored = !visible;
|
|
117
|
+
monitorBlocks.changeBlock({id, element: 'checkbox', value: visible});
|
|
118
|
+
runtime.emitProjectChanged();
|
|
119
|
+
}
|
|
120
|
+
|
|
42
121
|
function isUsed(target: VariableTarget, targets: readonly VariableTarget[], id: string, name: string) {
|
|
43
122
|
const targetName = target.isStage ? '_stage_' : target.getName();
|
|
44
123
|
return targets.some(({blocks}) => {
|
|
@@ -65,18 +144,26 @@ export function editSpriteVariable(vm: VariableProject, targetId: string, edit:
|
|
|
65
144
|
if (isUsed(target, runtime.targets, edit.id, variable!.name)) return 'inUse';
|
|
66
145
|
target.blocks.blocklyListen({type: 'var_delete', varId: edit.id});
|
|
67
146
|
} else {
|
|
68
|
-
const
|
|
147
|
+
const variableType = edit.type === 'create' ? edit.variableType ?? '' : '';
|
|
148
|
+
const cloud = edit.type === 'create' ? variableType === '' && edit.isCloud === true : variable?.isCloud === true;
|
|
149
|
+
const name = (cloud && edit.name.startsWith(cloudVariablePrefix) ?
|
|
150
|
+
edit.name.slice(cloudVariablePrefix.length) : edit.name).trim();
|
|
69
151
|
if (!name) return 'emptyName';
|
|
70
|
-
const fullName =
|
|
152
|
+
const fullName = cloud ? cloudVariablePrefix + name : name;
|
|
71
153
|
if (fullName === variable?.name) return;
|
|
72
154
|
const scope = target.isStage ? runtime.targets.filter(item => item.isStage || item.isOriginal) :
|
|
73
155
|
[target, runtime.getTargetForStage()];
|
|
74
|
-
if (scope.some(owner => owner && Object.values(owner.variables).some(item =>
|
|
156
|
+
if (scope.some(owner => owner && Object.values(owner.variables).some(item =>
|
|
157
|
+
item.type === variableType && item.name === fullName))) {
|
|
75
158
|
return 'duplicateName';
|
|
76
159
|
}
|
|
77
160
|
if (edit.type === 'create') {
|
|
161
|
+
if (cloud && !target.isStage) return 'unavailable';
|
|
162
|
+
if (cloud && !runtime.canAddCloudVariable?.()) return 'cloudLimit';
|
|
78
163
|
const varId = Array.from(crypto.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('');
|
|
79
|
-
target.blocks.blocklyListen({
|
|
164
|
+
target.blocks.blocklyListen({
|
|
165
|
+
type: 'var_create', varId, varName: fullName, varType: variableType, isLocal: !target.isStage, isCloud: cloud
|
|
166
|
+
});
|
|
80
167
|
} else {
|
|
81
168
|
target.blocks.blocklyListen({type: 'var_rename', varId: edit.id, newName: fullName});
|
|
82
169
|
}
|