/** * List block tracker for UUID-aware list editing and serialization. * * Dependencies: wp.blocks, SFE.ElementPrep * Exposes: SFE.ListBlockTracker */ (function() { 'use strict'; window.MWP = window.MWP || {}; window.MWP.SFE = window.MWP.SFE || {}; const SFE = window.MWP.SFE; SFE.ManagerData = SFE.ManagerData || {}; const { createBlock, serialize: serializeBlocks } = window.wp?.blocks || {}; const UUID_ATTR_KEYS = ['mwpSfeUuid', 'mwpSfeUuidShadow']; const LIST_ITEM_TEXT_ATTR = 'data-mwp-sfe-list-item-text'; const LIST_ITEM_TEXT_CLASS = 'mwp-sfe-list-item-text'; const PLACEHOLDER_ATTR = 'data-rich-text-placeholder'; const LIST_ID_ATTR = 'data-list-id'; const LIST_ITEM_ID_ATTR = 'data-item-id'; const LIST_RUNTIME_UUID_ATTR = 'data-mwp-sfe-list-runtime-uuid'; const LIST_ITEM_RUNTIME_UUID_ATTR = 'data-mwp-sfe-list-item-runtime-uuid'; /** * Return whether one node is a nested list element. * * @param {Node|null} node Candidate DOM node. * @returns {boolean} True when the node is a nested list. */ function isNestedListNode(node) { return !!( node && node.nodeType === Node.ELEMENT_NODE && (node.tagName === 'UL' || node.tagName === 'OL') ); } /** * Return whether one node is ignorable formatting whitespace between list * structures. * * Pretty-printed block markup often leaves direct `\n` text nodes between a * list item's own text and its nested child list. Those nodes should not be * moved into the direct text surface because they become visible editing * artifacts once wrapped. * * @param {Node|null} node Candidate DOM node. * @returns {boolean} True when the node is ignorable whitespace. */ function isIgnorableListWhitespaceNode(node) { return !!( node && node.nodeType === Node.TEXT_NODE && String(node.textContent || '') .replace(/\uFEFF/g, '') .replace(/\u00A0/g, ' ') .trim() .length === 0 ); } /** * Return the direct inline text surface for one list item. * * @param {HTMLLIElement|null} liElement Candidate list item. * @returns {HTMLElement|null} Existing direct text surface, if any. */ function getDirectItemTextSurface(liElement) { if (!liElement || liElement.nodeType !== Node.ELEMENT_NODE || liElement.tagName !== 'LI') { return null; } return Array.from(liElement.children || []).find(child => ( child && child.nodeType === Node.ELEMENT_NODE && child.getAttribute(LIST_ITEM_TEXT_ATTR) === '1' )) || null; } /** * Return the DOM attribute name that stores one session-scoped runtime UUID. * * Runtime UUIDs are distinct from the existing serialization/attr UUIDs. They * exist only while the editor session is open so external callers can point at * the current live list cursor/target without having to manage shifting paths. * * @param {string} type Supported runtime identity type. * @returns {string} Attribute name, or an empty string for invalid types. */ function getRuntimeUuidAttributeName(type) { if (type === 'list') { return LIST_RUNTIME_UUID_ATTR; } if (type === 'item') { return LIST_ITEM_RUNTIME_UUID_ATTR; } return ''; } /** * Normalize one runtime UUID candidate. * * @param {*} value Candidate runtime UUID. * @returns {string} Trimmed runtime UUID, or an empty string. */ function normalizeRuntimeUuid(value) { return String(value || '').trim(); } /** * Return whether one element matches the requested runtime-identity type. * * @param {Element|null} element Candidate DOM element. * @param {string} type Supported runtime identity type. * @returns {boolean} True when the element matches the type. */ function isRuntimeIdentityElement(element, type) { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return false; } if (type === 'list') { return element.tagName === 'UL' || element.tagName === 'OL'; } if (type === 'item') { return element.tagName === 'LI'; } return false; } /** * Return one tracked element by runtime UUID from the live DOM. * * This intentionally queries the current DOM first instead of trusting only a * cached map so history restores and other structural replacements can keep * runtime UUID resolution stable as long as the attributes remain present. * * @param {Object|null} tracker Active list tracker. * @param {string} runtimeUuid Session-scoped runtime UUID. * @param {string} type Supported runtime identity type. * @returns {Element|null} Matching live DOM element. */ function findElementByRuntimeUuid(tracker, runtimeUuid, type) { const normalizedRuntimeUuid = normalizeRuntimeUuid(runtimeUuid); const attrName = getRuntimeUuidAttributeName(type); if ( !tracker?.listElement || !normalizedRuntimeUuid || !attrName || !isRuntimeIdentityElement(tracker.listElement, 'list') ) { return null; } if ( type === 'list' && tracker.listElement.getAttribute(attrName) === normalizedRuntimeUuid ) { return tracker.listElement; } try { const selector = `[${attrName}="${CSS.escape(normalizedRuntimeUuid)}"]`; return tracker.listElement.querySelector(selector); } catch (error) { const escapedUuid = normalizedRuntimeUuid.replace(/"/g, '\\"'); return tracker.listElement.querySelector(`[${attrName}="${escapedUuid}"]`); } } /** * Return every direct text surface currently attached to one list item. * * Merge/delete flows can temporarily move multiple direct text surfaces into * the same list item. The shared list normalizer must collapse them back to * one canonical surface before placeholder syncing or serialization. * * @param {HTMLLIElement|null} liElement Candidate list item. * @returns {HTMLElement[]} Direct text surfaces in DOM order. */ function getAllDirectItemTextSurfaces(liElement) { if (!liElement || liElement.nodeType !== Node.ELEMENT_NODE || liElement.tagName !== 'LI') { return []; } return Array.from(liElement.children || []).filter(child => ( child && child.nodeType === Node.ELEMENT_NODE && child.getAttribute(LIST_ITEM_TEXT_ATTR) === '1' )); } /** * Return whether one node is only placeholder/caret scaffolding. * * Duplicate direct text surfaces may carry empty placeholder anchors or * caret `
` nodes. Those artifacts should be dropped when collapsing * duplicate surfaces instead of being concatenated into visible stacks. * * @param {Node|null} node Candidate DOM node. * @returns {boolean} True when the node is redundant placeholder UI. */ function isRedundantSurfaceArtifact(node) { if (!node) { return true; } if (isIgnorableListWhitespaceNode(node)) { return true; } if (node.nodeType === Node.TEXT_NODE) { return String(node.textContent || '') .replace(/\uFEFF/g, '') .replace(/\u00A0/g, ' ') .trim() .length === 0; } if (node.nodeType !== Node.ELEMENT_NODE) { return false; } if (node.tagName === 'BR') { return true; } return !!node.hasAttribute?.(PLACEHOLDER_ATTR); } /** * Ensure one list item owns one direct inline text surface. * * The root list remains the single live editor host, but this wrapper gives * schema/ABE flows one stable DOM surface per list item's own text content. * * @param {HTMLLIElement|null} liElement Candidate list item. * @returns {HTMLElement|null} Ensured direct text surface. */ function ensureDirectItemTextSurface(liElement) { if (!liElement || liElement.nodeType !== Node.ELEMENT_NODE || liElement.tagName !== 'LI') { return null; } let surface = getDirectItemTextSurface(liElement); if (!surface) { surface = document.createElement('span'); surface.setAttribute(LIST_ITEM_TEXT_ATTR, '1'); surface.classList.add(LIST_ITEM_TEXT_CLASS); liElement.insertBefore( surface, Array.from(liElement.childNodes || []).find(isNestedListNode) || null ); } getAllDirectItemTextSurfaces(liElement) .filter(candidate => candidate && candidate !== surface) .forEach(duplicateSurface => { Array.from(duplicateSurface.childNodes || []).forEach(node => { if (isRedundantSurfaceArtifact(node)) { node.remove(); return; } surface.appendChild(node); }); duplicateSurface.remove(); }); const childNodes = Array.from(liElement.childNodes || []); childNodes.forEach(node => { if ( node && node !== surface && !isNestedListNode(node) && isIgnorableListWhitespaceNode(node) ) { node.remove(); } }); const movableNodes = childNodes.filter(node => { if (!node || node === surface || isNestedListNode(node)) { return false; } if (isIgnorableListWhitespaceNode(node)) { return false; } return !( node.nodeType === Node.ELEMENT_NODE && node.getAttribute?.(LIST_ITEM_TEXT_ATTR) === '1' ); }); movableNodes.forEach(node => surface.appendChild(node)); return surface; } /** * Clone list attrs while optionally stripping plugin UUID ownership. * * The outermost core/list is the only persisted UUID owner for an entire * list tree. Nested core/list blocks are structural children of that root. * If we preserve a nested list UUID here, one accidental assignment can be * re-serialized forever and split a single logical list into multiple * history/edit targets. Keep this guard unless the ownership model changes * everywhere else in PHP and JS at the same time. * * @param {Object} attrs Parsed Gutenberg attrs. * @param {boolean} allowUuidOwnership True for the root list only. * @returns {Object} Safe cloned attrs. */ function cloneListAttrs(attrs, allowUuidOwnership = true) { const clonedAttrs = JSON.parse(JSON.stringify(attrs || {})); if (allowUuidOwnership) { return clonedAttrs; } UUID_ATTR_KEYS.forEach(key => delete clonedAttrs[key]); return clonedAttrs; } /** * Return the direct list-item children for one list element. * * @param {HTMLElement|null} listElement Candidate list element. * @returns {HTMLLIElement[]} Direct child list items. */ function getDirectListItems(listElement) { if (!listElement || listElement.nodeType !== Node.ELEMENT_NODE) { return []; } return Array.from(listElement.children || []).filter(child => child.tagName === 'LI'); } /** * Normalize one path-like value into zero-based list indexes. * * Supported inputs: * - `0_1_2` * - `1.2.3` * - arrays of integers * * @param {string|Array|null} pathValue Candidate path value. * @returns {number[]|null} Parsed zero-based indexes. */ function normalizePathIndexes(pathValue) { if (Array.isArray(pathValue)) { const indexes = pathValue.map(value => Number.parseInt(value, 10)); return indexes.every(Number.isInteger) && indexes.every(index => index >= 0) ? indexes : null; } const raw = typeof pathValue === 'string' ? pathValue.trim() : ''; if (!raw) { return []; } const separator = raw.includes('.') ? '.' : '_'; const parts = raw.split(separator).filter(Boolean); if (!parts.length) { return []; } const indexes = parts.map(part => Number.parseInt(part, 10)); if (!indexes.every(Number.isInteger)) { return null; } if (separator === '.') { return indexes.every(index => index > 0) ? indexes.map(index => index - 1) : null; } return indexes.every(index => index >= 0) ? indexes : null; } /** * Convert one zero-based path index list into public path metadata. * * @param {number[]} indexes Zero-based indexes. * @returns {{path: string, pathLabel: string, depth: number}} Path metadata. */ function buildPathMeta(indexes) { const safeIndexes = Array.isArray(indexes) ? indexes.filter(Number.isInteger) : []; return { path: safeIndexes.join('_'), pathLabel: safeIndexes.map(index => index + 1).join('.'), depth: Math.max(0, safeIndexes.length - 1), }; } /** * Return the direct child list element for one list item. * * @param {HTMLLIElement|null} listItem Candidate list item. * @returns {HTMLElement|null} Direct nested list, if present. */ function getDirectChildList(listItem) { if (!listItem || listItem.nodeType !== Node.ELEMENT_NODE || listItem.tagName !== 'LI') { return null; } return Array.from(listItem.children || []).find(child => ( child.tagName === 'UL' || child.tagName === 'OL' )) || null; } /** * Return the preferred nested list tag for one list item. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Parent list item. * @returns {string} `UL` or `OL`. */ function getPreferredChildListTagName(tracker, listItem) { const directChildList = getDirectChildList(listItem); if (directChildList) { return directChildList.tagName; } const parentList = listItem?.parentElement; if (parentList && (parentList.tagName === 'UL' || parentList.tagName === 'OL')) { return parentList.tagName; } return tracker?.listElement?.tagName === 'OL' ? 'OL' : 'UL'; } /** * Return one direct child list for a list item, creating it only when needed. * * Outdent can promote one item and then re-home its trailing siblings beneath * that promoted item. When the promoted item already owns a child list, those * siblings must be appended into the existing list so the DOM mirrors native * editor behavior instead of creating duplicate sibling list wrappers. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Parent list item. * @param {string} preferredTagName Fallback list tag name. * @returns {HTMLElement|null} Direct child list element. */ function ensureDirectChildList(tracker, listItem, preferredTagName = '') { if (!listItem || listItem.nodeType !== Node.ELEMENT_NODE || listItem.tagName !== 'LI') { return null; } let childList = getDirectChildList(listItem); if (childList) { return childList; } childList = document.createElement( preferredTagName || getPreferredChildListTagName(tracker, listItem) ); childList.classList.add('wp-block-list'); listItem.appendChild(childList); return childList; } /** * Remove empty nested list wrappers up the ancestry chain. * * The root list element is never removed, even when it becomes empty. * * @param {Object|null} tracker Active list tracker. * @param {HTMLElement|null} startList First candidate nested list. * @returns {void} */ function cleanupEmptyAncestorLists(tracker, startList) { let currentList = startList; while ( currentList && currentList !== tracker?.listElement && currentList.nodeType === Node.ELEMENT_NODE && (currentList.tagName === 'UL' || currentList.tagName === 'OL') && !getDirectListItems(currentList).length ) { const parentItem = currentList.parentElement?.tagName === 'LI' ? currentList.parentElement : null; currentList.remove(); currentList = parentItem ? parentItem.parentElement : null; } } /** * Build one new list item element from a structural operation payload. * * @param {Object|null} operation Candidate operation payload. * @returns {HTMLLIElement} New list item element. */ function buildListItemFromOperation(operation) { const li = document.createElement('li'); const directSurface = ensureDirectItemTextSurface(li); const runtimeUuid = normalizeRuntimeUuid( operation?.itemUuid ?? operation?.newItemUuid ); const html = typeof operation?.contentHtml === 'string' ? operation.contentHtml : (typeof operation?.html === 'string' ? operation.html : ''); const text = typeof operation?.contentText === 'string' ? operation.contentText : (typeof operation?.text === 'string' ? operation.text : ''); if (html) { directSurface.innerHTML = html; } else if (text) { directSurface.textContent = text; } if (runtimeUuid) { li.setAttribute(LIST_ITEM_RUNTIME_UUID_ATTR, runtimeUuid); } return li; } /** * Copy one donor item's structural/style attributes onto the destination list * item while preserving the destination runtime UUID. * * Native Enter list splitting happens inside the browser's contenteditable * engine, so the new sibling inherits the source `li` element's attributes * such as class and style automatically. API-driven insert/move operations * should mirror that behavior by cloning the donor item's `li` attributes, * except for the session-scoped runtime UUID which must stay unique. * * @param {HTMLLIElement|null} listItem Destination list item. * @param {HTMLLIElement|null} donorItem Style/structure donor item. * @returns {void} */ function copyDonorItemAttributes(listItem, donorItem) { if ( !listItem || listItem.nodeType !== Node.ELEMENT_NODE || listItem.tagName !== 'LI' || !donorItem || donorItem.nodeType !== Node.ELEMENT_NODE || donorItem.tagName !== 'LI' ) { return; } const destinationRuntimeUuid = normalizeRuntimeUuid( listItem.getAttribute(LIST_ITEM_RUNTIME_UUID_ATTR) ); Array.from(listItem.attributes || []).forEach(attr => { if (attr?.name === LIST_ITEM_RUNTIME_UUID_ATTR) { return; } listItem.removeAttribute(attr.name); }); Array.from(donorItem.attributes || []).forEach(attr => { if (attr?.name === LIST_ITEM_RUNTIME_UUID_ATTR) { return; } listItem.setAttribute(attr.name, attr.value); }); if (destinationRuntimeUuid) { listItem.setAttribute(LIST_ITEM_RUNTIME_UUID_ATTR, destinationRuntimeUuid); } } /** * Reassign one list item's structural ID from its destination styling * context. * * When an explicit target item is known, its structural `data-item-id` * becomes the style donor for insert-before, insert-after, move-before, and * move-after commands. This keeps the inheritance rule simple and matches the * public API's explicit `targetItemUuid` model. * * For internal list-path insert/move cases that do not resolve through one * target item, fall back to the local destination neighbors. If there is no * neighboring item at all, clear the structural ID so the next tracker rebuild * seeds a fresh item identity instead of accidentally preserving source attrs. * * @param {HTMLLIElement|null} listItem Destination list item. * @param {HTMLLIElement|null} targetItem Explicit style donor item. * @returns {string} Applied structural ID or an empty string. */ function inheritDestinationItemId(listItem, targetItem = null) { if (!listItem || listItem.nodeType !== Node.ELEMENT_NODE || listItem.tagName !== 'LI') { return ''; } const explicitTargetItem = targetItem && targetItem.nodeType === Node.ELEMENT_NODE && targetItem.tagName === 'LI' ? targetItem : null; const previousItem = listItem.previousElementSibling?.tagName === 'LI' ? listItem.previousElementSibling : null; const nextItem = listItem.nextElementSibling?.tagName === 'LI' ? listItem.nextElementSibling : null; const inheritedId = normalizeRuntimeUuid( explicitTargetItem?.getAttribute(LIST_ITEM_ID_ATTR) || previousItem?.getAttribute(LIST_ITEM_ID_ATTR) || nextItem?.getAttribute(LIST_ITEM_ID_ATTR) ); if (inheritedId) { listItem.setAttribute(LIST_ITEM_ID_ATTR, inheritedId); return inheritedId; } listItem.removeAttribute(LIST_ITEM_ID_ATTR); return ''; } /** * Apply one remove-list-item operation. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Target list item. * @returns {boolean} True when the mutation applied. */ function applyRemoveListItemOperation(tracker, listItem) { if (!tracker?.listElement || !listItem) { return false; } const oldParentList = listItem.parentElement; listItem.remove(); cleanupEmptyAncestorLists(tracker, oldParentList); return true; } /** * Apply one indent-list-item operation. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Target list item. * @returns {boolean} True when the mutation applied. */ function applyIndentListItemOperation(tracker, listItem) { if (!tracker?.listElement || !listItem) { return false; } const previousItem = listItem.previousElementSibling?.tagName === 'LI' ? listItem.previousElementSibling : null; if (!previousItem) { return false; } const nestedList = ensureDirectChildList(tracker, previousItem); nestedList.appendChild(listItem); return true; } /** * Apply one outdent-list-item operation. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Target list item. * @returns {boolean} True when the mutation applied. */ function applyOutdentListItemOperation(tracker, listItem) { if (!tracker?.listElement || !listItem) { return false; } const parentList = listItem.parentElement; const parentItem = parentList?.parentElement?.tagName === 'LI' ? parentList.parentElement : null; if (!parentList || !parentItem) { return false; } const ancestorList = parentItem.parentElement; const followingSiblings = []; let next = listItem.nextElementSibling; while (next) { followingSiblings.push(next); next = next.nextElementSibling; } ancestorList.insertBefore(listItem, parentItem.nextElementSibling); if (followingSiblings.length) { const nestedList = ensureDirectChildList(tracker, listItem, parentList.tagName); followingSiblings.forEach(sibling => nestedList.appendChild(sibling)); } cleanupEmptyAncestorLists(tracker, parentList); return true; } /** * Apply one toggle-list-type operation. * * @param {Object|null} tracker Active list tracker. * @param {Object} rawOperation Structural operation payload. * @param {Object} options Apply-operation options. * @returns {boolean} True when the mutation applied. */ function applyToggleListTypeOperation(tracker, rawOperation, options = {}) { if (!tracker?.listElement) { return false; } const listPath = rawOperation.listPath ?? rawOperation.list_path ?? ''; const targetList = getListByPath(tracker, listPath); const editorHost = options?.editorHost && typeof options.editorHost.changeListType === 'function' ? options.editorHost : null; const requestedType = targetList ? ( normalizeListTypeTagName( rawOperation.value ?? rawOperation.listType ?? rawOperation.list_type ?? rawOperation.ordered ) || (targetList.tagName === 'OL' ? 'UL' : 'OL') ) : ''; if ( !targetList || !requestedType || targetList.tagName === requestedType || !editorHost ) { return false; } const nextList = editorHost.changeListType(targetList, requestedType.toLowerCase(), { saveHistory: options.saveHistory !== false, restoreCursor: options.restoreCursor !== false, }); return !!nextList; } /** * Apply one update-list-item-text operation. * * @param {HTMLLIElement} listItem Target list item. * @param {Object} rawOperation Structural operation payload. * @returns {boolean} True when the mutation applied. */ function applyUpdateListItemTextOperation(listItem, rawOperation) { if (!listItem) { return false; } const directSurface = ensureDirectItemTextSurface(listItem); const html = typeof rawOperation?.contentHtml === 'string' ? rawOperation.contentHtml : (typeof rawOperation?.html === 'string' ? rawOperation.html : ''); const text = typeof rawOperation?.contentText === 'string' ? rawOperation.contentText : (typeof rawOperation?.text === 'string' ? rawOperation.text : ''); if (!directSurface) { return false; } directSurface.innerHTML = ''; if (html) { directSurface.innerHTML = html; } else if (text) { directSurface.textContent = text; } return true; } /** * Insert one new list item relative to explicit before/after/list anchors. * * @param {Object|null} tracker Active list tracker. * @param {Object} rawOperation Structural operation payload. * @param {Object} trackerApi List tracker API surface. * @returns {boolean} True when the mutation applied. */ function applyInsertListItemOperation(tracker, rawOperation, trackerApi) { if (!tracker?.listElement || !trackerApi) { return false; } const beforePath = rawOperation.beforePath ?? rawOperation.before_path ?? ''; const afterPath = rawOperation.afterPath ?? rawOperation.after_path ?? ''; const listPath = rawOperation.listPath ?? rawOperation.list_path ?? ''; const beforeItem = trackerApi.getItemByPath(tracker, beforePath); const afterItem = trackerApi.getItemByPath(tracker, afterPath); const newListItem = buildListItemFromOperation(rawOperation); if (beforeItem?.parentElement) { beforeItem.parentElement.insertBefore(newListItem, beforeItem); copyDonorItemAttributes(newListItem, beforeItem); inheritDestinationItemId(newListItem, beforeItem); return true; } if (afterItem?.parentElement) { afterItem.parentElement.insertBefore(newListItem, afterItem.nextElementSibling); copyDonorItemAttributes(newListItem, afterItem); inheritDestinationItemId(newListItem, afterItem); return true; } const targetList = getListByPath(tracker, listPath); if (!targetList) { return false; } const position = typeof rawOperation.position === 'string' ? rawOperation.position.trim().toLowerCase() : 'append'; if (position === 'prepend' && targetList.firstElementChild) { targetList.insertBefore(newListItem, targetList.firstElementChild); } else { targetList.appendChild(newListItem); } inheritDestinationItemId(newListItem); return true; } /** * Move one existing list item relative to explicit before/after/list anchors. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Target list item. * @param {Object} rawOperation Structural operation payload. * @param {Object} trackerApi List tracker API surface. * @returns {boolean} True when the mutation applied. */ function applyMoveListItemOperation(tracker, listItem, rawOperation, trackerApi) { if (!tracker?.listElement || !listItem || !trackerApi) { return false; } const beforePath = rawOperation.beforePath ?? rawOperation.before_path ?? ''; const afterPath = rawOperation.afterPath ?? rawOperation.after_path ?? ''; const listPath = rawOperation.listPath ?? rawOperation.list_path ?? ''; const beforeItem = trackerApi.getItemByPath(tracker, beforePath); const afterItem = trackerApi.getItemByPath(tracker, afterPath); const oldParentList = listItem.parentElement; let didApply = false; if (beforeItem && beforeItem !== listItem && !listItem.contains(beforeItem)) { beforeItem.parentElement.insertBefore(listItem, beforeItem); copyDonorItemAttributes(listItem, beforeItem); inheritDestinationItemId(listItem, beforeItem); didApply = true; } else if (afterItem && afterItem !== listItem && !listItem.contains(afterItem)) { afterItem.parentElement.insertBefore(listItem, afterItem.nextElementSibling); copyDonorItemAttributes(listItem, afterItem); inheritDestinationItemId(listItem, afterItem); didApply = true; } else if (typeof listPath === 'string') { const targetList = getListByPath(tracker, listPath); const ownerItem = targetList?.parentElement?.tagName === 'LI' ? targetList.parentElement : null; if (targetList && ownerItem !== listItem && !listItem.contains(ownerItem || null)) { const position = typeof rawOperation.position === 'string' ? rawOperation.position.trim().toLowerCase() : 'append'; if (position === 'prepend' && targetList.firstElementChild) { targetList.insertBefore(listItem, targetList.firstElementChild); } else { targetList.appendChild(listItem); } inheritDestinationItemId(listItem); didApply = true; } } if (didApply) { cleanupEmptyAncestorLists(tracker, oldParentList); } return didApply; } /** * Resolve one tracked list element from a list-path payload. * * The root list lives at the empty path. Nested lists are addressed by the * tree path of the parent item that owns that child list. * * @param {Object|null} tracker Active list tracker. * @param {string|Array} pathValue Root-empty list path or parent-item path. * @returns {HTMLElement|null} Matching list element. */ function getListByPath(tracker, pathValue) { if (!tracker?.listElement) { return null; } if ( pathValue === '' || pathValue === null || typeof pathValue === 'undefined' || (Array.isArray(pathValue) && !pathValue.length) || (typeof pathValue === 'string' && !pathValue.trim()) ) { return tracker.listElement; } const parentItem = ListBlockTracker.getItemByPath(tracker, pathValue); return parentItem ? getDirectChildList(parentItem) : null; } /** * Normalize one requested list-type value to a DOM tag name. * * @param {*} value Candidate list-type value. * @returns {string} `OL`, `UL`, or an empty string. */ function normalizeListTypeTagName(value) { if (value === true) { return 'OL'; } if (value === false) { return 'UL'; } const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'ordered' || normalized === 'ol' || normalized === 'true') { return 'OL'; } if (normalized === 'unordered' || normalized === 'ul' || normalized === 'false') { return 'UL'; } return ''; } const ListBlockTracker = { active: null, /** * Initialize tracker for a list element * @param {HTMLElement} listElement - The UL or OL element * @param {Object} originalBlock - The complete WordPress block structure */ init(listElement, originalBlock = {}) { if (!createBlock || !serializeBlocks) { console.error('wp.blocks not available'); } const tracker = { listElement, originalBlock: JSON.parse(JSON.stringify(originalBlock)), uuidMap: new Map(), // uuid -> {type, attrs, element} domMap: new WeakMap(), // element -> uuid runtimeUuidMap: new Map(), // runtimeUuid -> {type, element} runtimeDomMap: new WeakMap() // element -> runtimeUuid }; // Attach tracker to element to avoid singleton issues listElement._mwpListTracker = tracker; // Build UUID tracking from DOM and original block structure this.buildFromDOM(tracker, listElement, originalBlock); this.active = tracker; return tracker; }, /** * Build UUID mappings from DOM and original block structure * Assigns UUIDs to all lists and list items, mapping to their original attrs */ buildFromDOM(tracker, listElement, originalBlock) { const previousEntries = tracker.uuidMap instanceof Map ? new Map(tracker.uuidMap) : new Map(); const previousRuntimeEntries = tracker.runtimeUuidMap instanceof Map ? new Map(tracker.runtimeUuidMap) : new Map(); tracker.uuidMap.clear(); tracker.domMap = new WeakMap(); tracker.runtimeUuidMap.clear(); tracker.runtimeDomMap = new WeakMap(); tracker.listElement = listElement; this.syncEditableTextSurfaces(listElement); this.registerRuntimeIdentity( tracker, listElement, 'list', previousRuntimeEntries ); // Assign UUID to root list and map to original attrs const rootUuid = this.getOrCreateUuid(listElement, 'list'); const previousRootEntry = previousEntries.get(rootUuid); tracker.uuidMap.set(rootUuid, { type: 'list', attrs: previousRootEntry?.attrs ? cloneListAttrs(previousRootEntry.attrs, true) : cloneListAttrs(originalBlock.attrs || {}, true), element: listElement }); tracker.domMap.set(listElement, rootUuid); // Recursively process list structure this.processListRecursive( tracker, listElement, originalBlock.innerBlocks || [], previousEntries, previousRuntimeEntries ); }, /** * Recursively process list items and nested lists, assigning UUIDs */ processListRecursive( tracker, listElement, originalItems, previousEntries = new Map(), previousRuntimeEntries = new Map() ) { const items = getDirectListItems(listElement); items.forEach((li, index) => { this.syncEditableTextSurfaces(li); const originalItem = originalItems[index] || {}; this.registerRuntimeIdentity( tracker, li, 'item', previousRuntimeEntries ); const itemUuid = this.getOrCreateUuid(li, 'item'); const previousItemEntry = previousEntries.get(itemUuid); // Map UUID to original item attrs tracker.uuidMap.set(itemUuid, { type: 'item', attrs: previousItemEntry?.attrs ? JSON.parse(JSON.stringify(previousItemEntry.attrs || {})) : JSON.parse(JSON.stringify(originalItem.attrs || {})), element: li }); tracker.domMap.set(li, itemUuid); // Handle nested lists const nestedList = getDirectChildList(li); if (nestedList) { const originalNested = originalItem.innerBlocks?.[0] || {}; this.registerRuntimeIdentity( tracker, nestedList, 'list', previousRuntimeEntries ); const nestedUuid = this.getOrCreateUuid(nestedList, 'list'); const previousNestedEntry = previousEntries.get(nestedUuid); // Map nested list attrs without plugin UUID ownership. // The tracker still needs a temporary DOM identity for list // editing, but persisting mwpSfeUuid* on nested lists would // fracture one logical list into multiple save/history roots. tracker.uuidMap.set(nestedUuid, { type: 'list', attrs: previousNestedEntry?.attrs ? cloneListAttrs(previousNestedEntry.attrs, false) : cloneListAttrs(originalNested.attrs || {}, false), element: nestedList }); tracker.domMap.set(nestedList, nestedUuid); // Recurse into nested list this.processListRecursive( tracker, nestedList, originalNested.innerBlocks || [], previousEntries, previousRuntimeEntries ); } }); }, /** * Register one list or list-item runtime identity on the tracker. * * @param {Object} tracker Active list tracker. * @param {Element} element Live list or list-item element. * @param {string} type Supported runtime identity type. * @param {Map} previousRuntimeEntries Previous runtime entry map. * @returns {string} Resolved runtime UUID. */ registerRuntimeIdentity( tracker, element, type, previousRuntimeEntries = new Map() ) { const runtimeUuid = this.getOrCreateRuntimeUuid( tracker, element, type, previousRuntimeEntries ); if (!runtimeUuid) { return ''; } tracker.runtimeUuidMap.set(runtimeUuid, { type, element, }); tracker.runtimeDomMap.set(element, runtimeUuid); return runtimeUuid; }, /** * Return whether one runtime UUID is already owned by a different element. * * Native contenteditable list splitting can clone DOM attributes from the * source item into the newly created sibling. When that happens, the new * runtime UUID must be reseeded so each live cursor target stays unique. * * @param {Object} tracker Active list tracker. * @param {string} runtimeUuid Candidate runtime UUID. * @param {Element|null} element Element requesting that UUID. * @returns {boolean} True when the UUID belongs elsewhere. */ isRuntimeUuidClaimedByDifferentElement(tracker, runtimeUuid, element) { const normalizedRuntimeUuid = normalizeRuntimeUuid(runtimeUuid); if (!tracker?.runtimeUuidMap || !normalizedRuntimeUuid) { return false; } const existingEntry = tracker.runtimeUuidMap.get(normalizedRuntimeUuid); return !!(existingEntry?.element && existingEntry.element !== element); }, /** * Get the inherited structural ID for an element or seed it from the * runtime UUID when it does not exist yet. * * Structural IDs may be intentionally copied by native list splitting so * related items can retain style inheritance. They are not treated as * unique runtime cursor identifiers. */ getOrCreateUuid(element, type) { const attrName = type === 'list' ? LIST_ID_ATTR : LIST_ITEM_ID_ATTR; let uuid = element.getAttribute(attrName); if (!uuid) { uuid = normalizeRuntimeUuid( element.getAttribute( type === 'list' ? LIST_RUNTIME_UUID_ATTR : LIST_ITEM_RUNTIME_UUID_ATTR ) ) || this.generateTempUuid(); element.setAttribute(attrName, uuid); } return uuid; }, /** * Return the existing runtime UUID for one element or create one. * * Caller-supplied runtime UUIDs win for newly created items/lists. When an * element already belongs to the previous tracker build, its existing * runtime UUID is preserved so API references remain stable across rebuilds. * * @param {Object} tracker Active list tracker. * @param {Element} element Live list or list-item element. * @param {string} type Supported runtime identity type. * @param {Map} previousRuntimeEntries Previous runtime entry map. * @returns {string} Session-scoped runtime UUID. */ getOrCreateRuntimeUuid( tracker, element, type, previousRuntimeEntries = new Map() ) { const attrName = getRuntimeUuidAttributeName(type); if (!attrName || !isRuntimeIdentityElement(element, type)) { return ''; } let runtimeUuid = normalizeRuntimeUuid(element.getAttribute(attrName)); if (!runtimeUuid) { for (const [candidateUuid, entry] of previousRuntimeEntries.entries()) { if (entry?.element === element && entry.type === type) { runtimeUuid = normalizeRuntimeUuid(candidateUuid); break; } } } if (this.isRuntimeUuidClaimedByDifferentElement(tracker, runtimeUuid, element)) { runtimeUuid = ''; } if (!runtimeUuid) { runtimeUuid = this.generateTempUuid(); } element.setAttribute(attrName, runtimeUuid); return runtimeUuid; }, /** * Generate a RFC4122 version 4 UUID */ generateTempUuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }, /** * Return the zero-based tree path for one list item inside the tracked list. * * @param {Object} tracker Active list tracker. * @param {HTMLLIElement} listItem Candidate list item. * @returns {number[]|null} Zero-based indexes, or null on failure. */ getPathIndexesForItem(tracker, listItem) { if ( !tracker?.listElement || !listItem || listItem.nodeType !== Node.ELEMENT_NODE || listItem.tagName !== 'LI' || !tracker.listElement.contains(listItem) ) { return null; } const indexes = []; let currentItem = listItem; while (currentItem && tracker.listElement.contains(currentItem)) { const parentList = currentItem.parentElement; if (!parentList || (parentList.tagName !== 'UL' && parentList.tagName !== 'OL')) { return null; } const siblingItems = getDirectListItems(parentList); const itemIndex = siblingItems.indexOf(currentItem); if (itemIndex < 0) { return null; } indexes.unshift(itemIndex); const nextItem = parentList.closest('li'); if (!nextItem || !tracker.listElement.contains(nextItem)) { break; } currentItem = nextItem; } return indexes.length ? indexes : null; }, /** * Return the direct list item currently living at one tree path. * * @param {Object} tracker Active list tracker. * @param {string|Array} pathValue Zero-based path value. * @returns {HTMLLIElement|null} Matching list item. */ getItemByPath(tracker, pathValue) { const indexes = normalizePathIndexes(pathValue); if (!tracker?.listElement || !Array.isArray(indexes) || !indexes.length) { return null; } let currentList = tracker.listElement; let currentItem = null; for (let index = 0; index < indexes.length; index++) { const itemIndex = indexes[index]; const siblingItems = getDirectListItems(currentList); currentItem = siblingItems[itemIndex] || null; if (!currentItem) { return null; } if (index === indexes.length - 1) { return currentItem; } currentList = getDirectChildList(currentItem); if (!currentList) { return null; } } return currentItem; }, /** * Resolve one list element back to its public list path. * * The root list is addressed as an empty string. Nested child lists are * addressed by the path of the parent list item that owns them. * * @param {Object|null} tracker Active list tracker. * @param {HTMLElement|null} listElement Candidate list element. * @returns {string} Root-relative list path. */ getPathForList(tracker, listElement) { if (!tracker) tracker = this.active; if (!tracker?.listElement || !listElement || listElement.nodeType !== Node.ELEMENT_NODE) { return ''; } if (listElement === tracker.listElement) { return ''; } const parentItem = ( listElement.parentElement && listElement.parentElement.tagName === 'LI' ) ? listElement.parentElement : null; if (!parentItem) { return ''; } const indexes = this.getPathIndexesForItem(tracker, parentItem); return Array.isArray(indexes) ? indexes.join('_') : ''; }, /** * Return one tracked list item by runtime UUID. * * @param {Object|null} tracker Active list tracker. * @param {string} runtimeUuid Session-scoped item UUID. * @returns {HTMLLIElement|null} Matching list item. */ getItemByRuntimeUuid(tracker, runtimeUuid) { if (!tracker) tracker = this.active; const element = findElementByRuntimeUuid(tracker, runtimeUuid, 'item'); return element && element.tagName === 'LI' ? element : null; }, /** * Return one tracked list by runtime UUID. * * @param {Object|null} tracker Active list tracker. * @param {string} runtimeUuid Session-scoped list UUID. * @returns {HTMLElement|null} Matching list element. */ getListByRuntimeUuid(tracker, runtimeUuid) { if (!tracker) tracker = this.active; const element = findElementByRuntimeUuid(tracker, runtimeUuid, 'list'); return isListElementForRuntimeLookup(element) ? element : null; }, /** * Return the runtime UUID for one tracked list item. * * @param {Object|null} tracker Active list tracker. * @param {HTMLLIElement} listItem Live list item. * @returns {string} Session-scoped item UUID. */ getRuntimeUuidForItem(tracker, listItem) { if (!tracker) tracker = this.active; if (!tracker?.listElement || !listItem || listItem.tagName !== 'LI') { return ''; } return normalizeRuntimeUuid( listItem.getAttribute(LIST_ITEM_RUNTIME_UUID_ATTR) || tracker.runtimeDomMap?.get(listItem) ); }, /** * Return the runtime UUID for one tracked list. * * @param {Object|null} tracker Active list tracker. * @param {HTMLElement} listElement Live list element. * @returns {string} Session-scoped list UUID. */ getRuntimeUuidForList(tracker, listElement) { if (!tracker) tracker = this.active; if (!tracker?.listElement || !isListElementForRuntimeLookup(listElement)) { return ''; } return normalizeRuntimeUuid( listElement.getAttribute(LIST_RUNTIME_UUID_ATTR) || tracker.runtimeDomMap?.get(listElement) ); }, /** * Parse current DOM list structure to WordPress block format * Uses UUID to preserve original attrs, updates only content and ordered * * @param {HTMLElement} listElement Live UL/OL element. * @param {Object} tracker Active list tracker. * @param {boolean} allowUuidOwnership True only for the outermost * core/list. Nested lists must * serialize without persisted * plugin UUID attrs. */ parseListToBlock(listElement, tracker, allowUuidOwnership = true) { const isOrdered = listElement.tagName === 'OL'; const uuid = listElement.getAttribute(LIST_ID_ATTR); let attrs = { ordered: isOrdered }; if (uuid && tracker && tracker.uuidMap.has(uuid)) { const original = tracker.uuidMap.get(uuid); const originalAttrs = (Array.isArray(original.attrs) || !original.attrs) ? {} : cloneListAttrs(original.attrs, allowUuidOwnership); attrs = { ...originalAttrs, ordered: isOrdered }; } const listItems = Array.from(listElement.children).filter(el => el.tagName === 'LI'); const innerBlocks = listItems.map(li => this.parseListItemToBlock(li, tracker)); return createBlock('core/list', attrs, innerBlocks); }, /** * Parse a list item to WordPress block format * Preserves original attrs if UUID exists */ parseListItemToBlock(liElement, tracker) { const uuid = liElement.getAttribute(LIST_ITEM_ID_ATTR); let attrs = {}; if (uuid && tracker && tracker.uuidMap.has(uuid)) { const original = tracker.uuidMap.get(uuid); attrs = JSON.parse(JSON.stringify(original.attrs || {})); } // Recurse into nested list using the live element before cloning const innerBlocks = []; const nestedListEl = liElement.querySelector(':scope > ul, :scope > ol'); if (nestedListEl) { innerBlocks.push(this.parseListToBlock(nestedListEl, tracker, false)); } const clone = liElement.cloneNode(true); const directSurface = clone.querySelector(`:scope > [${LIST_ITEM_TEXT_ATTR}="1"]`); let content = ''; if (directSurface) { const cleanedSurface = this.cleanElement(directSurface); content = cleanedSurface.innerHTML.trim(); } else { const nestedInClone = clone.querySelector(':scope > ul, :scope > ol'); if (nestedInClone) nestedInClone.remove(); const cleaned = this.cleanElement(clone); content = cleaned.innerHTML.trim(); } return createBlock('core/list-item', { ...attrs, content }, innerBlocks); }, /** * Clean element using ElementPrep * Removes editing artifacts while preserving UUIDs and content */ cleanElement(element) { if (SFE.ElementPrep) { return SFE.ElementPrep.clean(element, { removeIdentity: true, removeControls: true, clone: false // Already working with a clone }); } console.error('ElementPrep not found'); return element; }, /** * Serialize the current list structure to WordPress block format * Preserves all original attrs, updates only content and ordered */ serialize(tracker) { if (!tracker) tracker = this.active; if (!tracker) return null; const block = this.parseListToBlock(tracker.listElement, tracker); return serializeBlocks([block]); }, /** * Update the tracker's element reference * Used when the list element is replaced (e.g., OL to UL conversion) */ updateElement(tracker, newElement) { if (!tracker) tracker = this.active; if (!tracker) return; // Ensure new element keeps the tracker reference newElement._mwpListTracker = tracker; this.syncEditableTextSurfaces(newElement); tracker.listElement = newElement; // Update element references in uuidMap tracker.uuidMap.forEach((value, key) => { if (value.type === 'list') { const newEl = newElement.querySelector(`[${LIST_ID_ATTR}="${key}"]`) || (newElement.getAttribute(LIST_ID_ATTR) === key ? newElement : null); if (newEl) { value.element = newEl; } } else if (value.type === 'item') { const newEl = newElement.querySelector(`[${LIST_ITEM_ID_ATTR}="${key}"]`); if (newEl) { value.element = newEl; } } }); tracker.runtimeUuidMap.forEach((value, key) => { if (value.type === 'list') { const newEl = this.getListByRuntimeUuid(tracker, key); if (newEl) { value.element = newEl; tracker.runtimeDomMap.set(newEl, key); } } else if (value.type === 'item') { const newEl = this.getItemByRuntimeUuid(tracker, key); if (newEl) { value.element = newEl; tracker.runtimeDomMap.set(newEl, key); } } }); }, /** * Return one tree-aware structural snapshot for the active list. * * @param {Object|null} tracker Active list tracker. * @returns {Object|null} Lightweight structure snapshot. */ getStructure(tracker) { if (!tracker) tracker = this.active; if (!tracker?.listElement) { return null; } const buildListNode = (listElement, listPath = '', parentIndexes = []) => ({ listUuid: this.getRuntimeUuidForList(tracker, listElement), listPath, ordered: listElement.tagName === 'OL', items: getDirectListItems(listElement).map((listItem, index) => { const indexes = [ ...parentIndexes, index ]; const pathMeta = buildPathMeta(indexes); const directSurface = ensureDirectItemTextSurface(listItem); const nestedList = getDirectChildList(listItem); return { itemUuid: this.getRuntimeUuidForItem(tracker, listItem), path: pathMeta.path, pathLabel: pathMeta.pathLabel, depth: pathMeta.depth, contentHtml: directSurface ? directSurface.innerHTML.trim() : '', childList: nestedList ? buildListNode(nestedList, pathMeta.path, indexes) : null, }; }), }); return buildListNode(tracker.listElement, '', []); }, /** * Apply one primitive structural list operation against the live tracked * DOM. * * Public API calls are translated into this lower-level operation set by * the shared schema executor so the tracker only needs to understand the * canonical primitive mutation layer. * * Supported primitive kinds: * - `insert_list_item` * - `remove_list_item` * - `move_list_item` * - `indent_list_item` * - `outdent_list_item` * - `update_list_item_text` * - `toggle_list_type` * * @param {Object|null} tracker Active list tracker. * @param {Object} rawOperation Structural operation payload. * @returns {Object|null} Result summary when applied. */ applyOperation(tracker, rawOperation, options = {}) { if (!tracker) tracker = this.active; if (!tracker?.listElement || !rawOperation || typeof rawOperation !== 'object') { return null; } const kind = typeof rawOperation.kind === 'string' ? rawOperation.kind.trim().toLowerCase() : ''; const path = rawOperation.path ?? rawOperation.itemPath ?? rawOperation.item_path ?? ''; const listItem = this.getItemByPath(tracker, path); let didApply = false; if (kind === 'remove_list_item' && listItem) { didApply = applyRemoveListItemOperation(tracker, listItem); } else if (kind === 'indent_list_item' && listItem) { didApply = applyIndentListItemOperation(tracker, listItem); } else if (kind === 'outdent_list_item' && listItem) { didApply = applyOutdentListItemOperation(tracker, listItem); } else if (kind === 'toggle_list_type') { didApply = applyToggleListTypeOperation(tracker, rawOperation, options); } else if (kind === 'update_list_item_text' && listItem) { didApply = applyUpdateListItemTextOperation(listItem, rawOperation); } else if (kind === 'insert_list_item') { didApply = applyInsertListItemOperation(tracker, rawOperation, this); } else if (kind === 'move_list_item' && listItem) { didApply = applyMoveListItemOperation(tracker, listItem, rawOperation, this); } if (!didApply) { return null; } this.syncEditableTextSurfaces(tracker.listElement); this.buildFromDOM(tracker, tracker.listElement, tracker.originalBlock || {}); return { kind, structure: this.getStructure(tracker), }; }, /** * Ensure every list item in one tree has one direct text surface. * * @param {HTMLElement|null} rootElement Candidate list root or list item. * @returns {HTMLElement|null} Normalized root element. */ syncEditableTextSurfaces(rootElement) { if (!rootElement || rootElement.nodeType !== Node.ELEMENT_NODE) { return null; } if (rootElement.tagName === 'LI') { ensureDirectItemTextSurface(rootElement); Array.from(rootElement.children || []) .filter(child => isNestedListNode(child)) .forEach(childList => this.syncEditableTextSurfaces(childList)); return rootElement; } if (rootElement.tagName !== 'UL' && rootElement.tagName !== 'OL') { return rootElement; } Array.from(rootElement.children || []) .filter(child => child.tagName === 'LI') .forEach(li => this.syncEditableTextSurfaces(li)); return rootElement; }, /** * Ensure runtime UUID attributes are unique within one live list tree. * * Native contenteditable list splitting can clone `li` and nested list * attributes verbatim before FrontEdit regains control. This pass reseeds only the * session-scoped runtime UUID attributes so freshly created structures become * distinct live API/editor targets immediately, while leaving `data-item-id` * and `data-list-id` untouched for their separate responsibilities. * * @param {HTMLElement|null} rootElement Candidate list root. * @returns {HTMLElement|null} Normalized root element. */ ensureUniqueRuntimeUuids(rootElement) { if ( !rootElement || rootElement.nodeType !== Node.ELEMENT_NODE || (rootElement.tagName !== 'UL' && rootElement.tagName !== 'OL') ) { return rootElement || null; } const seenListRuntimeUuids = new Set(); const seenItemRuntimeUuids = new Set(); const collectLists = [ rootElement, ...Array.from(rootElement.querySelectorAll('ul, ol')) ]; const collectItems = Array.from(rootElement.querySelectorAll('li')); collectLists.forEach(listElement => { const runtimeUuid = normalizeRuntimeUuid( listElement.getAttribute(LIST_RUNTIME_UUID_ATTR) ); if (!runtimeUuid) { return; } if (seenListRuntimeUuids.has(runtimeUuid)) { listElement.setAttribute(LIST_RUNTIME_UUID_ATTR, this.generateTempUuid()); return; } seenListRuntimeUuids.add(runtimeUuid); }); collectItems.forEach(listItem => { const runtimeUuid = normalizeRuntimeUuid( listItem.getAttribute(LIST_ITEM_RUNTIME_UUID_ATTR) ); if (!runtimeUuid) { return; } if (seenItemRuntimeUuids.has(runtimeUuid)) { listItem.setAttribute(LIST_ITEM_RUNTIME_UUID_ATTR, this.generateTempUuid()); return; } seenItemRuntimeUuids.add(runtimeUuid); }); return rootElement; }, /** * Ensure one live list tree has the required structural IDs and runtime UUIDs. * * On first editor activation, list elements may not yet have either identity * attribute family. Seed both from one generated UUID per element so the * initial history snapshot captures stable targeting data. Later native list * splits may intentionally copy the structural IDs; in those cases this pass * preserves the structural IDs and only reseeds duplicated runtime UUIDs. * * @param {HTMLElement|null} rootElement Candidate list root. * @returns {HTMLElement|null} Normalized root element. */ ensureIdentityAttributes(rootElement) { if ( !rootElement || rootElement.nodeType !== Node.ELEMENT_NODE || (rootElement.tagName !== 'UL' && rootElement.tagName !== 'OL') ) { return rootElement || null; } const syncIdentityPair = (element, structuralAttrName, runtimeAttrName) => { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return; } let structuralId = normalizeRuntimeUuid( element.getAttribute(structuralAttrName) ); let runtimeUuid = normalizeRuntimeUuid( element.getAttribute(runtimeAttrName) ); if (!structuralId && !runtimeUuid) { runtimeUuid = this.generateTempUuid(); structuralId = runtimeUuid; } else if (!runtimeUuid) { runtimeUuid = structuralId; } else if (!structuralId) { structuralId = runtimeUuid; } element.setAttribute(structuralAttrName, structuralId); element.setAttribute(runtimeAttrName, runtimeUuid); }; syncIdentityPair(rootElement, LIST_ID_ATTR, LIST_RUNTIME_UUID_ATTR); Array.from(rootElement.querySelectorAll('ul, ol')).forEach(listElement => { syncIdentityPair(listElement, LIST_ID_ATTR, LIST_RUNTIME_UUID_ATTR); }); Array.from(rootElement.querySelectorAll('li')).forEach(listItem => { syncIdentityPair(listItem, LIST_ITEM_ID_ATTR, LIST_ITEM_RUNTIME_UUID_ATTR); }); return this.ensureUniqueRuntimeUuids(rootElement); }, getDirectItemTextSurface, normalizePathIndexes, buildPathMeta, getRuntimeUuidAttributeName, getListRuntimeUuidAttributeName() { return LIST_RUNTIME_UUID_ATTR; }, getListItemRuntimeUuidAttributeName() { return LIST_ITEM_RUNTIME_UUID_ATTR; }, getListIdAttributeName() { return LIST_ID_ATTR; }, getListItemIdAttributeName() { return LIST_ITEM_ID_ATTR; }, /** * Destroy tracker and clean up */ destroy(tracker) { if (!tracker) tracker = this.active; if (!tracker) return; tracker.uuidMap.clear(); tracker.runtimeUuidMap.clear(); if (tracker.listElement) { delete tracker.listElement._mwpListTracker; } if (this.active === tracker) this.active = null; } }; /** * Return whether one element is a live list node for runtime lookup helpers. * * @param {Element|null} element Candidate DOM element. * @returns {boolean} True when the element is `UL` or `OL`. */ function isListElementForRuntimeLookup(element) { return !!( element && element.nodeType === Node.ELEMENT_NODE && (element.tagName === 'UL' || element.tagName === 'OL') ); } // Expose globally SFE.ListBlockTracker = ListBlockTracker; })(); Eric Schmid, Author at Luxe Designs - Page 24 of 25

Shikaka Casino: Jak maximálně využít své EUR a uvítací bonusy



V dnešním digitálním světě se online kasina stávají stále populárnějšími a Shikaka Casino je jedním z nejzajímavějších hráčů na trhu. Nabízí širokou škálu her a lákavé bonusy, což jej činí atraktivním pro hráče v Česká republika a široko daleko. Pokud hledáte kvalitní zábavu, https://shikaka.cz/ vám poskytne skvělou příležitost vyzkoušet nové hry a užít si vzrušení. Tento článek vám ukáže, jak maximálně využít své EUR a uvítací bonusy na této platformě.

Co zkontrolovat před začátkem s Shikaka Casino

Předtím, než se pustíte do světa online hazardu, je důležité mít na paměti několik klíčových aspektů. Šikaka Casino poskytuje bezpečné a zabezpečené prostředí pro své uživatele. Před registrací se ujistěte, že jste obeznámeni s podmínkami bonusů a možnostmi her. Můžete se těšit na uvítací bonus až 400% do výše 15 000 EUR a 400 volných spinů. To je skvělý start pro každého nového hráče.

Dále je důležité zkontrolovat, zda vaše zařízení je kompatibilní s platformou a jak rychle je možné se zaregistrovat. V Shikaka Casino trvá registrace jen několik minut a je přístupná na všech zařízeních, což zaručuje pohodlné hraní.

Jak začít s Shikaka Casino

Začít hrát v Shikaka Casino je snadné a rychlé. Zde je návod, jak to udělat krok za krokem:

  1. Vytvořte účet: Navštivte webové stránky Shikaka Casino a zaregistrujte se pomocí e-mailu a hesla.
  2. Ověřte své údaje: Sledujte pokyny k ověření vaší identity, což zaručuje bezpečnost vašich prostředků.
  3. Vložte peníze: Zvolte si preferovaný způsob platby a vložte EUR nebo kryptoměnu.
  4. Vyberte si hru: Prozkoumejte širokou nabídku her a vyberte si tu, která vás nejvíce zaujme.
  5. Začněte hrát: Užívejte si hraní a nezapomeňte využít svůj uvítací bonus!
  • Rychlá registrace, které vám umožní začít hrát téměř okamžitě.
  • Bezpečné ověření, které chrání vaše osobní údaje.
  • Možnost vložení v různých měnách, včetně EUR a kryptoměn.

Praktické detaily pro Shikaka Casino

Při používání Shikaka Casino si můžete užít různé hry od špičkových vývojářů. Všechny hry jsou certifikovány, což zajišťuje jejich spravedlnost a kvalitu. Kromě toho platforma nabízí vynikající uživatelské rozhraní, které usnadňuje navigaci a hledání vašich oblíbených her. Pokud se rozhodnete hrát na mobilním zařízení, můžete si být jisti, že zážitek bude stejně kvalitní jako na počítači.

  • Široká škála her, včetně automatů, stolních her a živého kasina.
  • Podpora více měn, což usnadňuje transakce.
  • Skvělá uživatelská zkušenost na všech typech zařízení.

Při využívání těchto funkcí nezapomeňte také na to, že Shikaka Casino nabízí různé bonusy a akce. Využijte příležitosti a získejte výhody při svých hrách!

Klíčové výhody Shikaka Casino

Zde je několik klíčových výhod, které Shikaka Casino nabízí svým hráčům:

  • Generózní uvítací bonus, který může dosáhnout až 15 000 EUR.
  • 400 volných spinů, které vám umožní vyzkoušet nové hry bez rizika.
  • Bezpečné a zabezpečené prostředí pro vaše hráčské aktivity.
  • Kompatibilita se všemi zařízeními, což znamená, že můžete hrát kdykoliv a kdekoliv.

Tyto výhody vám pomohou maximalizovat váš herní zážitek a zvýšit šance na výhru.

Důvěra a bezpečnost

Bezpečnost je pro hráče vždy na prvním místě. Shikaka Casino využívá SSL šifrování, aby zajistilo, že všechny vaše osobní a finanční údaje jsou chráněny před neoprávněným přístupem. To znamená, že se můžete plně soustředit na svou hru, aniž byste se museli obávat o své údaje.

Další výhodou je, že platforma spolupracuje pouze s certifikovanými vývojáři softwaru, což zaručuje bezpečné a férové hraní. Ochrana hráčů je zde brána velmi vážně, takže se můžete cítit bezstarostně při vyzkoušení různých her.

Proč si vybrat Shikaka Casino

Shikaka Casino se ukazuje jako skvělá volba pro všechny, kteří hledají bezpečné a zábavné online herní prostředí. Jeho široká nabídka her, atraktivní bonusy a snadná dostupnost činí tuto platformu ideální pro nové i zkušené hráče. Ať už hledáte vzrušení z automatů nebo strategie stolních her, Shikaka Casino má co nabídnout každému.

Tak neváhejte a připojte se k Shikaka Casino. Vyzkoušejte si své štěstí a užijte si úžasný herní zážitek, který této platforma nabízí!

Avantages et inconvénients des jeux mobiles : vaut-il la peine de passer aux applications



Les avantages des jeux de casino mobiles

Les jeux de casino mobiles offrent une expérience de divertissement qui est à la fois pratique et accessible. Avec l’essor des smartphones et des tablettes, les joueurs peuvent désormais accéder à leurs jeux préférés n’importe où et n’importe quand. Que ce soit pendant un trajet en transport en commun ou dans le confort de leur maison, la commodité des jeux mobiles est un atout majeur. De plus, de nombreuses applications de jeux de casino proposent ainsi des fonctionnalités telles que des bonus et promotions intéressantes, comme sur Shuffle Casino , ce qui améliore considérablement l’expérience de jeu.

En outre, les casinos en ligne mobiles offrent souvent des promotions exclusives et des bonus pour encourager les joueurs à télécharger leurs applications. Ces incitatifs peuvent inclure des tours gratuits, des bonus de bienvenue ou des programmes de fidélité qui récompensent les joueurs pour leur engagement. Cela permet aux joueurs d’optimiser leur expérience et d’augmenter leurs chances de gains sans avoir à dépenser beaucoup d’argent.

Les inconvénients des jeux de casino mobiles

Malgré leurs avantages, les jeux de casino mobiles présentent également certains inconvénients. L’un des principaux problèmes est lié à la sécurité des transactions. Les joueurs doivent être vigilants quant aux applications qu’ils téléchargent, car certaines peuvent ne pas offrir le niveau de sécurité adéquat pour protéger les données personnelles et financières. Il est donc essentiel de choisir des applications de casino réputées et bien évaluées pour éviter les risques liés à la fraude.

De plus, la taille de l’écran peut être un facteur limitant pour certains jeux, en particulier ceux qui requièrent des graphiques détaillés ou une interface complexe. Certains joueurs préfèrent l’expérience immersive offerte par un ordinateur de bureau, où ils peuvent bénéficier d’un écran plus grand et d’un meilleur son. Par conséquent, bien que les jeux mobiles soient pratiques, ils ne remplacent pas toujours l’expérience de jeu sur des plateformes plus traditionnelles.

Les tendances du marché des jeux de casino mobiles

Le marché des jeux de casino mobiles est en constante évolution, avec de nouveaux développements technologiques qui transforment la manière dont les joueurs interagissent avec les jeux. L’intégration de la réalité augmentée et de la réalité virtuelle dans les applications de casino commence à offrir des expériences plus immersives et engageantes. Ces technologies permettent aux joueurs de se plonger dans un environnement de casino virtuel, ajoutant une nouvelle dimension au plaisir du jeu.

De plus, l’essor des crypto-monnaies est en train de modifier le paysage des jeux de casino mobiles. De plus en plus de plateformes acceptent les paiements en crypto-monnaies, offrant ainsi aux joueurs une option de transaction rapide et sécurisée. Cela favorise également l’anonymat des joueurs, ce qui est un atout pour ceux qui préfèrent garder leur activité de jeu privée.

Les défis réglementaires dans le secteur des jeux mobiles

Les jeux de casino mobiles sont soumis à une réglementation stricte qui varie d’un pays à l’autre. Cela peut poser des défis pour les développeurs d’applications et les opérateurs de casinos en ligne, qui doivent s’assurer qu’ils respectent toutes les lois en vigueur dans les juridictions où ils opèrent. Les restrictions peuvent affecter l’accès des joueurs aux jeux, ainsi que la disponibilité de certaines promotions ou fonctionnalités.

En outre, les autorités de régulation cherchent à protéger les joueurs contre les comportements de jeu problématiques. Cela peut entraîner l’imposition de limites sur les mises ou des exigences de vérification d’identité plus strictes, ce qui peut être perçu comme un inconvénient par certains joueurs. C’est un aspect important à considérer pour ceux qui envisagent de s’engager dans le jeu mobile de manière régulière.

Conclusion sur les jeux de casino mobiles

En conclusion, les jeux de casino mobiles représentent une alternative attrayante pour les amateurs de jeux d’argent, offrant à la fois commodité et accessibilité. Toutefois, il est crucial de peser les avantages contre les inconvénients, notamment en ce qui concerne la sécurité et l’expérience utilisateur. Alors que le marché continue d’évoluer avec de nouvelles technologies et des réglementations, les joueurs doivent être informés et prudents dans leur approche des jeux de casino en ligne. La décision de passer aux applications de jeu mobile mérite réflexion, mais avec un choix judicieux, elle peut révéler de belles opportunités de divertissement et de gains.

Sensible Medical insurance Preparations

@ghstbuyer 1208 Mandating this type of extremely important health and fitness benefits guarantees complete coverage for those and you may household, dealing with the diverse health care requires. These types of benefits tend to be emergency characteristics, maternity and you may newborn proper care, psychological state and you can compound fool around with sickness features, prescribed drugs, and a lot more. The marketplace  provides multiple choices to be sure medical insurance is accessible and you can affordable to have many anyone.

  • To get very important information and you will condition from the medical insurance, sign up for current email address and text message notification to own prompt reminders and you may crucial guidance.
  • Seek out availability, while the never assume all material top preparations can be found in all areas.
  • Which brings a critical pathway so you can sensible visibility for legitimately establish immigrants which you will if not face a gap within the insurance choices.
  • Find out about what goes on that have representative exposure once Individual & Family members Package (IFP) termination to the December 31, 2025.
  • See how to register Protector’s increasing circle out of dental work with team.

Taking a medical Insurance policies Markets® plan: cuatro procedures

  • By understanding and making use of the brand new Special Subscription Months, you could take care of persisted health coverage and prevent holes in your insurance rates.
  • Mental health hotlines you to suffice rural groups have experienced an enthusiastic uptick inside the calls.
  • Issues that enable you to subscribe medical health insurance outside Open Enrollment.
  • You might be qualified to receive another Subscription Several months for those who features a major lifestyle knowledge.
  • Might significantly enhance your odds of to stop a gap inside acquiring it help if you digitally document the taxation return with Form 8962 because of the deadline of the go back.

This web site doesn’t display the offered preparations. † CSRs variations out of Restricted and you will No can also be found for the most other metal level agreements for people in federally approved tribes and you will ANCSA corporation investors. You will find a supplementary premium recharged of these elective benefits.

Coronavirus disease 2019

COVID-19 is a contagious disease caused by the coronavirus SARS-CoV-2. In January 2020, the disease spread worldwide, resulting in the COVID-19 pandemic.

The symptoms of COVID‑19 can vary but often include fever,[7] fatigue, cough, breathing difficulties, loss of smell, and loss of taste.[8][9][10] Symptoms may begin one to fourteen days after exposure to the virus. At least a third of people who are infected do not develop noticeable symptoms.[11][12] Of those who develop symptoms noticeable enough to be classified as patients, most (81%) develop mild to moderate symptoms (up to mild pneumonia), while 14% develop severe symptoms (dyspnea, hypoxia, or more than 50% lung involvement on imaging), and 5% develop critical symptoms (respiratory failure, shock, or multiorgan dysfunction).[13] Older people have a higher risk of developing severe symptoms. Some complications result in death. Some people continue to experience a range of effects (long COVID) for months or years after infection, and damage to organs has been observed.[14] Multi-year studies on the long-term effects are ongoing.[15]

COVID‑19 transmission occurs when infectious particles are breathed in or come into contact with the eyes, nose, or mouth. The risk is highest when people are in close proximity, but small airborne particles containing the virus can remain suspended in the air and travel over longer distances, particularly indoors. Transmission can also occur when people touch their eyes, nose, or mouth after touching surfaces or objects that have been contaminated by the virus. People remain contagious for up to 20 days and can spread the virus even if they do not develop symptoms.[16]

Testing methods for COVID-19 to detect the virus’s nucleic acid include real-time reverse transcription polymerase chain reaction (RT‑PCR),[17][18] transcription-mediated amplification,[17][18][19] and reverse transcription loop-mediated isothermal amplification (RT‑LAMP)[17][18] from a nasopharyngeal swab.[20]

Several COVID-19 vaccines have been approved and distributed in various countries, many of which have initiated mass vaccination campaigns. Other preventive measures include physical or social distancing, quarantining, ventilation of indoor spaces, use of face masks or coverings in public, covering coughs and sneezes, hand washing, and keeping unwashed hands away from the face. While drugs have been developed to inhibit the virus, the primary treatment is still symptomatic, managing the disease through supportive care, isolation, and experimental measures.

Julius Caesar The Man Who Changed Rome Forever

Published: March 24, 2026

Julius Caesar (100 BC – 44 BC) was one of the most influential figures in the history of the ancient world. A brilliant military commander, cunning politician, and gifted writer, he transformed the Roman Republic into what would eventually become the Roman Empire.

Early Life

Gaius Julius Caesar was born on July 13, 100 BC, into a patrician family in Rome. Despite his noble origins, his family was not particularly wealthy or politically powerful at the time. From an early age, Caesar showed exceptional intelligence and ambition. He studied rhetoric and philosophy, skills that would later make him one of Rome’s greatest orators.

Rise to Power

Caesar’s political career began in earnest in his early thirties. He formed a powerful alliance known as theFirst Triumvirate with two of Rome’s most powerful men — Pompey, the celebrated general, and Crassus, the wealthiest man in Rome. This partnership allowed Caesar to gain the consulship in 59 BC, one of the highest offices in the Roman Republic.

Military Campaigns

Perhaps Caesar’s greatest achievements came on the battlefield. His conquest of Gaul (modern-day France and Belgium) between 58 and 50 BC is considered one of the most remarkable military campaigns in history. Over nearly a decade of fighting, Caesar’s legions defeated numerous Celtic tribes and brought vast new territories under Roman control.

He also conducted two expeditions to Britain in 55 and 54 BC — the first Roman general to do so — and famously crossed the Rhine River into Germanic territory, demonstrating Rome’s military reach beyond its known borders.

Crossing the Rubicon

In 49 BC, Caesar made one of the most consequential decisions in world history. Ordered by the Senate to disband his army, he instead crossed theRubicon River with his troops — a direct act of defiance that triggered a civil war. The phrase “crossing the Rubicon” has since become a universal expression for making an irreversible decision.

After defeating his rival Pompey and his supporters across multiple campaigns from Spain to Egypt to Asia Minor, Caesar emerged as the undisputed master of the Roman world.

Dictator of Rome

By 44 BC, Caesar had been declared dictator perpetuo — dictator in perpetuity. He implemented sweeping reforms: restructuring the calendar (giving us the Julian calendar, still the basis of our modern one), reducing debt, expanding citizenship, and improving the administration of Rome’s provinces.

Assassination

Despite — or perhaps because of — his immense power, Caesar made powerful enemies. OnMarch 15, 44 BC, known as the Ides of March, a group of senators led by Marcus Junius Brutus and Gaius Cassius Longinus assassinated him in the Theatre of Pompey. He was stabbed 23 times.

The assassins believed they were saving the Republic. Instead, Caesar’s death plunged Rome into years of civil war and ultimately led to the rise of his adopted son Octavian as Augustus, the first Roman Emperor.

Legacy

Julius Caesar’s legacy is immeasurable. His name became a title — Kaiser in German, Tsar in Russian — synonymous with supreme power. He reformed the calendar, reshaped the Roman state, and inspired countless works of art, literature, and political thought across two millennia.

William Shakespeare immortalized him in his famous play Julius Caesar, and his own writings — particularly Commentarii de Bello Gallico — remain studied to this day as masterpieces of Latin prose and military history.

As we reflect on his life on March 24, 2026, Julius Caesar remains a towering figure — a man whose ambition, genius, and fate continue to captivate the imagination of the world more than 2,000 years after his death.

“Veni, vidi, vici” — I came, I saw, I conquered.

— Julius Caesar

Современный взгляд на программы лояльности: параметры, безопасность и прозрачность

В современном мире конкуренция среди компаний настолько высока, что им приходится разрабатывать различные маркетинговые стратегии для привлечения и удержания клиентов. Одним из наиболее популярных инструментов, который используется для увеличения лояльности клиентов winity казино официальный сайт, являются программы лояльности. Однако, прежде чем присоединиться к программе лояльности, важно провести анализ параметров данной программы с реалистичными ожиданиями, обратив внимание на безопасность и прозрачность ее работы.
Программа лояльности – это маркетинговый инструмент, который предлагает клиентам различные бонусы и вознаграждения за их лояльность к бренду или компании. Эти бонусы могут включать в себя скидки на товары и услуги, подарки, бесплатные доставки и многое другое. Однако, не все программы лояльности одинаково эффективны, и перед присоединением к любой из них важно провести анализ.
В первую очередь, стоит обратить внимание на условия участия в программе лояльности. Некоторые программы могут быть абсолютно бесплатными, в то время как другие могут требовать платное членство или дополнительные затраты. Поэтому, перед тем как присоединиться к программе лояльности, стоит внимательно изучить все условия участия и рассмотреть, насколько они выгодны для вас.
Важным аспектом является также количество и разнообразие бонусов, которые предлагает программа лояльности. Чем больше вознаграждений и привилегий вы получаете за участие в программе, тем более привлекательной она будет для вас. Поэтому, прежде чем присоединяться к программе лояльности, стоит изучить список всех возможных бонусов и оценить их ценность для себя.
Также важным параметром является лояльность компании к своим клиентам. Программа лояльности должна быть долгосрочной и стабильной, чтобы клиенты могли рассчитывать на постоянные бонусы и привилегии. Если программа часто меняется или отменяется, это может негативно сказаться на доверии клиентов к компании.
Безопасность и прозрачность работы программы лояльности также играют важную роль. Клиенты должны быть уверены, что их персональные данные защищены и не будут переданы третьим лицам без их согласия. Кроме того, все условия участия в программе и правила начисления и использования бонусов должны быть четко описаны и доступны для всех участников.
Итак, программы лояльности могут быть эффективным инструментом увеличения лояльности клиентов к бренду или компании. Однако, перед тем как присоединиться к программе лояльности, важно провести анализ всех параметров программы с реалистичными ожиданиями, обратив внимание на безопасность и прозрачность ее работы. Только таким образом можно быть уверенным, что участие в программе лояльности будет действительно выгодным и приятным для клиента.
Маркерованный список:

  • Условия участия в программе лояльности
  • Количество и разнообразие бонусов
  • Долгосрочность и стабильность программы
  • Безопасность персональных данных
  • Прозрачность правил программы
Posted in 5

Salongier krok po kroku – kompleksowy poradnik korzystania z platformy

 

Salongier to platforma online, która umożliwia zarządzanie salonami fryzjerskimi i kosmetycznymi. Dzięki niej można łatwo planować grafik pracowników, prowadzić księgowość, zarządzać klientami oraz monitorować sprzedaż. W niniejszym artykule przedstawimy kompleksowy poradnik korzystania z platformy  Salongier, krok po kroku.

Krok 1: Rejestracja

Pierwszym krokiem jest rejestracja na platformie Salongier. Wystarczy wejść na stronę internetową platformy i utworzyć nowe konto. W procesie rejestracji należy podać podstawowe dane, takie jak nazwa salonu, adres e-mail oraz hasło. Po zarejestrowaniu się można zalogować się do platformy i rozpocząć korzystanie z jej funkcji.

Krok 2: Dodawanie pracowników

Po zalogowaniu się do platformy, należy dodać pracowników salonu. Wystarczy wybrać zakładkę "Pracownicy" i kliknąć przycisk "Dodaj pracownika". Następnie należy podać dane osobowe pracownika oraz określić jego rolę w salonie (np. fryzjer, kosmetyczka). Po dodaniu pracowników można przypisać im harmonogram pracy oraz monitorować ich obecność w salonie.

Krok 3: Zarządzanie usługami i cenami

Kolejnym krokiem jest dodanie usług, które są oferowane w salonie oraz określenie ich cen. W panelu administracyjnym platformy Salongier można łatwo zarządzać usługami i ich cenami. Wystarczy wybrać zakładkę "Usługi" i dodać nową usługę, podając jej nazwę, opis oraz cenę. Można także określić czas trwania usługi oraz przypisać ją do konkretnego pracownika.

Krok 4: Planowanie grafiku pracy

Kolejnym ważnym elementem jest planowanie grafiku pracy pracowników. W panelu administracyjnym platformy Salongier można łatwo ustalić harmonogram pracy dla każdego pracownika. Wystarczy wybrać zakładkę "Grafik" i ustalić dni i godziny pracy dla poszczególnych pracowników. Można także dodawać urlopy, dni wolne oraz nadgodziny.

Krok 5: Zarządzanie klientami

Kolejnym istotnym elementem jest zarządzanie bazą klientów salonu. W panelu administracyjnym platformy Salongier można przechowywać dane klientów, tworzyć historie ich wizyt oraz wysyłać im powiadomienia o promocjach i nowych usługach. Wystarczy wybrać zakładkę "Klienci" i dodawać nowe rekordy klientów, podając ich dane kontaktowe oraz preferencje.

Krok 6: Monitorowanie sprzedaży

Ostatnim krokiem jest monitorowanie sprzedaży salonu za pomocą platformy Salongier. W panelu administracyjnym można generować raporty sprzedażowe, analizować przychody oraz wydatki, a także obserwować trendy sprzedażowe. Dzięki temu można łatwiej planować strategie marketingowe i zwiększać rentowność salonu.

Podsumowując, platforma Salongier to skuteczne narzędzie do zarządzania salonem fryzjerskim lub kosmetycznym. Dzięki jej funkcjom można łatwo planować grafik pracy, zarządzać usługami i cenami, monitorować sprzedaż oraz dbać o relacje z klientami. Korzystając z powyższego poradnika, można skutecznie wykorzystać potencjał platformy Salongier i zwiększyć efektywność działania salonu.

Posted in 7

Salongier krok po kroku – kompleksowy poradnik korzystania z platformy

 

Salongier to platforma online, która umożliwia zarządzanie salonami fryzjerskimi i kosmetycznymi. Dzięki niej można łatwo planować grafik pracowników, prowadzić księgowość, zarządzać klientami oraz monitorować sprzedaż. W niniejszym artykule przedstawimy kompleksowy poradnik korzystania z platformy  Salongier, krok po kroku.

Krok 1: Rejestracja

Pierwszym krokiem jest rejestracja na platformie Salongier. Wystarczy wejść na stronę internetową platformy i utworzyć nowe konto. W procesie rejestracji należy podać podstawowe dane, takie jak nazwa salonu, adres e-mail oraz hasło. Po zarejestrowaniu się można zalogować się do platformy i rozpocząć korzystanie z jej funkcji.

Krok 2: Dodawanie pracowników

Po zalogowaniu się do platformy, należy dodać pracowników salonu. Wystarczy wybrać zakładkę "Pracownicy" i kliknąć przycisk "Dodaj pracownika". Następnie należy podać dane osobowe pracownika oraz określić jego rolę w salonie (np. fryzjer, kosmetyczka). Po dodaniu pracowników można przypisać im harmonogram pracy oraz monitorować ich obecność w salonie.

Krok 3: Zarządzanie usługami i cenami

Kolejnym krokiem jest dodanie usług, które są oferowane w salonie oraz określenie ich cen. W panelu administracyjnym platformy Salongier można łatwo zarządzać usługami i ich cenami. Wystarczy wybrać zakładkę "Usługi" i dodać nową usługę, podając jej nazwę, opis oraz cenę. Można także określić czas trwania usługi oraz przypisać ją do konkretnego pracownika.

Krok 4: Planowanie grafiku pracy

Kolejnym ważnym elementem jest planowanie grafiku pracy pracowników. W panelu administracyjnym platformy Salongier można łatwo ustalić harmonogram pracy dla każdego pracownika. Wystarczy wybrać zakładkę "Grafik" i ustalić dni i godziny pracy dla poszczególnych pracowników. Można także dodawać urlopy, dni wolne oraz nadgodziny.

Krok 5: Zarządzanie klientami

Kolejnym istotnym elementem jest zarządzanie bazą klientów salonu. W panelu administracyjnym platformy Salongier można przechowywać dane klientów, tworzyć historie ich wizyt oraz wysyłać im powiadomienia o promocjach i nowych usługach. Wystarczy wybrać zakładkę "Klienci" i dodawać nowe rekordy klientów, podając ich dane kontaktowe oraz preferencje.

Krok 6: Monitorowanie sprzedaży

Ostatnim krokiem jest monitorowanie sprzedaży salonu za pomocą platformy Salongier. W panelu administracyjnym można generować raporty sprzedażowe, analizować przychody oraz wydatki, a także obserwować trendy sprzedażowe. Dzięki temu można łatwiej planować strategie marketingowe i zwiększać rentowność salonu.

Podsumowując, platforma Salongier to skuteczne narzędzie do zarządzania salonem fryzjerskim lub kosmetycznym. Dzięki jej funkcjom można łatwo planować grafik pracy, zarządzać usługami i cenami, monitorować sprzedaż oraz dbać o relacje z klientami. Korzystając z powyższego poradnika, można skutecznie wykorzystać potencjał platformy Salongier i zwiększyć efektywność działania salonu.

Posted in 7

Полезные советы: RTP игровых автоматов

Игровые автоматы являются одной из самых популярных форм развлечений в мире азартных игр. Каждый год миллионы людей обращаются к этим играм в надежде на удачу и большие выигрыши. Однако, многие игроки не знают, что РТП (Return to Player) – это один из наиболее важных показателей, который следует учитывать при выборе игрового автомата.

РТП показывает процент выигрышей, который игрок может ожидать в среднем за долгосрочную игру. Чем выше процент РТП, тем больше шансов на выигрыш у игрока. Поэтому, перед тем как начать игру, необходимо изучить условия и ограничения игровых автоматов для того, чтобы делать более осознанные решения.

Существуют несколько полезных советов, которые помогут игрокам читать условия и ограничения игровых автоматов:

1) Изучите РТП игрового автомата. Эта информация часто доступна на официальных сайтах игровых провайдеров. Выбирайте автоматы с высоким процентом РТП, чтобы увеличить свои шансы на выигрыш.

2) Внимательно изучите правила игры. Узнайте, какие символы являются самыми выгодными, какие бонусные функции есть в игре, а также какие условия необходимо выполнить для получения выигрыша.

3) Оцените величину минимальной и максимальной ставок. Помните, что чем выше ваша ставка, тем больше шансов на выигрыш. Однако, не забывайте, что игра на большие суммы также может привести к большим потерям.

4) Играйте ответственно. Не теряйте голову и не рискуйте слишком много велвура. Установите предельные рамки для себя и придерживайтесь их.

5) Используйте демо-версию игры, чтобы понять ее механику и определить, насколько вы вам нравится. Это позволит вам избежать ненужных потерь и делать более взвешенные решения.

Надеемся, что эти советы помогут вам стать более осознанным игроком и делать более взвешенные решения при выборе игровых автоматов. Помните, что азартные игры могут быть веселыми и захватывающими, но только при условии, что вы играете ответственно и осознанно. Удачи вам!