Merge branch 'side-pannel' into 'master'

ENH: Convert the dialog side pannel to DOM

See merge request 
This commit is contained in:
Rama 2025-04-08 21:51:37 +00:00
commit 08bb699b81
11 changed files with 1490 additions and 523 deletions

View file

@ -11,7 +11,8 @@
"dialog-paginate dialog-grid" auto / var(--menu-button-size) auto;
}
.dialog-root[data-unload] {
.dialog-root[data-unload],
.dialog-root [data-unload] {
display: none;
}
@ -141,10 +142,6 @@
overflow: hidden;
}
.dialog-grid-button[data-unload] {
display: none;
}
@supports selector(:nth-child(1n of :not([data-unload]))) {
.dialog-grid .dialog-grid-button:nth-child(4n - 3 of :not([data-unload])) > .button-tooltip {
left: unset;
@ -317,10 +314,6 @@
}
}
.dialog-dialog-button[data-unload] {
display: none;
}
.dialog-dialog-button > .button-label {
position: unset;
padding-inline: 0.15em 0.15em;
@ -351,6 +344,162 @@
border-color: cyan;
}
.dialog-self-menu-root {
gap: 0 var(--gap);
}
.dialog-self-menu-root > .dialog-grid {
width: calc(100% - var(--scrollbar-gutter));
padding-right: var(--scrollbar-gutter);
}
.dialog-self-menu-root > .dialog-status {
padding-bottom: var(--gap);
}
.dialog-self-menu-menubar {
min-width: calc(5 * var(--menu-button-size) + 2 * var(--gap));
grid-area: dialog-menubar;
display: grid;
gap: calc(0.5 * var(--gap));
direction: rtl;
grid-auto-flow: column;
grid-template-columns: repeat(3, var(--menu-button-size)) calc(3px + var(--menu-button-size) + 0.5 * var(--gap)) repeat(auto-fill, var(--menu-button-size));
}
.dialog-expression-grid > .dialog-menubar-button,
.dialog-pose-grid > .dialog-menubar-button,
.dialog-expression-preset-slot > canvas,
#dialog-owner-rules-grid > li {
scroll-snap-align: start;
}
#dialog-expression {
grid-template:
"dialog-menubar dialog-menubar" var(--menu-button-size)
"dialog-status dialog-status" min-content
"dialog-left-menu dialog-grid" auto / var(--menu-button-size) min-content;
}
#dialog-expression-menu-left {
grid-area: dialog-left-menu;
display: grid;
padding-top: 3px;
gap: calc(0.5 * var(--gap));
grid-template-rows: repeat(auto-fill, var(--menu-button-size));
}
.dialog-expression-grid {
display: grid;
gap: calc(0.5 * var(--gap));
grid-template-columns: repeat(3, var(--menu-button-size));
grid-template-rows: repeat(auto-fill, var(--menu-button-size));
height: calc(7 * var(--menu-button-size) + 3 * var(--gap));
}
.dialog-expression-grid[data-unload] {
display: grid;
visibility: hidden;
}
#dialog-expression-button-grid {
display: none;
}
#dialog-pose {
width: fit-content;
grid-template:
"dialog-menubar dialog-menubar" var(--menu-button-size)
"dialog-status dialog-status" min-content
". dialog-grid" auto / var(--menu-button-size) min-content;
}
#dialog-pose > .dialog-grid {
gap: calc(0.5 * var(--gap));
}
.dialog-pose-grid {
display: grid;
gap: calc(0.5 * var(--gap));
}
#dialog-expression-preset {
width: fit-content;
grid-template:
"dialog-menubar dialog-menubar" var(--menu-button-size)
"dialog-status dialog-status" min-content
"dialog-grid dialog-grid" auto / var(--menu-button-size) min-content;
}
#dialog-expression-preset-button-grid {
display: block;
padding-block: 3px;
margin: unset;
}
.dialog-expression-preset-slot {
--expression-preset-size: min(8dvh, 4dvw);
display: grid;
gap: calc(var(--gap) / 2);
grid-template:
"canvas save-button" 50%
"canvas load-button" 50% / calc(2 * var(--menu-button-size) + var(--gap)) min-content;
}
.dialog-expression-preset-slot button {
height: var(--expression-preset-size);
width: calc(2 * var(--menu-button-size) + 0.5 * var(--gap));
justify-self: start;
}
.dialog-expression-preset-slot button[name="save"] {
grid-area: save-button;
margin-top: calc(var(--gap) / 4);
align-self: self-end;
}
.dialog-expression-preset-slot button[name="load"] {
grid-area: load-button;
margin-bottom: calc(var(--gap) / 4);
align-self: self-start;
}
.dialog-expression-preset-slot:first-child button {
margin-top: unset;
}
.dialog-expression-preset-slot:last-child button {
margin-bottom: unset;
}
.dialog-expression-preset-slot canvas {
grid-area: canvas;
max-height: calc(2 * var(--expression-preset-size) + var(--gap) / 2);
max-width: calc(2 * var(--expression-preset-size) + var(--gap) / 2);
min-height: min(20dvh, 10dvw, 200px) !important;
min-width: min(20dvh, 10dvw, 200px) !important;
position: static;
}
#dialog-owner-rules {
grid-template:
"dialog-menubar dialog-menubar" var(--menu-button-size)
"dialog-status dialog-status" min-content
"dialog-grid dialog-grid" auto / var(--menu-button-size) min-content;
}
#dialog-owner-rules-grid {
width: 100% !important;
display: block;
color: white;
margin-block: unset;
user-select: none;
}
#dialog-owner-rules-grid > li {
text-indent: 1em hanging each-line;
}
@supports(height: 100dvh) {
.dialog-root {
--menu-button-size: min(9dvh, 4.5dvw);

View file

Before

(image error) Size: 1,002 B

After

(image error) Size: 1,002 B

View file

Before

(image error) Size: 919 B

After

(image error) Size: 919 B

View file

@ -364,7 +364,7 @@ RequireSelfBondage6,Requires self-bondage 6.
RequireSelfBondage7,Requires self-bondage 7.
RequireSelfBondage8,Requires self-bondage 8.
RequireSelfBondage9,Requires self-bondage 9.
RulesMenu,Active Rules
RulesMenu,Active owner/lover rules
RulesMenuBlockChange,Cannot change:
RulesMenuBlockFamilyKey,Blocked: Family keys
RulesMenuBlockKey,Blocked: Normal keys

1 's 's
364 RequireSelfBondage7 Requires self-bondage 7.
365 RequireSelfBondage8 Requires self-bondage 8.
366 RequireSelfBondage9 Requires self-bondage 9.
367 RulesMenu Active Rules Active owner/lover rules
368 RulesMenuBlockChange Cannot change:
369 RulesMenuBlockFamilyKey Blocked: Family keys
370 RulesMenuBlockKey Blocked: Normal keys

View file

@ -848,6 +848,7 @@ const CommonCommands = [
Action: () => {
const expression = WardrobeGetExpression(Player).Emoticon != "Afk" ? "Afk" : null;
CharacterSetFacialExpression(Player, "Emoticon", expression);
Player.ActiveExpression.Emoticon = expression;
}
},
{
@ -877,7 +878,7 @@ const CommonCommands = [
}
/** @type {(null | ExpressionNameMap["Blush"])[]} */
let BlushLevels = [null, "Low", "Medium", "High", "VeryHigh", "Extreme"];
/** @type {null | ExpressionName} */
/** @type {null | ExpressionNameMap["Blush"]} */
let NewExpression = null;
let AcceptCmd = false;
if (/^[0-5]$/.test(args)) {
@ -912,11 +913,7 @@ const CommonCommands = [
}
if (AcceptCmd) {
CharacterSetFacialExpression(Player, "Blush", NewExpression);
// Also save in GUI
if (DialogFacialExpressions.length == 0) {
DialogFacialExpressionsBuild();
}
DialogFacialExpressions.find(FE => FE.Group == "Blush").CurrentExpression = NewExpression;
Player.ActiveExpression.Blush = NewExpression;
}
}
},
@ -930,7 +927,7 @@ const CommonCommands = [
return;
}
let AcceptCmd = false;
/** @type {ExpressionName} */
/** @type {ExpressionNameMap["Eyes"] | "Open"} */
let NewExpression;
let TargetLeft = false;
let TargetRight = false;
@ -979,15 +976,6 @@ const CommonCommands = [
return;
}
if (NewExpression == "Open" || NewExpression == "Closed") {
if (NewExpression == "Open") {
// Restore opened eye expression set from GUI
let DialogCurrentExpr = DialogFacialExpressions.find(FE => FE.Group == "Eyes");
if (DialogCurrentExpr) {
NewExpression = DialogCurrentExpr.CurrentExpression;
} else {
NewExpression = null;
}
}
if (TargetLeft && TargetRight) {
CharacterSetFacialExpression(Player, "Eyes", NewExpression);
} else if (TargetLeft) {
@ -996,20 +984,17 @@ const CommonCommands = [
CharacterSetFacialExpression(Player, "Eyes2", NewExpression);
}
} else {
// Change eye expression
// Save new expression in GUI because close will erase it
let DialogCurrentExpr = DialogFacialExpressions.find(FE => FE.Group == "Eyes");
if (!DialogCurrentExpr) {
DialogFacialExpressionsBuild();
DialogCurrentExpr = DialogFacialExpressions.find(FE => FE.Group == "Eyes");
}
DialogCurrentExpr.CurrentExpression = NewExpression;
// Apply new expression only to eyes that are opened
let LeftClosed = InventoryGetItemProperty(InventoryGet(Player, "Eyes"), "Expression") === "Closed";
let RightClosed = InventoryGetItemProperty(InventoryGet(Player, "Eyes2"), "Expression") === "Closed";
if (!LeftClosed) CharacterSetFacialExpression(Player, "Eyes1", NewExpression);
if (!RightClosed) CharacterSetFacialExpression(Player, "Eyes2", NewExpression);
if (!LeftClosed) {
CharacterSetFacialExpression(Player, "Eyes1", NewExpression);
Player.ActiveExpression.Eyes = NewExpression;
}
if (!RightClosed) {
CharacterSetFacialExpression(Player, "Eyes2", NewExpression);
Player.ActiveExpression.Eyes = NewExpression;
}
}
}
},

View file

@ -863,7 +863,14 @@ var Shop2 = {
return;
}
DialogLoadPoseMenu(Shop2InitVars.Preview);
/** @type {Partial<Record<AssetPoseCategory, Pose[]>>} */
const poses = {};
for (const pose of PoseFemale3DCG) {
if (pose.AllowMenu || pose.AllowMenuTransient) {
(poses[pose.Category] ??= []).push(pose);
}
}
ElementCreate({
tag: "div",
style: { display: "none" },
@ -873,7 +880,7 @@ var Shop2 = {
children: [{
tag: "div",
classList: ["shop2-pose-outer-grid"],
children: DialogActivePoses.map(poseList => {
children: Object.values(poses).map(poseList => {
return {
tag: "div",
classList: ["shop2-pose-inner-grid"],

View file

@ -153,6 +153,7 @@ function CharacterCreate(CharacterAssetFamily, Type, CharacterID) {
HeightRatio: 1,
HasHiddenItems: false,
SavedColors: GetDefaultSavedColors(),
ActiveExpression: null,
PoseMapping: {},
get Pose() {
@ -752,6 +753,36 @@ function CharacterCreate(CharacterAssetFamily, Type, CharacterID) {
}
};
// Keep these two methods non-enumerable such that they do not interfere with the likes of `Object.keys`
const activeExpression = Object.defineProperties(/** @type {Character["ActiveExpression"]} */({}), {
setWithoutReload: {
value: function (key, value) { this[key] = value; },
enumerable: false,
},
deleteWithoutReload: {
value: function (key) { delete this[key]; },
enumerable: false,
},
});
/** @type {Mutable<Character>} */(NewCharacter).ActiveExpression = new Proxy(
activeExpression,
{
set(...args) {
if (DialogSelfMenuSelected === "Expression" && DialogSelfMenuMapping.Expression.C.ID === NewCharacter.ID) {
DialogSelfMenuMapping.Expression.Reload();
}
return Reflect.set(...args);
},
deleteProperty(...args) {
if (DialogSelfMenuSelected === "Expression" && DialogSelfMenuMapping.Expression.C.ID === NewCharacter.ID) {
DialogSelfMenuMapping.Expression.Reload();
}
return Reflect.deleteProperty(...args);
},
},
);
// Add the character to the cache
Character.push(NewCharacter);
@ -1855,6 +1886,30 @@ function CharacterResetFacialExpression(C) {
}
}
/**
* Checks if a given expression is disallowed on a character
* @param {Character} C
* @param {Item} Item
* @param {null | ExpressionName} Expression
* @returns {null | string} - An exit status or `null` if the expression is otherwise allowed
*/
function CharacterIsExpressionDisallowed(C, Item, Expression) {
if (!C || !Item) return "Internal error: missing character or item";
const allowedExpr = InventoryGetItemProperty(Item, "AllowExpression", true);
const exprPres = InventoryGetItemProperty(Item, "ExpressionPrerequisite", true);
const exprPre = exprPres[allowedExpr.indexOf(Expression)];
const prereqMessage = !exprPre ? null : InventoryPrerequisiteMessage(C, exprPre, Item.Asset);
if (Expression != null && !allowedExpr.includes(Expression)) {
return `Illegal expression "${Expression}"`;
} else if (prereqMessage) {
return prereqMessage;
} else {
return null;
}
}
/**
* Checks if a given expression is allowed on a character
* @param {Character} C
@ -1862,13 +1917,7 @@ function CharacterResetFacialExpression(C) {
* @param {ExpressionName} Expression
*/
function CharacterIsExpressionAllowed(C, Item, Expression) {
if (!C || !Item) return false;
const allowedExpr = InventoryGetItemProperty(Item, "AllowExpression", true);
const exprPres = InventoryGetItemProperty(Item, "ExpressionPrerequisite", true);
const exprPre = exprPres[allowedExpr.indexOf(Expression)];
return ((Expression == null || allowedExpr.includes(Expression)) && (!exprPre || InventoryPrerequisiteMessage(C, exprPre, Item.Asset) === ""));
return CharacterIsExpressionDisallowed(C, Item, Expression) == null;
}
/**

File diff suppressed because it is too large Load diff

View file

@ -1002,6 +1002,68 @@ var ElementButton = {
}
},
/**
* Click event listener for spin buttons.
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/spinbutton_role
* @private
* @type {(this: HTMLButtonElement, ev: MouseEvent) => void}
*/
_ClickSpin: function _ClickSpin(ev) {
const min = Number.parseInt(this.getAttribute("aria-valuemin"), 10);
const max = Number.parseInt(this.getAttribute("aria-valuemax"), 10);
const now = Number.parseInt(this.getAttribute("aria-valuenow"), 10);
if (Number.isNaN(min) || Number.isNaN(max)) {
ev.stopImmediatePropagation();
return;
}
if (Number.isNaN(now) || now < min || now === max) {
this.setAttribute("aria-valuenow", min);
} else if (now > max) {
this.setAttribute("aria-valuenow", max);
} else {
this.setAttribute("aria-valuenow", now + 1);
}
},
/**
* Keydown event listener for spin buttons.
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/spinbutton_role
* @private
* @type {(this: HTMLButtonElement, ev: KeyboardEvent) => void}
*/
_KeyDownSpin: function _KeyDownSpin(ev) {
if (CommonKey.GetModifiers(ev)) {
return;
}
// Decrement the current value such that the next click action will bring it to the correct, expected value
let valuenow = null;
switch (ev.key) {
case "ArrowRight":
case "ArrowUp":
valuenow = this.getAttribute("aria-valuenow");
break;
case "ArrowLeft":
case "ArrowDown":
valuenow = Number.parseInt(this.getAttribute("aria-valuenow"), 10) - 2;
break;
case "Home":
valuenow = Number.parseInt(this.getAttribute("aria-valuemax"), 10) - 1;
break;
case "End":
valuenow = this.getAttribute("aria-valuemax");
break;
}
if (valuenow != null) {
this.setAttribute("aria-valuenow", valuenow);
this.click();
ev.stopPropagation();
ev.preventDefault();
}
},
/**
* @this {HTMLElement}
* @param {KeyboardEvent} ev
@ -1313,6 +1375,19 @@ var ElementButton = {
elem.setAttribute("aria-checked", "false");
}
break;
case "spinbutton": {
if (!elem.hasAttribute("aria-valuemin")) {
elem.setAttribute("aria-valuemin", "0");
}
if (!elem.hasAttribute("aria-valuemax")) {
elem.setAttribute("aria-valuemax", "100");
}
if (!elem.hasAttribute("aria-valuenow")) {
elem.setAttribute("aria-valuenow", elem.getAttribute("aria-valuemin"));
}
elem.addEventListener("click", this._ClickSpin);
elem.addEventListener("keydown", this._KeyDownSpin);
}
}
elem.addEventListener("click", onClick);

View file

@ -195,9 +195,10 @@ function PoseSetByItems(C, category, poseName) {
* @param {Character} C - Character for which to set the pose
* @param {null | AssetPoseName} poseName - Name of the pose to set as active or `null` to return to the default pose
* @param {boolean} [ForceChange=false] - TRUE if the set pose(s) should overwrite current active pose(s)
* @param {boolean} [RefreshDialog] - Refresh {@link DialogSelfMenuMapping.Pose} if so required
* @returns {void} - Nothing
*/
function PoseSetActive(C, poseName, ForceChange = false) {
function PoseSetActive(C, poseName, ForceChange=false, RefreshDialog=true) {
const newPose = PoseRecord[poseName];
if (
poseName == null
@ -206,6 +207,9 @@ function PoseSetActive(C, poseName, ForceChange = false) {
) {
C.ActivePoseMapping = (newPose == null) ? {} : { [newPose.Category]: newPose.Name };
CharacterRefresh(C, false);
if (RefreshDialog && DialogSelfMenuSelected === "Pose" && DialogSelfMenuMapping.Pose.C.ID === C.ID) {
DialogSelfMenuMapping.Pose.Reload();
}
return;
}
@ -229,7 +233,11 @@ function PoseSetActive(C, poseName, ForceChange = false) {
C.PoseMapping.BodyUpper = C.PoseMapping.BodyUpper ?? "BaseUpper";
C.PoseMapping.BodyLower = C.PoseMapping.BodyLower ?? "BaseLower";
}
CharacterRefresh(C, false);
if (RefreshDialog && DialogSelfMenuSelected === "Pose" && DialogSelfMenuMapping.Pose.C.ID === C.ID) {
DialogSelfMenuMapping.Pose.Reload();
}
}
/**

View file

@ -172,7 +172,7 @@ declare namespace ElementButton {
*/
icons?: readonly (null | undefined | InventoryIcon | CustomIcon)[];
/** The role of the button. All accepted values are currently special-cased in order to set role-specific event listeners and/or attributes. */
role?: "radio" | "checkbox" | "menuitemradio" | "menuitemcheckbox";
role?: "radio" | "checkbox" | "menuitemradio" | "menuitemcheckbox" | "spinbutton";
/** Whether to limit the default styling of the button's border and background */
noStyling?: boolean;
/** Whether the button should be disabled or not */
@ -336,6 +336,46 @@ declare namespace DialogMenu {
C: Character;
focusGroup?: AssetGroup;
}
/** An object representing the validation output of a menubar button, determining whether it should be clickable or not */
interface MenuButtonValidateData {
/**
* The button's validation state:
* * `null`: Validation successful
* * `"hide"`: Validation failure; hide the button
* * `"disabled"`: Validation failure; disable the button but do not hide it
*/
state: null | "hidden" | "disabled";
/**
* An optional status message to-be returned if the validation fails.
*/
status?: null | string;
}
/**
* A custom validation function for determining whether the button should be enabled, disabled or hidden.
* @param button The button in question
* @param properties The {@link InitProperties} associated with the specific dialog menu
* @param equippedItem The equipped item in question (if any)
* @returns A nullish object if the validation passes or an object representing the validation state
*/
type MenuButtonValidator<T extends InitProperties> = (
button: HTMLButtonElement,
properties: T,
equippedItem?: Item | null
) => MenuButtonValidateData | null;
interface MenuButtonData<T extends InitProperties> {
/**
* @param button The button in question
* @param ev The mouse event associated with the button click
* @param properties The {@link InitProperties} associated with the specific dialog menu
* @param equippedItem The equipped item in question (if any)
*/
click: (button: HTMLButtonElement, ev: MouseEvent, properties: T, equippedItem?: Item | null) => any;
/** An object mapping labels to custom validation functions for button clicks. */
validate?: Record<string, MenuButtonValidator<T>>;
}
}
type DialogSortOrder = | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
@ -1124,6 +1164,11 @@ interface ExpressionItem {
ExpressionList: ExpressionName[],
}
interface ExpressionPair {
Group: Exclude<ExpressionGroupName, "Eyes2">,
Expression: null | ExpressionName,
}
/**
* The internal Asset definition of an asset.
*
@ -1630,6 +1675,18 @@ interface Character {
Dialog: DialogLine[];
Reputation: Reputation[];
Skill: Skill[];
/**
* An object mapping expression group names to their respective currently explicitly, manually selected expression (which may thus diverge from the actual currently active expression).
*
* Setting or deleting an entry will potentially trigger a {@link DialogSelfMenuMapping.Expression} reload unless done so via `setWithoutReload()`/`deleteWithoutReload()` calls.
*/
readonly ActiveExpression: (
Partial<Record<ExpressionGroupName, ExpressionName>>
& {
setWithoutReload(key: ExpressionGroupName, value: ExpressionName): void,
deleteWithoutReload(key: ExpressionGroupName): void,
}
);
/**
* Get a copy or set the array of currently enabled poses.
* @see {@link PoseMapping} - The underlying record of this property, usage of which is recommended
@ -4300,13 +4357,7 @@ interface DialogInventoryItem extends Item {
Vibrating: boolean;
}
interface DialogSelfMenuOptionType {
Name: string;
IsAvailable: () => boolean;
Load?: () => void;
Draw: () => void;
Click: () => void;
}
type DialogSelfMenuName = "Expression" | "Pose" | "SavedExpressions" | "OwnerRules";
// #end region