Add link property for animation and skin selection.

This commit is contained in:
Davide Tantillo 2026-01-28 12:03:19 +01:00
parent 1447b85c6e
commit 3fa9330c83
5 changed files with 565 additions and 133 deletions

View File

@ -27,6 +27,187 @@
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
// Common theme type
interface ModalTheme {
overlayBg: string;
captionBg: string;
captionText: string;
contentBg: string;
contentText: string;
buttonBg: string;
buttonText: string;
buttonBorder: string;
buttonHoverBg: string;
closeColor: string;
itemHoverBg: string;
itemSelectedBg: string;
}
// Helper: Create theme
function getTheme (darkMode: boolean): ModalTheme {
return darkMode ? {
overlayBg: 'rgba(0, 0, 0, 0.5)',
captionBg: 'rgb(71, 71, 71)', // gray9
captionText: 'rgb(214, 214, 214)', // gray27
contentBg: 'rgb(87, 87, 87)', // gray11
contentText: 'rgb(214, 214, 214)', // gray27
buttonBg: 'rgb(71, 71, 71)', // gray9
buttonText: 'rgb(214, 214, 214)', // gray27
buttonBorder: 'rgb(56, 56, 56)', // gray7
buttonHoverBg: 'rgb(79, 79, 79)', // gray10
closeColor: 'rgb(168, 168, 168)', // gray21
itemHoverBg: 'rgb(79, 79, 79)', // gray10
itemSelectedBg: 'rgb(56, 56, 56)', // gray7
} : {
overlayBg: 'rgba(0, 0, 0, 0.3)',
captionBg: 'rgb(247, 247, 247)', // gray31
captionText: 'rgb(94, 94, 94)', // gray12
contentBg: 'rgb(232, 232, 232)', // gray29
contentText: 'rgb(94, 94, 94)', // gray12
buttonBg: 'rgb(222, 222, 222)', // gray28
buttonText: 'rgb(94, 94, 94)', // gray12
buttonBorder: 'rgb(199, 199, 199)', // gray25
buttonHoverBg: 'rgb(214, 214, 214)', // gray27
closeColor: 'rgb(94, 94, 94)', // gray12
itemHoverBg: 'rgb(214, 214, 214)', // gray27
itemSelectedBg: 'rgb(199, 199, 199)', // gray25
};
}
// Helper: Create overlay
function createOverlay (theme: ModalTheme): HTMLDivElement {
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: ${theme.overlayBg};
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 14px;
`;
return overlay;
}
// Helper: Create dialog
function createDialog (theme: ModalTheme, minWidth: number): HTMLDivElement {
const dialog = document.createElement('div');
dialog.style.cssText = `
background: ${theme.contentBg};
border-radius: 6px;
min-width: ${minWidth}px;
max-width: 550px;
display: flex;
flex-direction: column;
filter: drop-shadow(0 4px 5px rgba(10,10,10,0.35)) drop-shadow(0 2px 1px rgba(10,10,10,0.5));
`;
return dialog;
}
// Helper: Create caption with close button
function createCaption (title: string, theme: ModalTheme, onClose: () => void): HTMLDivElement {
const caption = document.createElement('div');
caption.style.cssText = `
background: ${theme.captionBg};
color: ${theme.captionText};
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
border-radius: 6px 6px 0 0;
`;
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="${theme.closeColor}"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`;
closeBtn.style.cssText = `
background: transparent;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
opacity: 0.7;
`;
closeBtn.onmouseover = () => { closeBtn.style.opacity = '1'; };
closeBtn.onmouseout = () => { closeBtn.style.opacity = '0.7'; };
closeBtn.addEventListener('click', onClose);
caption.appendChild(titleSpan);
caption.appendChild(closeBtn);
return caption;
}
// Helper: Create footer
function createFooter (): HTMLDivElement {
const footer = document.createElement('div');
footer.style.cssText = `
padding: 8px 14px 12px;
display: flex;
justify-content: flex-end;
gap: 6px;
`;
return footer;
}
// Helper: Create button
function createButton (text: string, theme: ModalTheme, onClick: () => void): HTMLButtonElement {
const btn = document.createElement('button');
btn.textContent = text;
btn.style.cssText = `
padding: 4px 14px;
border: 1px solid ${theme.buttonBorder};
border-radius: 3px;
background: ${theme.buttonBg};
color: ${theme.buttonText};
font-size: 14px;
font-family: inherit;
cursor: pointer;
`;
btn.onmouseover = () => { btn.style.background = theme.buttonHoverBg; };
btn.onmouseout = () => { btn.style.background = theme.buttonBg; };
btn.addEventListener('click', onClick);
return btn;
}
// Helper: Setup modal event handlers
function setupModalHandlers (overlay: HTMLDivElement, onCancel: () => void, extraKeyHandler?: (e: KeyboardEvent) => boolean) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
return;
}
if (extraKeyHandler?.(e)) {
return;
}
};
document.addEventListener('keydown', handleKeyDown);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
onCancel();
}
});
return () => {
document.removeEventListener('keydown', handleKeyDown);
overlay.remove();
};
}
// Original interfaces
interface ModalButton<T> {
text: string;
color?: string;
@ -46,138 +227,35 @@ export function showModal<T> (options: ModalOptions<T>): Promise<T | undefined>
return new Promise((resolve) => {
const { title, text, buttons, darkMode = false } = options;
const theme = darkMode ? {
overlayBg: 'rgba(0, 0, 0, 0.5)',
captionBg: 'rgb(71, 71, 71)', // gray9
captionText: 'rgb(214, 214, 214)', // gray27
contentBg: 'rgb(87, 87, 87)', // gray11
contentText: 'rgb(214, 214, 214)', // gray27
buttonBg: 'rgb(71, 71, 71)', // gray9
buttonText: 'rgb(214, 214, 214)', // gray27
buttonBorder: 'rgb(56, 56, 56)', // gray7
buttonHoverBg: 'rgb(79, 79, 79)', // gray10
closeColor: 'rgb(168, 168, 168)', // gray21
} : {
overlayBg: 'rgba(0, 0, 0, 0.3)',
captionBg: 'rgb(247, 247, 247)', // gray31
captionText: 'rgb(94, 94, 94)', // gray12
contentBg: 'rgb(232, 232, 232)', // gray29
contentText: 'rgb(94, 94, 94)', // gray12
buttonBg: 'rgb(222, 222, 222)', // gray28
buttonText: 'rgb(94, 94, 94)', // gray12
buttonBorder: 'rgb(199, 199, 199)', // gray25
buttonHoverBg: 'rgb(214, 214, 214)', // gray27
closeColor: 'rgb(94, 94, 94)', // gray12
};
const theme = getTheme(darkMode);
const overlay = createOverlay(theme);
const dialog = createDialog(theme, 200);
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: ${theme.overlayBg};
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 14px;
`;
const cleanup = setupModalHandlers(overlay, () => {
cleanup();
resolve(undefined);
});
const dialog = document.createElement('div');
dialog.style.cssText = `
background: ${theme.contentBg};
border-radius: 6px;
min-width: 200px;
max-width: 550px;
display: flex;
flex-direction: column;
filter: drop-shadow(0 4px 5px rgba(10,10,10,0.35)) drop-shadow(0 2px 1px rgba(10,10,10,0.5));
`;
const caption = document.createElement('div');
caption.style.cssText = `
background: ${theme.captionBg};
color: ${theme.captionText};
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
border-radius: 6px 6px 0 0;
`;
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="${theme.closeColor}"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`;
closeBtn.style.cssText = `
background: transparent;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
opacity: 0.7;
`;
closeBtn.onmouseover = () => { closeBtn.style.opacity = '1'; };
closeBtn.onmouseout = () => { closeBtn.style.opacity = '0.7'; };
caption.appendChild(titleSpan);
caption.appendChild(closeBtn);
const caption = createCaption(title, theme, () => {
cleanup();
resolve(undefined);
});
const contents = document.createElement('div');
contents.style.cssText = `
padding: 12px 14px;
color: ${theme.contentText};
line-height: 1.4;
`;
`;
contents.textContent = text;
const footer = document.createElement('div');
footer.style.cssText = `
padding: 8px 14px 12px;
display: flex;
justify-content: flex-end;
gap: 6px;
`;
const cleanup = () => {
document.removeEventListener('keydown', handleKeyDown);
overlay.remove();
};
closeBtn.addEventListener('click', () => {
cleanup();
resolve(undefined);
});
const footer = createFooter();
buttons.forEach((buttonConfig, index) => {
const btn = document.createElement('button');
btn.textContent = buttonConfig.text;
btn.style.cssText = `
padding: 4px 14px;
border: 1px solid ${theme.buttonBorder};
border-radius: 3px;
background: ${theme.buttonBg};
color: ${theme.buttonText};
font-size: 14px;
font-family: inherit;
cursor: pointer;
`;
btn.onmouseover = () => { btn.style.background = theme.buttonHoverBg; };
btn.onmouseout = () => { btn.style.background = theme.buttonBg; };
btn.addEventListener('click', () => {
const btn = createButton(buttonConfig.text, theme, () => {
cleanup();
resolve(buttonConfig.value);
});
footer.appendChild(btn);
if (index === buttons.length - 1) {
@ -185,21 +263,6 @@ export function showModal<T> (options: ModalOptions<T>): Promise<T | undefined>
}
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
cleanup();
resolve(undefined);
}
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
cleanup();
resolve(undefined);
}
};
document.addEventListener('keydown', handleKeyDown);
dialog.appendChild(caption);
dialog.appendChild(contents);
dialog.appendChild(footer);
@ -208,3 +271,296 @@ export function showModal<T> (options: ModalOptions<T>): Promise<T | undefined>
});
}
interface ListSelectionOptions {
darkMode: boolean;
title: string;
items: string[];
maxWidth?: number;
maxHeight?: number;
}
export function showListSelectionModal (options: ListSelectionOptions): Promise<string | undefined> {
return new Promise((resolve) => {
const { title, items, darkMode = false, maxHeight = 400 } = options;
const theme = getTheme(darkMode);
const overlay = createOverlay(theme);
const dialog = createDialog(theme, 300);
let selectedItem: string | undefined;
const cleanup = setupModalHandlers(overlay, () => {
cleanup();
resolve(undefined);
}, (e) => {
if (e.key === 'Enter' && selectedItem) {
cleanup();
resolve(selectedItem);
return true;
}
return false;
});
const caption = createCaption(title, theme, () => {
cleanup();
resolve(undefined);
});
const contents = document.createElement('div');
contents.style.cssText = `
padding: 12px 0;
color: ${theme.contentText};
max-height: ${maxHeight}px;
overflow-y: auto;
`;
items.forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.textContent = item;
itemDiv.style.cssText = `
padding: 8px 14px;
cursor: pointer;
user-select: none;
`;
itemDiv.addEventListener('click', () => {
// Deselect all items
contents.querySelectorAll('div').forEach(div => {
div.style.background = 'transparent';
});
// Select this item
itemDiv.style.background = theme.itemSelectedBg;
selectedItem = item;
});
itemDiv.addEventListener('mouseover', () => {
if (itemDiv.style.background !== theme.itemSelectedBg) {
itemDiv.style.background = theme.itemHoverBg;
}
});
itemDiv.addEventListener('mouseout', () => {
if (itemDiv.style.background !== theme.itemSelectedBg) {
itemDiv.style.background = 'transparent';
}
});
contents.appendChild(itemDiv);
});
const footer = createFooter();
const cancelBtn = createButton('Cancel', theme, () => {
cleanup();
resolve(undefined);
});
const okBtn = createButton('OK', theme, () => {
cleanup();
resolve(selectedItem);
});
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
dialog.appendChild(caption);
dialog.appendChild(contents);
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
setTimeout(() => okBtn.focus(), 0);
});
}
interface AlertOptions {
darkMode: boolean;
title: string;
message: string;
}
export function showAlertModal (options: AlertOptions): Promise<void> {
return new Promise((resolve) => {
const { title, message, darkMode = false } = options;
const theme = getTheme(darkMode);
const overlay = createOverlay(theme);
const dialog = createDialog(theme, 300);
const cleanup = setupModalHandlers(overlay, () => {
cleanup();
resolve();
}, (e) => {
if (e.key === 'Enter') {
cleanup();
resolve();
return true;
}
return false;
});
const caption = createCaption(title, theme, () => {
cleanup();
resolve();
});
const contents = document.createElement('div');
contents.style.cssText = `
padding: 12px 14px;
color: ${theme.contentText};
line-height: 1.4;
`;
contents.textContent = message;
const footer = createFooter();
const okBtn = createButton('OK', theme, () => {
cleanup();
resolve();
});
footer.appendChild(okBtn);
dialog.appendChild(caption);
dialog.appendChild(contents);
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
setTimeout(() => okBtn.focus(), 0);
});
}
interface MultiListSelectionOptions {
darkMode: boolean;
title: string;
items: string[];
selectedItems?: string[];
maxWidth?: number;
maxHeight?: number;
}
export function showMultiListSelectionModal (options: MultiListSelectionOptions): Promise<string[] | undefined> {
return new Promise((resolve) => {
const { title, items, selectedItems = [], darkMode = false, maxHeight = 400 } = options;
const theme = getTheme(darkMode);
const overlay = createOverlay(theme);
const dialog = createDialog(theme, 300);
const selectedSet = new Set(selectedItems);
const cleanup = setupModalHandlers(overlay, () => {
cleanup();
resolve(undefined);
}, (e) => {
if (e.key === 'Enter') {
cleanup();
resolve(Array.from(selectedSet));
return true;
}
return false;
});
const caption = createCaption(title, theme, () => {
cleanup();
resolve(undefined);
});
const contents = document.createElement('div');
contents.style.cssText = `
padding: 12px 0;
color: ${theme.contentText};
max-height: ${maxHeight}px;
overflow-y: auto;
`;
items.forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.style.cssText = `
padding: 8px 14px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
`;
// Create checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = selectedSet.has(item);
checkbox.style.cssText = `
cursor: pointer;
`;
const label = document.createElement('span');
label.textContent = item;
label.style.cssText = `
flex: 1;
cursor: pointer;
`;
itemDiv.appendChild(checkbox);
itemDiv.appendChild(label);
const toggleSelection = () => {
if (selectedSet.has(item)) {
selectedSet.delete(item);
checkbox.checked = false;
} else {
selectedSet.add(item);
checkbox.checked = true;
}
};
itemDiv.addEventListener('click', (e) => {
// Don't toggle if clicking directly on checkbox (it handles itself)
if (e.target !== checkbox) {
toggleSelection();
}
});
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
selectedSet.add(item);
} else {
selectedSet.delete(item);
}
});
itemDiv.addEventListener('mouseover', () => {
itemDiv.style.background = theme.itemHoverBg;
});
itemDiv.addEventListener('mouseout', () => {
itemDiv.style.background = 'transparent';
});
contents.appendChild(itemDiv);
});
const footer = createFooter();
const cancelBtn = createButton('Cancel', theme, () => {
cleanup();
resolve(undefined);
});
const okBtn = createButton('OK', theme, () => {
cleanup();
resolve(Array.from(selectedSet));
});
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
dialog.appendChild(caption);
dialog.appendChild(contents);
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
setTimeout(() => okBtn.focus(), 0);
});
}

View File

@ -579,7 +579,66 @@ class SpineC3PluginInstance extends SDK.IWorldInstanceBase {
}
public async selectAnimation () {
console.log('[Spine] Select animation dialog called');
if (!this.skeleton) {
await spine.showAlertModal({
darkMode: false,
title: 'Error',
message: 'Skeleton not loaded. Please ensure atlas and skeleton files are set.',
});
return;
}
const animations = this.skeleton.data.animations.map(anim => anim.name);
if (animations.length === 0) {
await spine.showAlertModal({
darkMode: false,
title: 'No Animations',
message: 'No animations found in the skeleton.',
});
return;
}
const selectedAnimation = await spine.showListSelectionModal({
darkMode: false,
title: 'Select Animation',
items: animations,
});
if (selectedAnimation) {
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_ANIMATION, selectedAnimation);
}
}
public async selectSkin () {
if (!this.skeleton) {
await spine.showAlertModal({
darkMode: false,
title: 'Error',
message: 'Skeleton not loaded. Please ensure atlas and skeleton files are set.',
});
return;
}
const skins = this.skeleton.data.skins.map(skin => skin.name).filter(s => s !== "default");
if (skins.length === 0) {
await spine.showAlertModal({
darkMode: false,
title: 'No Skins',
message: 'No skins found in the skeleton.',
});
return;
}
const selectedSkins = await spine.showMultiListSelectionModal({
darkMode: false,
title: 'Select Skins',
items: skins,
selectedItems: this.skins,
});
if (selectedSkins !== undefined) {
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_SKIN, selectedSkins.join(","));
}
}
private lang (stringKey: string, interpolate: (string | number)[] = []): string {

View File

@ -22,6 +22,11 @@
"name": "Loader scale",
"desc": "Loader scale"
},
"select-skin": {
"name": "Select skin",
"desc": "Open a dialog to select skins from the skeleton",
"link-text": "Select"
},
"select-animation": {
"name": "Select animation",
"desc": "Open a dialog to select an animation from the skeleton",

View File

@ -22,6 +22,11 @@
"name": "加载比例",
"desc": "加载比例"
},
"select-skin": {
"name": "选择皮肤",
"desc": "打开对话框从骨架中选择皮肤",
"link-text": "选择"
},
"select-animation": {
"name": "选择动画",
"desc": "打开对话框从骨架中选择动画",

View File

@ -78,6 +78,13 @@ const PLUGIN_CLASS = class SpineC3Plugin extends SDK.IPluginBase {
new SDK.PluginProperty("projectfile", SpineC3Plugin.PROP_ATLAS, { initialValue: "", filter: ".atlas" }),
new SDK.PluginProperty("projectfile", SpineC3Plugin.PROP_SKELETON, { initialValue: "", filter: ".json,.skel" }),
new SDK.PluginProperty("float", SpineC3Plugin.PROP_LOADER_SCALE, 1),
new SDK.PluginProperty("link", "select-skin", {
linkCallback: async (instance) => {
const sdkInst = instance as SDKEditorInstanceClass;
await sdkInst.selectSkin();
},
callbackType: "for-each-instance"
}),
new SDK.PluginProperty("text", SpineC3Plugin.PROP_SKIN, ""),
new SDK.PluginProperty("link", "select-animation", {
linkCallback: async (instance) => {