diff --git a/corsscripts/stacksortable.js b/corsscripts/stacksortable.js index f8df73ea5d338abcde7bc5727dcbd33940d3d0c2..00a8f30646848cecf165e316bfa9a30c07990d19 100644 --- a/corsscripts/stacksortable.js +++ b/corsscripts/stacksortable.js @@ -25,74 +25,74 @@ export const SUPPORTED_CALLBACK_FUNCTIONS = [ ]; /** - * Preprocess and validate proof steps, block user options, and sortable user options ready for use in `stack_sortable` class. + * Preprocess and validate steps, sortable user options, headers and indices ready for use in `stack_sortable` class. * - * The function takes proof steps in the form of a Parson's JSON or Maxima string variable, along with block user options + * The function takes steps in the form of a Parson's JSON or Maxima string variable, along with block user options * and sortable user options. It performs the following tasks: - * 1. If `proofSteps` is a Maxima string of expected format, it converts it to an object using `_stackstring_objectify`. + * 1. If `steps` is a Maxima string of expected format, it converts it to an object using `_stackstring_objectify`. * 2. It validates the structure of the Parson's JSON using `_validate_parsons_JSON`. - * 3. If the Parsons JSON contains "steps" and "options," it separates them. - * - If "header" is present in options, it separates this away from Sortable options into `blockUserOpts`. - * - It splits Sortable options into "used" and "available" and passes to `sortableUserOpts`. - * 4. If `proofSteps` is a Maxima string (after separation), it converts it to an object. + * 3. If the Parsons JSON is of depth two with a valid set of top-level keys it separates them. + * 4. If `steps` is a Maxima string (after separation), it converts it to an object. * - * @param {string|Object} proofSteps - The proof steps to be preprocessed. Either a JSON of expected format - * or - * @param {Object} blockUserOpts - Block user options for the 'header' setting, should be passed as an empty Object. - * @param {Object} sortableUserOpts - Sortable user options split into used and available, should be passed as an empty Object. - * @returns {Array} - An array containing preprocessed proof steps, block user options, - * sortable user options, and a boolean indicating the validity of the proof steps structure. + * @param {Object|string} steps - The steps object or string representation of steps. + * @param {Object} sortableUserOpts - Options for the sortable plugin. + * @param {Array} headers - Headers for the answer lists. + * @param {Array} available_header - Header for the available list. + * @param {Array} index - Index column. + * @returns {Array} - An array containing preprocessed steps, options, headers, available header, and index in that order. * * @example * // Returns [ - * // { step1: "Proof step 1", step2: "Proof step 2" }, - * // { used: { header: "Header 1" }, available: { header: "Header 2" } }, - * // { used: { option1: "Value 1" }, available: { option2: "Value 2" } }, + * // { step1: "step 1 text", step2: "step 2 text" }, + * // { option1: "Value 1", option2: "Value 2" }, + * // ["header 1", "header 2"], + * // ["Drag from here:"], + * // null, * // true * // ] * preprocess_steps({ * steps: { - * step1: "Proof step 1", - * step2: "Proof step 2" + * step1: "step 1 text", + * step2: "step 2 text" * }, * options: { - * header: ["Header 1", "Header 2"], * option1: "Value 1", * option2: "Value 2" * } - * }, {}, {}); + * }, ["header 1", "header 2"], ["Drag from here:"], null); */ -export function preprocess_steps(proofSteps, sortableUserOpts, headers, available_header, index) { - // Check if proofSteps is a string and convert it to an object - // (this occurs when proof steps are a flat list coming from a Maxima variable) - if (typeof proofSteps === "string") { - proofSteps = _stackstring_objectify(proofSteps); +export function preprocess_steps(steps, sortableUserOpts, headers, available_header, index) { + // Check if steps is a string and convert it to an object + // (this occurs when the steps are a flat list coming from a Maxima variable) + if (typeof steps === "string") { + steps = _stackstring_objectify(steps); } // Validate the object - var valid = _validate_parsons_JSON(proofSteps); + var valid = _validate_parsons_JSON(steps); - // At this point, we know proofSteps is either a flat JSON, or it's top-level keys are a subset of - // ["steps", "options", "headers", ""index"], and contains at least "steps". Separate these if they are present - if (_validate_top_level_keys_JSON(proofSteps, ["steps", "options", "headers", "index", "available_header"], ["steps"])) { - var sortableUserOpts = proofSteps["options"]; + // At this point, we know steps is either a flat JSON, or it's top-level keys are a subset of + // ["steps", "options", "headers", "available_header", "index"], and contains at least "steps". + // Separate these if they are present. + if (_validate_top_level_keys_JSON(steps, ["steps", "options", "headers", "index", "available_header"], ["steps"])) { + var sortableUserOpts = steps["options"]; // only want to replace defaults for headers if they have been provided - if ("headers" in proofSteps) { - headers = proofSteps["headers"]; + if ("headers" in steps) { + headers = steps["headers"]; } - if ("available_header" in proofSteps) { - available_header = proofSteps["available_header"]; + if ("available_header" in steps) { + available_header = steps["available_header"]; } - index = proofSteps["index"]; - proofSteps = proofSteps["steps"]; + index = steps["index"]; + steps = steps["steps"]; } - // Convert proofSteps to an object if it is still a string (occurs when the proof steps comes from a Maxima variable) - if (typeof proofSteps === "string") { - proofSteps = _stackstring_objectify(proofSteps); + // Convert steps to an object if it is still a string (occurs when the steps comes from a Maxima variable) + if (typeof steps === "string") { + steps = _stackstring_objectify(steps); } - return [proofSteps, sortableUserOpts, headers, available_header, index, valid]; + return [steps, sortableUserOpts, headers, available_header, index, valid]; } /** @@ -111,30 +111,31 @@ function _stackstring_objectify(stackjson_array_string) { } /** - * Validate the structure of Parson's JSON for proof steps. + * Validate the structure of Parson's JSON `steps`. * - * The function checks the structure of the provided Parson's JSON (`proofSteps`) + * The function checks the structure of the provided Parson's JSON (`steps`) * to ensure it follows specific patterns: - * 1. If the JSON has depth 1, it should be a valid proofStep JSON (i.e., should have string values). - * 2. If the JSON has depth 2, the top-level keys should be ["steps", "options"], and the value for "steps" - * should be a valid proofStep JSON. Options are not validated here. + * 1. If the JSON has depth 1, it should be a valid flat JSON (i.e., should have string values). + * 2. If the JSON has depth 2, the top-level keys should be a subset of + * `["steps", "options", "headers", "index", "available_header"]`, and must contain `"steps"`. + * The value for "steps" should be a valid flat JSON. Options are not validated here. * - * @param {Object} proofSteps - The Parson's JSON to be validated. + * @param {Object} steps - The Parson's JSON to be validated. * @returns {boolean} - Returns true if the provided Parsons JSON follows the expected structure, false otherwise. * * @example * // Returns true * _validate_parsons_JSON({ - * "step1": "proof step 1", - * "step2": "proof step 2" + * "step1": "step 1 text", + * "step2": "step 2 text" * }); * * @example * // Returns true * _validate_parsons_JSON({ * "steps": { - * "step1": "proof step 1", - * "step2": "proof step 2" + * "step1": "step 1 text", + * "step2": "step 2 text" * }, * "options": { * "option1": "value1", @@ -146,72 +147,79 @@ function _stackstring_objectify(stackjson_array_string) { * // Returns false * _validate_parsons_JSON({ * "invalidKey": { - * "step1": "proof step 1", - * "step2": "proof step 2" + * "step1": "step 1 text", + * "step2": "step 2 text" * } * }); */ -function _validate_parsons_JSON(proofSteps) { - // If the JSON has depth 1 then it should be a valid proofStep JSON (i.e., should have string values) - if (Object.values(proofSteps).every((val) => !(typeof(val) == 'object'))) { - return _validate_proof_steps(proofSteps); +function _validate_parsons_JSON(steps) { + // If the JSON has depth 1 then it should be a valid flat JSON (i.e., should have string values). + if (Object.values(steps).every((val) => !(typeof(val) == 'object'))) { + return _validate_flat_steps(steps); } - // Else the top-level of the JSON should have keys ["steps", "options"]. - // The value for "keys" should be a valid proofStep JSON - // We do not validate options here - if (Object.values(proofSteps).some((val) => typeof(val) == "object")) { - if (JSON.stringify(Object.keys(proofSteps)) !== JSON.stringify(["steps", "options"])) { + // Else the top-level of the JSON should have keys that are a subset of ["steps", "options", "headers", "index", "available_header"] + // and a superset of ["steps"]. + // The value for "steps" should be a valid flat JSON. + // We do not validate options here. + if (Object.values(steps).some((val) => typeof(val) == "object")) { + if (!_validate_top_level_keys_JSON(steps, ["steps", "options", "headers", "index", "available_header"], ["steps"])) { return false; } - if (!_validate_proof_steps(proofSteps["steps"])) { + if (!_validate_flat_steps(steps["steps"])) { return false; } return true; } - // TO-DO : we are missing one case here in depth 2 case and unclear how to catch it: - // if an author writes {"any string" : {#stackjson_stringify(proof_steps)#}}, - // then this should throw an error } /** - * Validate the structure of proof steps. + * Validate the structure of a flat steps JSON. * - * The function checks the structure of the provided proof steps (`proofSteps`) + * The function checks the structure of the provided steps (`steps`) * to ensure that all values are strings. * - * If the proof steps are provided as a Maxima variable (string of form '[["key", "value"], ...]'), they are converted + * If the steps are provided as a Maxima variable (string of form '[["key", "value"], ...]'), they are converted * to a JSON object using the `_stackstring_objectify` function before validation. * - * @param {string|Object} proofSteps - The proof steps to be validated. If a string, + * @param {string|Object} steps - The flat JSON to be validated. If a string, * it is assumed to be a Maxima variable and will be converted to a JSON object. - * @returns {boolean} - Returns true if all values in the proof steps are strings, false otherwise. + * @returns {boolean} - Returns true if all values in `steps` are strings, false otherwise. * * @example * // Returns true - * _validate_proof_steps({ - * step1: "Proof step 1", - * step2: "Proof step 2" + * _validate_flat_steps({ + * step1: "step 1 text", + * step2: "step 2 text" * }); * * @example * // Returns true - * _validate_proof_steps('["step1", "Proof step 1"], ["step2", "Proof step 2"]]'); + * _validate_flat_steps('["step1", "step 1 text"], ["step2", "step 2 text"]]'); * * @example * // Returns false - * _validate_proof_steps({ - * step1: "Proof step 1", + * _validate_flat_steps({ + * step1: "step 1 text", * step2: 123 // Not a string * }); */ -function _validate_proof_steps(proofSteps) { - // Case when proof steps are coming from a Maxima variable: convert to a JSON - if (typeof(proofSteps) == 'string') { - proofSteps = _stackstring_objectify(proofSteps); +function _validate_flat_steps(steps) { + // Case when steps are coming from a Maxima variable: convert to a JSON + if (typeof(steps) == 'string') { + steps = _stackstring_objectify(steps); } - return Object.values(proofSteps).every((val) => typeof(val) == 'string'); + return Object.values(steps).every((val) => typeof(val) == 'string'); } +/** + * Validates the top-level keys of a JSON object by checking they are a subset of `validKeys` + * and a superset of `requiredKeys`. + * + * @param {Object} JSON - The JSON object to validate. + * @param {Array} validKeys - An array of valid top-level keys. + * @param {Array} requiredKeys - An array of top-level keys that are required. + * @returns {boolean} - True if the JSON object passes validation, otherwise false. + */ function _validate_top_level_keys_JSON(JSON, validKeys, requiredKeys) { const keys = Object.keys(JSON); const missingRequiredKeys = requiredKeys.filter(key => !keys.includes(key)); @@ -219,57 +227,6 @@ function _validate_top_level_keys_JSON(JSON, validKeys, requiredKeys) { return invalidKeys.length === 0 && missingRequiredKeys.length === 0; } -/** - * Flips the orientation of specified used and available lists, and the bin (if present) in the UI. - * The function toggles between 'list-group row' and 'list-group col' classes for the specified elements. - * The bin element (if present) is expected to have ID 'bin'. - * - * @param {string} usedId - The ID of the used list element. - * @param {string} availableId - The ID of the available list element. - * @returns {void} - * - * @example - * // HTML structure: - * // <div id="usedList" class="list-group row">...</div> - * // <div id="availableList" class="list-group row">...</div> - * // <div id="bin" class="list-group row">...</div> - * - * // JavaScript usage: - * _flip_orientation('usedList', 'availableList'); - */ -function _flip_orientation(usedId, availableId) { - var usedList = document.getElementById(usedId); - var availableList = document.getElementById(availableId); - var newClass = usedList.className == 'list-group row' ? 'list-group col' : 'list-group row'; - usedList.setAttribute('class', newClass); - availableList.setAttribute('class', newClass); -} - -/** - * Adds an event listener to a button element with the specified ID to trigger the flipping - * of orientation between 'list-group row' and 'list-group col' classes for specified UI elements. - * This event will also change the orientation of the bin element (if present), which is expected - * to have ID 'bin'. - * - * @param {string} buttonId - The ID of the button element to which the event listener is added. - * @param {string} usedId - The ID of the used list element. - * @param {string} availableId - The ID of the available list element. - * @returns {void} - * - * @example - * // HTML structure: - * // <button id="toggleButton">Toggle Orientation</button> - * // <div id="usedList" class="list-group row">...</div> - * // <div id="availableList" class="list-group row">...</div> - * - * // JavaScript usage: - * add_orientation_listener('toggleButton', 'usedList', 'availableList'); - */ -export function add_orientation_listener(buttonId, usedId, availableId) { - const button = document.getElementById(buttonId); - button.addEventListener('click', () => _flip_orientation(usedId, availableId)); -} - /** * Get the current height of the iframe's content document. * @@ -279,22 +236,18 @@ export function get_iframe_height() { return document.documentElement.offsetHeight; } -function _is_empty_li(li) { - return li.textContent.trim() === '' && li.children.length === 0; -} - /** - * Class for for managing Sortable lists for Parson's proof questions in STACK. + * Class for for managing Sortable lists for Parson's block questions in STACK. * * @class - * @param {Object} proofSteps - Object containing proof steps. + * @param {Object} steps - Object containing flat steps JSON. * @param {string} availableId - ID of the available list element. * @param {string} usedId - ID of the used list element. * @param {string|null} inputId - ID of the input element for storing state (optional). * @param {Object|null} options - Custom options for sortable lists (optional). * @param {boolean} clone - Flag indicating whether to clone elements during drag-and-drop. * - * @property {Object} proofSteps - Object containing proof steps. + * @property {Object} steps - Object containing all steps. * @property {string} inputId - ID of the input element for storing state (optional). * @property {Object} state - Current state of used and available items. * @property {Object} userOptions - User-defined options merged with default options. @@ -334,7 +287,7 @@ export const stack_sortable = class { * Constructor for the StackSortable class. * * @constructor - * @param {Object} proofSteps - Object containing proof steps. + * @param {Object} steps - Object containing the flat steps JSON. * @param {string} availableId - ID of the available list element. * @param {string} usedId - ID of the used list element. * @param {string|null} inputId - ID of the input element for storing state (optional). @@ -342,11 +295,18 @@ export const stack_sortable = class { * of form {used: UsedOptions, available: AvailableOptions} (optional). * @param {boolean} clone - Flag indicating whether to clone elements during sorting. */ - - // TODO : add containerId as param - // TODO : be careful with default parameters, these should all be strings - constructor(proofSteps, inputId = null, options = null, clone = false, columns = 1, rows = null, orientation = "col", index = "", grid = false, item_height = null, item_width = null) { - this.proofSteps = proofSteps; + constructor(steps, + inputId = null, + options = null, + clone = false, + columns = 1, + rows = null, + orientation = "col", + index = "", + grid = false, + item_height = null, + item_width = null) { + this.steps = steps; this.inputId = inputId; this.orientation = orientation; this.columns = (this.orientation === "col") ? columns : rows; @@ -363,22 +323,17 @@ export const stack_sortable = class { this.item_height_width = (this.item_height_width['style'] === '') ? {} : this.item_height_width; this.item_height = (item_height !== '') ? {'style' : `height:${item_height}px;`} : {}; this.item_width = (item_width !== '') ? {'style' : `width:${item_width}px;`} : {}; - this.container_height_width = (this.item_height_width['style'] !== '') ? {'style' : this.item_height_width['style'] + 'margin: 12px;'} : {}; - this.state = this._generate_state(this.proofSteps, inputId, Number(this.columns), Number(this.rows)); + this.state = this._generate_state(this.steps, inputId, Number(this.columns), Number(this.rows)); if (inputId !== null) { this.input = document.getElementById(this.inputId); this.submitted = this.input.getAttribute("readonly") === "readonly" } this.ids = this._create_ids(this.rows, this.columns); this.availableId = this.ids.available; - //this.available = document.getElementById(this.availableId); this.usedId = this.ids.used; - //this.used = this.usedId.map(idList => idList.map(id => document.getElementById(id))); - //this.used = document.getElementById(this.usedId); this.clone = clone; - // TODO : additional default options? this.defaultOptions = {used: {animation: 50, cancel: ".header"}, available: {animation: 50, cancel: ".header"}}; // Merges user options and default, overwriting default with user options if they clash this.userOptions = this._set_user_options(options); @@ -389,22 +344,118 @@ export const stack_sortable = class { this.options = this._set_ghostClass_group_and_disabled_options(); } - _create_ids(rows, columns) { - var colIdx = Array.from({length: columns}, (_, i) => i); - var rowIdx = Array.from({length: rows}, (_, j) => j); - this.colIds = colIdx.map((idx) => `usedList_${idx}`); - this.rowColIds = {} - colIdx.forEach((i) => this.rowColIds[this.colIds[i]] = rowIdx.map((j) => `usedList_${j}${i}`)); - var usedIds = (rows === "") ? - this.colIds.map((id) => [id]) : - Object.values(this.rowColIds); + /** + * Adds double-click listeners to move items upon double-click and updates the state accordingly. + * Only supported for proofmode + * TODO : fix this + * + * @method + * @param {Object} newUsed - Updated used list. + * @param {Object} newAvailable - Updated available list. + * @returns {void} + */ + add_dblclick_listeners(newUsed, newAvailable) { + this.available.addEventListener('dblclick', (e) => { + if (this._double_clickable(e.target)) { + // get highest-level parent + var li = this._get_moveable_parent_li(e.target); + li = (this.clone === "true") ? li.cloneNode(true) : this.available.removeChild(li); + this.used[0].append(li); + this.update_state(newUsed, newAvailable); + } + }); + this.used[0].addEventListener('dblclick', (e) => { + if (this._double_clickable(e.target)) { + // get highest-level parent + var li = this._get_moveable_parent_li(e.target); + this.used[0].removeChild(li); + if (this.clone !== "true") { + this.available.insertBefore(li, this.available.children[1]); + } + this.update_state(newUsed, newAvailable); + } + }); + } - return { - used: usedIds, - available: "availableList" - }; + /** + * Add a click event listener to a button to delete all items from the "used" list and + * updates the state accordingly. + * + * @method + * @param {string} buttonId - ID of the button element to attach the listener. + * @param {Object} newUsed - Updated "used" list. + * @param {Object} newAvailable - Updated "available" list. + * @returns {void} + */ + add_delete_all_listener(buttonId, newUsed, newAvailable) { + const button = document.getElementById(buttonId); + button.addEventListener('click', () => { + this._delete_all_from_used(); this.update_state(newUsed, newAvailable);}); + } + + /** + * Adds header elements to the used and available lists. + * + * @method + * @param {Object} headers - Object containing header text for used and available lists. + * @returns {void} + */ + add_headers(headers, available_header) { + for (const [i, value] of headers.entries()) { + var parentEl = document.getElementById(`usedList_${i}`); + var header = this._create_header(value, `usedHeader_${i}`, this.item_height_width); + parentEl.insertBefore(header, parentEl.firstChild); + } + var parentEl = document.getElementById("availableList"); + parentEl.insertBefore(this._create_header(available_header, "availableHeader", this.item_height_width), parentEl.firstChild); } + /** + * Adds index elements to the DOM based on the provided index array. + * + * @param {Array} index - The array containing index values to be added. + */ + add_index(index) { + for (const [i, value] of index.entries()) { + // Deal with the item in both header and index separately + if (i === 0) { + var idx = this._create_index(value, `usedIndex_${i}`, this.item_height_width); + var addClass = this.orientation === "col" ? "header" : "index"; + idx.classList.add(addClass); + } else { + var idx = this._create_index(value, `usedIndex_${i}`, this.item_height_width); + } + document.getElementById("index").append(idx); + } + } + + /** + * Adds a reorientation button to the document body. + * + * The button allows users to change the orientation of sortable lists between vertical + * and horizontal. + */ + add_reorientation_button() { + var btn = document.createElement("button"); + btn.id = "orientation"; + btn.setAttribute("class", "parsons-button"); + var icon = document.createElement("i"); + icon.setAttribute("class", "fa fa-refresh"); + btn.append(icon); + btn.addEventListener("click", () => this._flip_orientation()); + document.body.insertBefore(btn, document.getElementById("containerRow")); + } + + /** + * Populates the DOM with row and column div elements to the document based + * on how many columns and rows are being passed to the instance. + * + * How this occurs depends on various configurations. + * - Lists should contain the `"row"` or `"col"` class according to the orientation. + * - If the class is being used for proof (i.e., `this.grid === false`), then the list class should + * also contain `"list-group"`. + * - Items class depends only on the orientation. + */ create_row_col_divs() { var usedClassList = (!this.grid || this.orientation === "col") ? ["list-group", this.orientation, "usedList"]: @@ -456,121 +507,51 @@ export const stack_sortable = class { this.available = document.getElementById(this.availableId); } - _flip_orientation() { - var addClass = (this.orientation === "row") ? ["list-group", "col"] : ["row"]; - if (this.grid) { - var removeClass = (this.orientation === "row") ? ["list-group", "row"] : ["list-group", "col"]; - var currGridClass = (this.orientation === "row") ? "grid-item-rigid" : "grid-item"; - var gridAddClass = (this.orientation === "row") ? "grid-item" : "grid-item-rigid" - var gridItems = document.querySelectorAll(`.${currGridClass}`); - gridItems.forEach((item) => { - item.classList.remove(currGridClass); - item.classList.add(gridAddClass); - }) - - if (this.rows !== "") { - [].concat(...this.used).forEach((div) => { - if (this.orientation === "col") { - div.classList.remove("row"); - div.classList.add("col", "col-rigid"); - } else { - div.classList.remove("col", "col-rigid"); - div.classList.add("row"); - } - }) - } - } else { - var removeClass = (this.orientation === "row") ? ["row"] : ["col"]; - } - this.colIds.forEach((colId) => { - var ul = document.getElementById(colId); - ul.classList.remove(...removeClass); - ul.classList.add(...addClass); - } - ); - - this.available.classList.remove(...removeClass); - this.available.classList.add(...addClass); - if (this.orientation === "col") { - this.available.parentNode.insertBefore(this.available, this.available.parentNode.firstChild); - } else { - this.available.parentNode.append(this.available); - } + /** + * Generates the available list based on the current state. + * + * @method + * @returns {void} + */ + generate_available() { + this.state.available.forEach(key => this.available.append(this._create_li(key, this.item_height_width))); + } - if (this.grid) { - if (this.orientation === "col") { - document.querySelectorAll(".header").forEach((header) => { - if (!header.classList.contains("index")) { - header.classList.remove("header"); - header.classList.add("index"); - } - }); + /** + * Generates the used list based on the current state. + * + * @method + * @returns {void} + */ + generate_used() { + for (const [i, value] of this.state.used.entries()) { + if (this.rows !== "" && this.columns !== "") { + for (const [j, val] of value.entries()) { + this._apply_attrs(this.used[i][j], this.container_height_width); + val.forEach(key => this.used[i][j].append(this._create_li(key, this.item_height_width))); + } } else { - document.querySelectorAll(".index").forEach((index) => { - if (!index.classList.contains("header")) { - index.classList.remove("index"); - index.classList.add("header"); - } - }) + value[0].forEach(key => this.used[i][0].append(this._create_li(key, this.item_height_width))); } - }; - - if (this.use_index) { - var indexDiv = document.getElementById("index"); - indexDiv.classList.remove(...removeClass); - indexDiv.classList.add(...addClass); - if (this.orientation === "col") { - document.querySelectorAll("#index > .index").forEach((idx) => { - if (!idx.classList.contains("header")) { - idx.classList.remove("index"); - idx.classList.add("header"); - } - }) - } else { - document.querySelectorAll('#index > .header').forEach((header) => { - if (!header.classList.contains("index")) { - header.classList.remove("header"); - header.classList.add("index"); - } - }) } } - this.orientation = (this.orientation === "row") ? "col" : "row"; - - /*var usedList = document.getElementById(usedId); - var availableList = document.getElementById(availableId); - var newClass = usedList.className == 'list-group row' ? 'list-group col' : 'list-group row'; - usedList.setAttribute('class', newClass); - availableList.setAttribute('class', newClass);*/ - } - - add_reorientation_button() { - var btn = document.createElement("button"); - btn.id = "orientation"; - btn.setAttribute("class", "parsons-button"); - var icon = document.createElement("i"); - icon.setAttribute("class", "fa fa-refresh"); - btn.append(icon); - btn.addEventListener("click", () => this._flip_orientation()); - document.body.insertBefore(btn, document.getElementById("containerRow")); - } - /*if (orientation === "row") { - var availableEl = document.getElementById("availableList"); - availableEl.classList.remove("list-group", "col"); - availableEl.classList.add(orientation); - availableEl.parentNode.insertBefore(availableEl, availableList.parentNode.firstChild); - //document.getElementById("availableList").classList.add(orientation); - ids.used.forEach((idList) => { - document.getElementById(idList[0]).classList.remove("list-group", "col"); - document.getElementById(idList[0]).classList.add(orientation); - }) - if (use_index) { - var indexEl = document.getElementById("index"); - indexEl.classList.remove("list-group", "col"); - indexEl.classList.add("row"); + /** + * Updates the state based on changes in the used and available lists. + * + * @method + * @param {Object} newUsed - Updated used list. + * @param {Object} newAvailable - Updated available list. + * @returns {void} + */ + update_state(newUsed, newAvailable) { + var newState = {used: newUsed.map((usedList) => usedList.map((used) => used.toArray())), available: newAvailable.toArray()}; + if (this.inputId !== null) { + this.input.value = JSON.stringify(newState); + this.input.dispatchEvent(new Event('change')); } - };*/ + this.state = newState; + } /** * Validate user options against a list of possible option keys. @@ -632,195 +613,299 @@ export const stack_sortable = class { } /** - * Generates the available list based on the current state. - * - * @method - * @returns {void} + * Applies attributes to an HTML element. + * + * @param {HTMLElement} el - The HTML element to which attributes will be applied. + * @param {Object} opts - An object containing attribute-value pairs to be applied. */ - generate_available() { - this.state.available.forEach(key => this.available.append(this._create_li(key, this.item_height_width))); + _apply_attrs(el, opts) { + for (const [key, value] of Object.entries(opts)) { + el.setAttribute(key, value); + } } - _add_index(index, indexDOM) { - for (const [i, value] of index.entries()) { - indexDOM.append(this._create_index(value, `usedIndex${i}`, this.item_height_width)); - } + /** + * Creates a header element with specified inner HTML, ID, and other attributes. + * + * @param {string} innerHTML - The inner HTML content of the header element. + * @param {string} id - The ID attribute of the header element. + * @param {Object} attrs - An object containing additional attributes for the header element. + * @returns {HTMLElement} - The created header element. + */ + _create_header(innerHTML, id, attrs) { + let i = document.createElement("i"); + i.innerHTML = innerHTML; + var addClass = (this.orientation === "col") ? + [this.item_class, 'header'] : [this.item_class, 'index']; + i.classList.add(...addClass); + this._apply_attrs(i, {...{"id" : id}, ...attrs}); + return i; } /** - * Generates the used list based on the current state. - * - * @method - * @returns {void} + * Creates and organizes identifiers for rows and columns. If only columns are passed, then + * the used IDs will just be a flat list `["usedList_0", ..., "usedList_n"]`, where `columns = "n + 1"`. + * If both rows and columns have non-null values, then this will be a two-dimensional array + * `[["usedList_00", "usedList_01", ..., "usedList_0n"], ["usedList_10", ...], ...]`. + * In the two-dimensional case, a mapping between the column IDs `["usedList_0", ...]` and the + * two-dimensional array of item IDs is contained in the object `this.rowColIds`, that is + * `this.rowColIds["usedList_0"] = ["usedList_00", "usedList_01", ...]`. + * + * @param {number} rows - The number of rows. + * @param {number} columns - The number of columns. + * @returns {Object} - An object containing identifiers for used and available elements. */ - generate_used() { - for (const [i, value] of this.state.used.entries()) { - /*if (i === 0 && this.index !== null) { - this._add_index(this.index, value[0]); - }*/ - if (this.rows !== "" && this.columns !== "") { - for (const [j, val] of value.entries()) { - this._apply_attrs(this.used[i][j], this.container_height_width); - val.forEach(key => this.used[i][j].append(this._create_li(key, this.item_height_width))); - } - } else { - value[0].forEach(key => this.used[i][0].append(this._create_li(key, this.item_height_width))); - } - } + _create_ids(rows, columns) { + var colIdx = Array.from({length: columns}, (_, i) => i); + var rowIdx = Array.from({length: rows}, (_, j) => j); + this.colIds = colIdx.map((idx) => `usedList_${idx}`); + this.rowColIds = {} + colIdx.forEach((i) => this.rowColIds[this.colIds[i]] = rowIdx.map((j) => `usedList_${j}${i}`)); + var usedIds = (rows === "") ? + this.colIds.map((id) => [id]) : + Object.values(this.rowColIds); + + return { + used: usedIds, + available: "availableList" + }; } - add_index(index) { - for (const [i, value] of index.entries()) { - if (i === 0) { - var idx = this._create_index(value, `usedIndex_${i}`, this.item_height_width); - var addClass = this.orientation === "col" ? "header" : "index"; - idx.classList.add(addClass); - } else { - var idx = this._create_index(value, `usedIndex_${i}`, this.item_height_width); - } - document.getElementById("index").append(idx); - } + /** + * Creates an index element with specified inner HTML, ID, and additional attributes. + * + * @param {string} innerHTML - The inner HTML content of the index element. + * @param {string} id - The ID attribute of the index element. + * @param {Object} attrs - An object containing additional attributes for the index element. + * @returns {HTMLElement} - The created index element. + */ + _create_index(innerHTML, id, attrs) { + let i = document.createElement("i"); + i.innerHTML = innerHTML; + var addClass = (this.orientation === "col") ? + [this.item_class, 'index'] : [this.item_class, 'header']; + i.classList.add(...addClass); + this._apply_attrs(i, {...{"id" : id}, ...attrs}); + return i; } /** - * Adds header elements to the used and available lists. - * - * @method - * @param {Object} headers - Object containing header text for used and available lists. - * @returns {void} + * Creates a list item (li) element containing the value of the specified key from `this.steps` and attributes. + * + * @param {string} stepKey - The key whose HTML to get from `this.steps`. + * @param {Object} attrs - An object containing additional attributes for the list item element. + * @returns {HTMLElement} - The created list item (li) element. */ - add_headers(headers, available_header) { - for (const [i, value] of headers.entries()) { - var parentEl = document.getElementById(`usedList_${i}`); - var header = this._create_header(value, `usedHeader_${i}`, this.item_height_width); - parentEl.insertBefore(header, parentEl.firstChild); - } - var parentEl = document.getElementById("availableList"); - parentEl.insertBefore(this._create_header(available_header, "availableHeader", this.item_height_width), parentEl.firstChild); + _create_li(stepKey, attrs) { + let li = document.createElement("li"); + li.innerHTML = this.steps[stepKey]; + this._apply_attrs(li, {...{"data-id" : stepKey}, ...attrs}); + li.className = this.item_class; + return li; } + /** + * Checks if a list item (li) is deletable. + * + * @param {HTMLElement} li - The list item (li) element to check. + * @returns {boolean} - True if the list item is deletable, otherwise false. + */ + _deletable_li(li) { + return !li.matches(".header") && !li.matches(".index") && !this._is_empty_li(li); + } /** - * Updates the state based on changes in the used and available lists. + * Delete all non-header items from the "used" list. * * @method - * @param {Object} newUsed - Updated used list. - * @param {Object} newAvailable - Updated available list. + * @private * @returns {void} */ - update_state(newUsed, newAvailable) { - var newState = {used: newUsed.map((usedList) => usedList.map((used) => used.toArray())), available: newAvailable.toArray()}; - if (this.inputId !== null) { - this.input.value = JSON.stringify(newState); - this.input.dispatchEvent(new Event('change')); - } - this.state = newState; + _delete_all_from_used() { + const lis = document.querySelectorAll('.usedList li[data-id]'); + lis.forEach(li => {if (this._deletable_li(li)) {this._delete_li(li);}}); } /** - * Adds double-click listeners to move items upon double-click and updates the state accordingly. + * Deletes a list item (li) from its parent node. + * + * @param {HTMLElement} li - The list item (li) element to delete. + */ + _delete_li(li) { + li.parentNode.removeChild(li); + } + + /** + * Display a warning message on the question page. * * @method - * @param {Object} newUsed - Updated used list. - * @param {Object} newAvailable - Updated available list. + * @private + * @param {string} msg - The message to be displayed in the warning. * @returns {void} */ - add_dblclick_listeners(newUsed, newAvailable) { - this.available.addEventListener('dblclick', (e) => { - if (this._double_clickable(e.target)) { - // get highest-level parent - var li = this._get_moveable_parent_li(e.target); - li = (this.clone === "true") ? li.cloneNode(true) : this.available.removeChild(li); - this.used[0].append(li); - this.update_state(newUsed, newAvailable); - } - }); - this.used[0].addEventListener('dblclick', (e) => { - if (this._double_clickable(e.target)) { - // get highest-level parent - var li = this._get_moveable_parent_li(e.target); - this.used[0].removeChild(li); - if (this.clone !== "true") { - this.available.insertBefore(li, this.available.children[1]); - } - this.update_state(newUsed, newAvailable); - } - }); + _display_warning(msg) { + var warning = document.createElement("div"); + warning.className = "sortable-warning"; + var exclamation = document.createElement("i"); + exclamation.className = "icon fa fa-exclamation-circle text-danger fa-fw"; + warning.append(exclamation); + var warningMessage = document.createElement("span"); + warningMessage.textContent = msg; + warning.append(warningMessage); + document.body.insertBefore(warning, document.getElementById("sortableContainer")); } /** - * Add a click event listener to a button to delete all items from the "used" list and - * updates the state accordingly. + * TODO: fix this, it should not be an index or grid-item or grid-item-rigid + * Check if an HTML element is double-clickable (i.e., it is not a header element). + * + * This private method is called on items inside the used or available list. * * @method - * @param {string} buttonId - ID of the button element to attach the listener. - * @param {Object} newUsed - Updated "used" list. - * @param {Object} newAvailable - Updated "available" list. - * @returns {void} + * @private + * @param {HTMLElement} item - The HTML element to check for double-clickability. + * @returns {boolean} - Returns true if the element is double-clickable, false otherwise. */ - add_delete_all_listener(buttonId, newUsed, newAvailable) { - const button = document.getElementById(buttonId); - button.addEventListener('click', () => { - this._delete_all_from_used(); this.update_state(newUsed, newAvailable);}); + _double_clickable(item) { + return !item.matches(".header"); + } + + /* TODO : simplify this */ + _flip_orientation() { + var addClass = (this.orientation === "row") ? ["list-group", "col"] : ["row"]; + if (this.grid) { + var removeClass = (this.orientation === "row") ? ["list-group", "row"] : ["list-group", "col"]; + var currGridClass = (this.orientation === "row") ? "grid-item-rigid" : "grid-item"; + var gridAddClass = (this.orientation === "row") ? "grid-item" : "grid-item-rigid" + var gridItems = document.querySelectorAll(`.${currGridClass}`); + gridItems.forEach((item) => { + item.classList.remove(currGridClass); + item.classList.add(gridAddClass); + }) + + if (this.rows !== "") { + [].concat(...this.used).forEach((div) => { + if (this.orientation === "col") { + div.classList.remove("row"); + div.classList.add("col", "col-rigid"); + } else { + div.classList.remove("col", "col-rigid"); + div.classList.add("row"); + } + }) + } + } else { + var removeClass = (this.orientation === "row") ? ["row"] : ["col"]; + } + this.colIds.forEach((colId) => { + var ul = document.getElementById(colId); + ul.classList.remove(...removeClass); + ul.classList.add(...addClass); + } + ); + + this.available.classList.remove(...removeClass); + this.available.classList.add(...addClass); + if (this.orientation === "col") { + this.available.parentNode.insertBefore(this.available, this.available.parentNode.firstChild); + } else { + this.available.parentNode.append(this.available); + } + + if (this.grid) { + if (this.orientation === "col") { + document.querySelectorAll(".header").forEach((header) => { + if (!header.classList.contains("index")) { + header.classList.remove("header"); + header.classList.add("index"); + } + }); + } else { + document.querySelectorAll(".index").forEach((index) => { + if (!index.classList.contains("header")) { + index.classList.remove("index"); + index.classList.add("header"); + } + }) + } + }; + + if (this.use_index) { + var indexDiv = document.getElementById("index"); + indexDiv.classList.remove(...removeClass); + indexDiv.classList.add(...addClass); + if (this.orientation === "col") { + document.querySelectorAll("#index > .index").forEach((idx) => { + if (!idx.classList.contains("header")) { + idx.classList.remove("index"); + idx.classList.add("header"); + } + }) + } else { + document.querySelectorAll('#index > .header').forEach((header) => { + if (!header.classList.contains("index")) { + header.classList.remove("header"); + header.classList.add("index"); + } + }) + } + } + this.orientation = (this.orientation === "row") ? "col" : "row"; } /** - * Generates the initial state of used and available items based on the provided proof steps and input ID. + * Generates the initial state of used and available items based on the provided steps, input ID, + * and number of columns and rows used. The shape of the used state will be `(1, 1, ?)` if in proof + * mode, `(n, 1, ?)` if `n` columns are specified and `(n, m, 1)` if `n` columns and `m` rows are specified. * * @method * @private - * @param {Object} proofSteps - Object containing proof steps. + * @param {Object} steps - Object containing steps. * @param {string} inputId - ID of the input element for storing state. * @returns {Object} The initial state object with used and available lists. */ - _generate_state(proofSteps, inputId, columns, rows) { + _generate_state(steps, inputId, columns, rows) { const usedState = (rows === 0 || columns === 0) ? Array(columns).fill().map(() => [[]]) : Array(columns).fill().map(() => Array(rows).fill([])); let stateStore = document.getElementById(inputId); if (stateStore === null) { - return {used: usedState, available: [...Object.keys(proofSteps)]}; + return {used: usedState, available: [...Object.keys(steps)]}; } return (stateStore.value && stateStore.value != "") ? JSON.parse(stateStore.value) : - {used: usedState, available: [...Object.keys(proofSteps)]}; + {used: usedState, available: [...Object.keys(steps)]}; } /** - * Validate if a given option key is among the possible option keys. + * Get the nearest moveable parent list item for a given HTML element. + * + * This private method traverses the DOM hierarchy starting from the provided HTML + * element and finds the nearest parent list item with the class ".list-group-item". + * It is useful for identifying the moveable parent when doubling clicking on child + * elements (for example MathJax display elements) inside list items. * * @method * @private - * @param {string} key - The option key to validate. - * @param {string[]} possibleOptionKeys - List of possible option keys. - * @returns {boolean} - Returns true if the option key is valid, false otherwise. + * @param {HTMLElement} target - The HTML element for which to find the moveable parent list item. + * @returns {HTMLElement|null} - The nearest parent list item with class ".list-group-item", or null if not found. */ - _validate_option_key(key, possibleOptionKeys) { - return possibleOptionKeys.includes(key); + _get_moveable_parent_li(target) { + var li = target; + while (!li.matches(".list-group-item")) { + li = li.parentNode; + } + return li; } /** - * Set and merge user-provided options with default options. - * - * This private method sets user options for both "used" and "available" lists - * by merging the provided options with the default options. If no options are - * provided, it returns the default options. - * - * @method - * @private - * @param {Object|null} options - Custom options for sortable lists - * of form {used: UsedOptions, available: AvailableOptions} (optional). - * @returns {Object} - Merged user options for "used" and "available" lists. + * Checks if a list item (li) element is empty. + * + * @param {HTMLElement} li - The list item (li) element to check. + * @returns {boolean} - True if the list item is empty, otherwise false. */ - _set_user_options(options) { - var userOptions; - if (options === null) { - userOptions = this.defaultOptions; - } else { - userOptions = {used: Object.assign(this.defaultOptions.used, options.used), - available: Object.assign(this.defaultOptions.available, options.available)}; - } - return userOptions; + _is_empty_li(li) { + return li.textContent.trim() === '' && li.children.length === 0; } /** @@ -900,130 +985,41 @@ export const stack_sortable = class { } /** - * Display a warning message on the question page. - * - * @method - * @private - * @param {string} msg - The message to be displayed in the warning. - * @returns {void} - */ - _display_warning(msg) { - var warning = document.createElement("div"); - warning.className = "sortable-warning"; - var exclamation = document.createElement("i"); - exclamation.className = "icon fa fa-exclamation-circle text-danger fa-fw"; - warning.append(exclamation); - var warningMessage = document.createElement("span"); - warningMessage.textContent = msg; - warning.append(warningMessage); - document.body.insertBefore(warning, document.getElementById("sortableContainer")); - } - - /** - * Create an HTML list item element based on keys in `this.proofSteps`. - * - * @method - * @private - * @param {string} proofKey - The key associated with the proof content in 'proofSteps'. - * @returns {HTMLElement} - The created list item element. - */ - _create_li(proofKey, opts) { - let li = document.createElement("li"); - li.innerHTML = this.proofSteps[proofKey]; - this._apply_attrs(li, {...{"data-id" : proofKey}, ...opts}); - li.className = this.item_class; - return li; - } - - _apply_attrs(el, opts) { - for (const [key, value] of Object.entries(opts)) { - el.setAttribute(key, value); - } - } - - /** - * Creates a header element. - * - * @method - * @private - * @param {string} innerHTML - Inner HTML content of the header. - * @param {string} id - ID of the header element. - * @returns {HTMLElement} The created header element. - */ - _create_header(innerHTML, id, opts) { - let i = document.createElement("i"); - i.innerHTML = innerHTML; - var addClass = (this.orientation === "col") ? - [this.item_class, 'header'] : [this.item_class, 'index']; - i.classList.add(...addClass); - this._apply_attrs(i, {...{"id" : id}, ...opts}); - return i; - } - - _create_index(innerHTML, id, opts) { - let i = document.createElement("i"); - i.innerHTML = innerHTML; - var addClass = (this.orientation === "col") ? - [this.item_class, 'index'] : [this.item_class, 'header']; - i.classList.add(...addClass); - this._apply_attrs(i, {...{"id" : id}, ...opts}); - return i; - } - - /** - * Check if an HTML element is double-clickable (i.e., it is not a header element). - * - * This private method is called on items inside the used or available list. - * - * @method - * @private - * @param {HTMLElement} item - The HTML element to check for double-clickability. - * @returns {boolean} - Returns true if the element is double-clickable, false otherwise. - */ - _double_clickable(item) { - return !item.matches(".header"); - } - - /** - * Get the nearest moveable parent list item for a given HTML element. + * Set and merge user-provided options with default options. * - * This private method traverses the DOM hierarchy starting from the provided HTML - * element and finds the nearest parent list item with the class ".list-group-item". - * It is useful for identifying the moveable parent when doubling clicking on child - * elements (for example MathJax display elements) inside list items. + * This private method sets user options for both "used" and "available" lists + * by merging the provided options with the default options. If no options are + * provided, it returns the default options. * * @method * @private - * @param {HTMLElement} target - The HTML element for which to find the moveable parent list item. - * @returns {HTMLElement|null} - The nearest parent list item with class ".list-group-item", or null if not found. + * @param {Object|null} options - Custom options for sortable lists + * of form {used: UsedOptions, available: AvailableOptions} (optional). + * @returns {Object} - Merged user options for "used" and "available" lists. */ - _get_moveable_parent_li(target) { - var li = target; - while (!li.matches(".list-group-item")) { - li = li.parentNode; + _set_user_options(options) { + var userOptions; + if (options === null) { + userOptions = this.defaultOptions; + } else { + userOptions = {used: Object.assign(this.defaultOptions.used, options.used), + available: Object.assign(this.defaultOptions.available, options.available)}; } - return li; - } - - _deletable_li(li) { - return !li.matches(".header") && !li.matches(".index") && !_is_empty_li(li); + return userOptions; } - _delete_li(li) { - li.parentNode.removeChild(li); - } /** - * Delete all non-header items from the "used" list. + * Validate if a given option key is among the possible option keys. * * @method * @private - * @returns {void} + * @param {string} key - The option key to validate. + * @param {string[]} possibleOptionKeys - List of possible option keys. + * @returns {boolean} - Returns true if the option key is valid, false otherwise. */ - _delete_all_from_used() { - const lis = document.querySelectorAll('.usedList li[data-id]'); - lis.forEach(li => {if (this._deletable_li(li)) {this._delete_li(li);}}); + _validate_option_key(key, possibleOptionKeys) { + return possibleOptionKeys.includes(key); } - }; export default {stack_sortable};