From 5d5f19a61cfe0ba8de25f436f29a577c3a4e2327 Mon Sep 17 00:00:00 2001 From: JackUait Date: Sun, 16 Nov 2025 00:00:30 +0300 Subject: [PATCH 1/7] fix: replace deprecated APIs with the modern ones --- .gitignore | 1 + .yarn/install-state.gz | Bin 852563 -> 852421 bytes src/components/modules/rectangleSelection.ts | 63 +++- src/components/modules/tools.ts | 18 +- src/components/modules/ui.ts | 12 +- src/components/polyfills.ts | 122 +------ src/components/ui/toolbox.ts | 6 - src/components/utils.ts | 295 +++------------ src/components/utils/scroll-locker.ts | 1 + .../modules/rectangleSelection.test.ts | 338 +++++++++++++++++- .../components/utils/scroll-locker.test.ts | 100 ++++++ test/unit/polyfills.test.ts | 190 ---------- test/unit/tools/inline.test.ts | 18 + test/unit/ui/toolbox.test.ts | 10 +- test/unit/utils/utils.test.ts | 185 +--------- 15 files changed, 605 insertions(+), 754 deletions(-) create mode 100644 test/unit/components/utils/scroll-locker.test.ts diff --git a/.gitignore b/.gitignore index 0ca7788c..e5c4b039 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/* npm-debug.log yarn-error.log +.yarn/install-state.gz test-results diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 4f563a9342afb496033ba529a320d50250398ef1..c62d4fb741f10274b1d79574521939476d65f211 100644 GIT binary patch delta 15396 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 O{re9_+h*>#ktYDFWOTd$ delta 15537 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 { this.scrollVertical(speed); }, 0); @@ -413,10 +415,13 @@ export default class RectangleSelection extends Module { return; } - this.overlayRectangle.style.left = `${this.startX - window.pageXOffset}px`; - this.overlayRectangle.style.top = `${this.startY - window.pageYOffset}px`; - this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`; - this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`; + const scrollLeft = this.getScrollLeft(); + const scrollTop = this.getScrollTop(); + + this.overlayRectangle.style.left = `${this.startX - scrollLeft}px`; + this.overlayRectangle.style.top = `${this.startY - scrollTop}px`; + this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - scrollTop}px)`; + this.overlayRectangle.style.right = `calc(100% - ${this.startX - scrollLeft}px)`; } /** @@ -456,22 +461,25 @@ export default class RectangleSelection extends Module { return; } + const scrollLeft = this.getScrollLeft(); + const scrollTop = this.getScrollTop(); + // Depending on the position of the mouse relative to the starting point, // change this.e distance from the desired edge of the screen*/ if (this.mouseY >= this.startY) { - this.overlayRectangle.style.top = `${this.startY - window.pageYOffset}px`; - this.overlayRectangle.style.bottom = `calc(100% - ${this.mouseY - window.pageYOffset}px)`; + this.overlayRectangle.style.top = `${this.startY - scrollTop}px`; + this.overlayRectangle.style.bottom = `calc(100% - ${this.mouseY - scrollTop}px)`; } else { - this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - window.pageYOffset}px)`; - this.overlayRectangle.style.top = `${this.mouseY - window.pageYOffset}px`; + this.overlayRectangle.style.bottom = `calc(100% - ${this.startY - scrollTop}px)`; + this.overlayRectangle.style.top = `${this.mouseY - scrollTop}px`; } if (this.mouseX >= this.startX) { - this.overlayRectangle.style.left = `${this.startX - window.pageXOffset}px`; - this.overlayRectangle.style.right = `calc(100% - ${this.mouseX - window.pageXOffset}px)`; + this.overlayRectangle.style.left = `${this.startX - scrollLeft}px`; + this.overlayRectangle.style.right = `calc(100% - ${this.mouseX - scrollLeft}px)`; } else { - this.overlayRectangle.style.right = `calc(100% - ${this.startX - window.pageXOffset}px)`; - this.overlayRectangle.style.left = `${this.mouseX - window.pageXOffset}px`; + this.overlayRectangle.style.right = `calc(100% - ${this.startX - scrollLeft}px)`; + this.overlayRectangle.style.left = `${this.mouseX - scrollLeft}px`; } } @@ -483,7 +491,8 @@ export default class RectangleSelection extends Module { private genInfoForMouseSelection(): {index: number | undefined; leftPos: number; rightPos: number} { const widthOfRedactor = document.body.offsetWidth; const centerOfRedactor = widthOfRedactor / 2; - const y = this.mouseY - window.pageYOffset; + const scrollTop = this.getScrollTop(); + const y = this.mouseY - scrollTop; const elementUnderMouse = document.elementFromPoint(centerOfRedactor, y); const lastBlockHolder = this.Editor.BlockManager.lastBlock?.holder; const contentElement = lastBlockHolder?.querySelector('.' + Block.CSS.content); @@ -512,6 +521,28 @@ export default class RectangleSelection extends Module { }; } + /** + * Normalized vertical scroll value that does not rely on deprecated APIs. + */ + private getScrollTop(): number { + if (typeof window.scrollY === 'number') { + return window.scrollY; + } + + return document.documentElement?.scrollTop ?? document.body?.scrollTop ?? 0; + } + + /** + * Normalized horizontal scroll value that does not rely on deprecated APIs. + */ + private getScrollLeft(): number { + if (typeof window.scrollX === 'number') { + return window.scrollX; + } + + return document.documentElement?.scrollLeft ?? document.body?.scrollLeft ?? 0; + } + /** * Select block with index index * diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index ec21eb6d..cff744e2 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -2,6 +2,7 @@ import Paragraph from '@editorjs/paragraph'; import Module from '../__module'; import * as _ from '../utils'; import type { ChainData } from '../utils'; +import PromiseQueue from '../utils/promise-queue'; import type { SanitizerConfig, ToolConfig, ToolConstructable, ToolSettings } from '../../../types'; import BoldInlineTool from '../inline-tools/inline-tool-bold'; import ItalicInlineTool from '../inline-tools/inline-tool-italic'; @@ -186,7 +187,22 @@ export default class Tools extends Module { this.toolPrepareMethodFallback({ toolName: data.toolName }); }; - await _.sequence(sequenceData, handlePrepareSuccess, handlePrepareFallback); + const queue = new PromiseQueue(); + + sequenceData.forEach(chainData => { + void queue.add(async () => { + const callbackData = !_.isUndefined(chainData.data) ? chainData.data : {}; + + try { + await chainData.function(chainData.data); + handlePrepareSuccess(callbackData); + } catch (error) { + handlePrepareFallback(callbackData); + } + }); + }); + + await queue.completed; this.prepareBlockTools(); } diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index a1b5b7a7..221c06ab 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -526,17 +526,19 @@ export default class UI extends Module { * @param {KeyboardEvent} event - keyboard event */ private documentKeydown(event: KeyboardEvent): void { - switch (event.keyCode) { - case _.keyCodes.ENTER: + const key = event.key ?? ''; + + switch (key) { + case 'Enter': this.enterPressed(event); break; - case _.keyCodes.BACKSPACE: - case _.keyCodes.DELETE: + case 'Backspace': + case 'Delete': this.backspacePressed(event); break; - case _.keyCodes.ESC: + case 'Escape': this.escapePressed(event); break; diff --git a/src/components/polyfills.ts b/src/components/polyfills.ts index e6b06807..f07badb3 100644 --- a/src/components/polyfills.ts +++ b/src/components/polyfills.ts @@ -1,116 +1,14 @@ 'use strict'; -/** - * Extend Element interface to include prefixed and experimental properties - */ -interface Element { - matchesSelector: (selector: string) => boolean; - mozMatchesSelector: (selector: string) => boolean; - msMatchesSelector: (selector: string) => boolean; - oMatchesSelector: (selector: string) => boolean; - - prepend: (...nodes: Array) => void; - append: (...nodes: Array) => void; -} - -/** - * The Element.matches() method returns true if the element - * would be selected by the specified selector string; - * otherwise, returns false. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill} - * @param {string} s - selector - */ -if (typeof Element.prototype.matches === 'undefined') { - const proto = Element.prototype as Element & { - matchesSelector?: (selector: string) => boolean; - mozMatchesSelector?: (selector: string) => boolean; - msMatchesSelector?: (selector: string) => boolean; - oMatchesSelector?: (selector: string) => boolean; - webkitMatchesSelector?: (selector: string) => boolean; - }; - - Element.prototype.matches = proto.matchesSelector ?? - proto.mozMatchesSelector ?? - proto.msMatchesSelector ?? - proto.oMatchesSelector ?? - proto.webkitMatchesSelector ?? - function (this: Element, s: string): boolean { - const doc = this.ownerDocument; - const matches = doc.querySelectorAll(s); - const index = Array.from(matches).findIndex(match => match === this); - - return index !== -1; - }; -} - -/** - * The Element.closest() method returns the closest ancestor - * of the current element (or the current element itself) which - * matches the selectors given in parameter. - * If there isn't such an ancestor, it returns null. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill} - * @param {string} s - selector - */ -if (typeof Element.prototype.closest === 'undefined') { - Element.prototype.closest = function (this: Element, s: string): Element | null { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const startEl: Element = this; - - if (!document.documentElement.contains(startEl)) { - return null; - } - - const findClosest = (el: Element | null): Element | null => { - if (el === null) { - return null; - } - - if (el.matches(s)) { - return el; - } - - const parent: ParentNode | null = el.parentElement || el.parentNode; - - return findClosest(parent instanceof Element ? parent : null); - }; - - return findClosest(startEl); - }; -} - -/** - * The ParentNode.prepend method inserts a set of Node objects - * or DOMString objects before the first child of the ParentNode. - * DOMString objects are inserted as equivalent Text nodes. - * - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/prepend#Polyfill} - * @param {Node | Node[] | string | string[]} nodes - nodes to prepend - */ -if (typeof Element.prototype.prepend === 'undefined') { - Element.prototype.prepend = function prepend(nodes: Array | Node | string): void { - const docFrag = document.createDocumentFragment(); - - const nodesArray = Array.isArray(nodes) ? nodes : [ nodes ]; - - nodesArray.forEach((node: Node | string) => { - const isNode = node instanceof Node; - - docFrag.appendChild(isNode ? node as Node : document.createTextNode(node as string)); - }); - - this.insertBefore(docFrag, this.firstChild); - }; -} - -interface Element { - /** - * Scrolls the current element into the visible area of the browser window - * - * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor. - */ - scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void; +declare global { + interface Element { + /** + * Scrolls the current element into the visible area of the browser window + * + * @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor. + */ + scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void; + } } /** @@ -214,3 +112,5 @@ if (typeof window.cancelIdleCallback === 'undefined') { globalThis.clearTimeout(id); }; } + +export {}; diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index ecd81a9f..4e236906 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -1,5 +1,4 @@ import * as _ from '../utils'; -import { BlockToolAPI } from '../block'; import Shortcuts from '../utils/shortcuts'; import type BlockToolAdapter from '../tools/block'; import type ToolsCollection from '../tools/collection'; @@ -436,11 +435,6 @@ export default class Toolbox extends EventsDispatcher { currentBlock.isEmpty ); - /** - * Apply callback before inserting html - */ - newBlock.call(BlockToolAPI.APPEND_CALLBACK); - this.api.caret.setToBlock(index); this.emit(ToolboxEvent.BlockAdded, { diff --git a/src/components/utils.ts b/src/components/utils.ts index 842e63e8..3f4f896d 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -3,6 +3,18 @@ */ import { nanoid } from 'nanoid'; +import lodashDelay from 'lodash/delay'; +import lodashIsBoolean from 'lodash/isBoolean'; +import lodashIsEmpty from 'lodash/isEmpty'; +import lodashIsEqual from 'lodash/isEqual'; +import lodashIsFunction from 'lodash/isFunction'; +import lodashIsNumber from 'lodash/isNumber'; +import lodashIsPlainObject from 'lodash/isPlainObject'; +import lodashIsString from 'lodash/isString'; +import lodashIsUndefined from 'lodash/isUndefined'; +import lodashMergeWith from 'lodash/mergeWith'; +import lodashThrottle from 'lodash/throttle'; +import lodashToArray from 'lodash/toArray'; /** * Possible log levels @@ -141,17 +153,6 @@ const getGlobalWindow = (): Window | undefined => { return undefined; }; -/** - * Returns globally available document object if it exists. - */ -const getGlobalDocument = (): Document | undefined => { - if (globalScope?.document) { - return globalScope.document; - } - - return undefined; -}; - /** * Returns globally available navigator object if it exists. */ @@ -312,18 +313,6 @@ export const logLabeled = ( _log(true, msg, type, args, style); }; -/** - * Return string representation of the object type - * - * @param {*} object - object to get type - * @returns {string} - */ -export const typeOf = (object: unknown): string => { - const match = Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/); - - return match ? match[1].toLowerCase() : 'unknown'; -}; - /** * Check if passed variable is a function * @@ -331,7 +320,7 @@ export const typeOf = (object: unknown): string => { * @returns {boolean} */ export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown => { - return typeOf(fn) === 'function' || typeOf(fn) === 'asyncfunction'; + return lodashIsFunction(fn); }; /** @@ -341,7 +330,7 @@ export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown = * @returns {boolean} */ export const isObject = (v: unknown): v is object => { - return typeOf(v) === 'object'; + return lodashIsPlainObject(v); }; /** @@ -351,7 +340,7 @@ export const isObject = (v: unknown): v is object => { * @returns {boolean} */ export const isString = (v: unknown): v is string => { - return typeOf(v) === 'string'; + return lodashIsString(v); }; /** @@ -361,7 +350,7 @@ export const isString = (v: unknown): v is string => { * @returns {boolean} */ export const isBoolean = (v: unknown): v is boolean => { - return typeOf(v) === 'boolean'; + return lodashIsBoolean(v); }; /** @@ -371,7 +360,7 @@ export const isBoolean = (v: unknown): v is boolean => { * @returns {boolean} */ export const isNumber = (v: unknown): v is number => { - return typeOf(v) === 'number'; + return lodashIsNumber(v); }; /** @@ -381,17 +370,7 @@ export const isNumber = (v: unknown): v is number => { * @returns {boolean} */ export const isUndefined = function (v: unknown): v is undefined { - return typeOf(v) === 'undefined'; -}; - -/** - * Check if passed function is a class - * - * @param {Function} fn - function to check - * @returns {boolean} - */ -export const isClass = (fn: unknown): boolean => { - return isFunction(fn) && /^\s*class\s+/.test(fn.toString()); + return lodashIsUndefined(v); }; /** @@ -401,21 +380,7 @@ export const isClass = (fn: unknown): boolean => { * @returns {boolean} */ export const isEmpty = (object: object | null | undefined): boolean => { - if (!object) { - return true; - } - - return Object.keys(object).length === 0 && object.constructor === Object; -}; - -/** - * Check if passed object is a Promise - * - * @param {*} object - object to check - * @returns {boolean} - */ -export const isPromise = (object: unknown): object is Promise => { - return Promise.resolve(object) === object; + return lodashIsEmpty(object); }; /** @@ -491,7 +456,7 @@ export const sequence = async ( */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const array = (collection: ArrayLike): any[] => { - return Array.prototype.slice.call(collection); + return lodashToArray(collection); }; /** @@ -502,7 +467,7 @@ export const array = (collection: ArrayLike): any[] => { */ export const delay = (method: (...args: unknown[]) => unknown, timeout: number) => { return function (this: unknown, ...args: unknown[]): void { - setTimeout(() => method.apply(this, args), timeout); + void lodashDelay(() => method.apply(this, args), timeout); }; }; @@ -572,130 +537,12 @@ export const debounce = (func: (...args: unknown[]) => void, wait?: number, imme * but if you'd like to disable the execution on the leading edge, pass * `{leading: false}`. To disable execution on the trailing edge, ditto. */ -export const throttle = (func: (...args: unknown[]) => unknown, wait: number, options?: {leading?: boolean; trailing?: boolean}): (...args: unknown[]) => unknown => { - const state: { - args: unknown[] | null; - result: unknown; - timeoutId: ReturnType | null; - previous: number; - boundFunc: ((...boundArgs: unknown[]) => unknown) | null; - } = { - args: null, - result: undefined, - timeoutId: null, - previous: 0, - boundFunc: null, - }; - - const opts = options || {}; - - const later = function (): void { - state.previous = opts.leading === false ? 0 : Date.now(); - state.timeoutId = null; - if (state.args !== null && state.boundFunc !== null) { - state.result = state.boundFunc(...state.args); - } - - state.boundFunc = null; - state.args = null; - }; - - return function (this: unknown, ...restArgs: unknown[]): unknown { - const now = Date.now(); - - if (!state.previous && opts.leading === false) { - state.previous = now; - } - - const remaining = wait - (now - state.previous); - - state.boundFunc = func.bind(this); - state.args = restArgs; - - const shouldInvokeNow = remaining <= 0 || remaining > wait; - - if (!shouldInvokeNow && state.timeoutId === null && opts.trailing !== false) { - state.timeoutId = setTimeout(later, remaining); - } - - if (!shouldInvokeNow) { - return state.result; - } - - if (state.timeoutId !== null) { - clearTimeout(state.timeoutId); - state.timeoutId = null; - } - - state.previous = now; - - if (state.args !== null && state.boundFunc !== null) { - state.result = state.boundFunc(...state.args); - } - - state.boundFunc = null; - state.args = null; - - return state.result; - }; -}; - -/** - * Legacy fallback method for copying text to clipboard - * - * @param text - text to copy - */ -const fallbackCopyTextToClipboard = (text: string): void => { - const win = getGlobalWindow(); - const doc = getGlobalDocument(); - - if (!win || !doc || !doc.body) { - return; - } - - const el = doc.createElement('div'); - - el.className = 'codex-editor-clipboard'; - el.innerHTML = text; - - doc.body.appendChild(el); - - const selection = win.getSelection(); - const range = doc.createRange(); - - range.selectNode(el); - - win.getSelection()?.removeAllRanges(); - selection?.addRange(range); - - if (typeof doc.execCommand === 'function') { - doc.execCommand('copy'); - } - - doc.body.removeChild(el); -}; - -/** - * Copies passed text to the clipboard - * - * @param text - text to copy - */ -export const copyTextToClipboard = (text: string): void => { - const win = getGlobalWindow(); - const navigatorRef = getGlobalNavigator(); - - // Use modern Clipboard API if available - if (win?.isSecureContext && navigatorRef?.clipboard) { - navigatorRef.clipboard.writeText(text).catch(() => { - // Fallback to legacy method if Clipboard API fails - fallbackCopyTextToClipboard(text); - }); - - return; - } - - // Fallback to legacy method for older browsers - fallbackCopyTextToClipboard(text); +export const throttle = ( + func: (...args: unknown[]) => unknown, + wait: number, + options?: {leading?: boolean; trailing?: boolean} +): ((...args: unknown[]) => unknown) => { + return lodashThrottle(func, wait, options); }; /** @@ -710,7 +557,7 @@ export const getUserOS = (): {[key: string]: boolean} => { }; const navigatorRef = getGlobalNavigator(); - const userAgent = navigatorRef?.appVersion?.toLowerCase() ?? ''; + const userAgent = navigatorRef?.userAgent?.toLowerCase() ?? ''; const userOS = userAgent ? Object.keys(OS).find((os: string) => userAgent.indexOf(os) !== -1) : undefined; if (userOS !== undefined) { @@ -729,73 +576,42 @@ export const getUserOS = (): {[key: string]: boolean} => { * @returns {string} */ export const capitalize = (text: string): string => { - return text[0].toUpperCase() + text.slice(1); + if (!text) { + return text; + } + + return text.slice(0, 1).toUpperCase() + text.slice(1); }; /** - * Merge to objects recursively + * Customizer function for deep merge that overwrites arrays * - * @param {object} target - merge target - * @param {object[]} sources - merge sources - * @returns {object} + * @param {unknown} objValue - object value + * @param {unknown} srcValue - source value + * @returns {unknown} */ +const overwriteArrayMerge = (objValue: unknown, srcValue: unknown): unknown => { + if (Array.isArray(srcValue)) { + return srcValue; + } + + return undefined; +}; + export const deepMerge = (target: T, ...sources: Partial[]): T => { - if (sources.length === 0) { + if (!isObject(target) || sources.length === 0) { return target; } - const [source, ...rest] = sources; - - if (!isObject(target) || !isObject(source)) { - return deepMerge(target, ...rest); - } - - const targetRecord = target as Record; - - Object.entries(source).forEach(([key, value]) => { - if (value === null || value === undefined) { - targetRecord[key] = value as unknown; - - return; + return sources.reduce((acc: T, source) => { + if (!isObject(source)) { + return acc; } - if (typeof value !== 'object') { - targetRecord[key] = value; - - return; - } - - if (Array.isArray(value)) { - targetRecord[key] = value; - - return; - } - - if (!isObject(targetRecord[key])) { - targetRecord[key] = {}; - } - - deepMerge(targetRecord[key] as object, value as object); - }); - - return deepMerge(target, ...rest); + return lodashMergeWith(acc, source, overwriteArrayMerge) as T; + }, target); }; -/** - * Return true if current device supports touch events - * - * Note! This is a simple solution, it can give false-positive results. - * To detect touch devices more carefully, use 'touchstart' event listener - * - * @see http://www.stucox.com/blog/you-cant-detect-a-touchscreen/ - * @returns {boolean} - */ -export const isTouchSupported: boolean = (() => { - const doc = getGlobalDocument(); - - return Boolean(doc?.documentElement && 'ontouchstart' in doc.documentElement); -})(); - /** * Make shortcut command more human-readable * @@ -1168,12 +984,5 @@ export const isIosDevice = (() => { * @returns {boolean} true if they are equal */ export const equals = (var1: unknown, var2: unknown): boolean => { - const isVar1NonPrimitive = Array.isArray(var1) || isObject(var1); - const isVar2NonPrimitive = Array.isArray(var2) || isObject(var2); - - if (isVar1NonPrimitive || isVar2NonPrimitive) { - return JSON.stringify(var1) === JSON.stringify(var2); - } - - return var1 === var2; + return lodashIsEqual(var1, var2); }; diff --git a/src/components/utils/scroll-locker.ts b/src/components/utils/scroll-locker.ts index cc97775c..b20bedc9 100644 --- a/src/components/utils/scroll-locker.ts +++ b/src/components/utils/scroll-locker.ts @@ -43,6 +43,7 @@ export default class ScrollLocker { * Locks scroll in a hard way (via setting fixed position to body element) */ private lockHard(): void { + // eslint-disable-next-line deprecation/deprecation this.scrollPosition = window.pageYOffset; document.documentElement.style.setProperty( '--window-scroll-offset', diff --git a/test/unit/components/modules/rectangleSelection.test.ts b/test/unit/components/modules/rectangleSelection.test.ts index 6895a079..bbb0d525 100644 --- a/test/unit/components/modules/rectangleSelection.test.ts +++ b/test/unit/components/modules/rectangleSelection.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type Mock } import RectangleSelection from '../../../../src/components/modules/rectangleSelection'; import Block from '../../../../src/components/block'; +import SelectionUtils from '../../../../src/components/selection'; import EventsDispatcher from '../../../../src/components/utils/events'; import type { EditorEventMap } from '../../../../src/components/events'; import type { EditorModules } from '../../../../src/types-internal/editor-modules'; @@ -257,6 +258,58 @@ describe('RectangleSelection', () => { elementFromPointSpy.mockRestore(); }); + it('ignores selection attempts outside of selectable area', () => { + const { + rectangleSelection, + editorWrapper, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { mousedown: boolean }; + + const outsideNode = document.createElement('div'); + + document.body.appendChild(outsideNode); + + const elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockReturnValue(outsideNode); + + rectangleSelection.startSelection(10, 15); + + expect(internal.mousedown).toBe(false); + + const blockContent = document.createElement('div'); + + blockContent.className = Block.CSS.content; + editorWrapper.appendChild(blockContent); + elementFromPointSpy.mockReturnValue(blockContent); + + rectangleSelection.startSelection(20, 25); + + expect(internal.mousedown).toBe(false); + + elementFromPointSpy.mockRestore(); + }); + + it('clears selection activation flag when clearSelection is called', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { isRectSelectionActivated: boolean }; + + internal.isRectSelectionActivated = true; + rectangleSelection.clearSelection(); + + expect(internal.isRectSelectionActivated).toBe(false); + }); + + it('reports whether rectangle selection is active', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { isRectSelectionActivated: boolean }; + + internal.isRectSelectionActivated = false; + expect(rectangleSelection.isRectActivated()).toBe(false); + + internal.isRectSelectionActivated = true; + expect(rectangleSelection.isRectActivated()).toBe(true); + }); + it('resets selection parameters on endSelection', () => { const { rectangleSelection, @@ -346,6 +399,45 @@ describe('RectangleSelection', () => { expect(scrollSpy).toHaveBeenCalledWith(320); }); + it('updates rectangle on scroll events', () => { + const { + rectangleSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + processScroll: (event: MouseEvent) => void; + changingRectangle: (event: MouseEvent) => void; + }; + + const changeSpy = vi.spyOn(internal, 'changingRectangle'); + const scrollEvent = { pageX: 50, + pageY: 75 } as unknown as MouseEvent; + + internal.processScroll(scrollEvent); + + expect(changeSpy).toHaveBeenCalledWith(scrollEvent); + }); + + it('stops scrolling when cursor leaves scroll zones', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { + scrollByZones: (clientY: number) => void; + isScrolling: boolean; + inScrollZone: number | null; + }; + + Object.defineProperty(document.documentElement, 'clientHeight', { + configurable: true, + value: 1000, + }); + + internal.isScrolling = true; + internal.scrollByZones(200); + + expect(internal.isScrolling).toBe(false); + expect(internal.inScrollZone).toBeNull(); + }); + it('triggers vertical scrolling when mouse enters scroll zones', () => { const { rectangleSelection, @@ -374,6 +466,75 @@ describe('RectangleSelection', () => { expect(scrollSpy).toHaveBeenCalledWith(3); }); + it('scrolls vertically while mouse button is pressed in a scroll zone', () => { + const { rectangleSelection } = createRectangleSelection(); + const internal = rectangleSelection as unknown as { + scrollVertical: (speed: number) => void; + inScrollZone: number | null; + mousedown: boolean; + mouseY: number; + }; + + vi.useFakeTimers(); + + internal.inScrollZone = 1; + internal.mousedown = true; + internal.mouseY = 100; + + let yOffset = 0; + + const scrollYSpy = vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => yOffset); + + const scrollBySpy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => { + yOffset += y; + }); + + internal.scrollVertical(5); + + internal.inScrollZone = null; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + + expect(scrollBySpy).toHaveBeenCalledWith(0, 5); + expect(internal.mouseY).toBe(105); + + scrollBySpy.mockRestore(); + scrollYSpy.mockRestore(); + }); + + it('shrinks overlay rectangle to the starting point', () => { + const { + rectangleSelection, + editorWrapper, + } = createRectangleSelection(); + + rectangleSelection.prepare(); + + const internal = rectangleSelection as unknown as { + shrinkRectangleToPoint: () => void; + overlayRectangle: HTMLDivElement; + startX: number; + startY: number; + }; + + internal.overlayRectangle = editorWrapper.querySelector(`.${RectangleSelection.CSS.rect}`) as HTMLDivElement; + internal.startX = 150; + internal.startY = 260; + + const scrollXSpy = vi.spyOn(window, 'scrollX', 'get').mockReturnValue(10); + const scrollYSpy = vi.spyOn(window, 'scrollY', 'get').mockReturnValue(20); + + internal.shrinkRectangleToPoint(); + + expect(internal.overlayRectangle.style.left).toBe('140px'); + expect(internal.overlayRectangle.style.top).toBe('240px'); + expect(internal.overlayRectangle.style.bottom).toBe('calc(100% - 240px)'); + expect(internal.overlayRectangle.style.right).toBe('calc(100% - 140px)'); + + scrollXSpy.mockRestore(); + scrollYSpy.mockRestore(); + }); + it('selects or unselects blocks based on rectangle overlap', () => { const { rectangleSelection, @@ -411,6 +572,32 @@ describe('RectangleSelection', () => { expect(blockSelection.selectBlockByIndex).not.toHaveBeenCalled(); }); + it('adds blocks to selection stack via addBlockInSelection', () => { + const { + rectangleSelection, + blockSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + rectCrossesBlocks: boolean; + stackOfSelected: number[]; + addBlockInSelection: (index: number) => void; + }; + + internal.rectCrossesBlocks = true; + internal.addBlockInSelection(2); + + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(2); + expect(internal.stackOfSelected).toEqual([ 2 ]); + + blockSelection.selectBlockByIndex.mockClear(); + internal.rectCrossesBlocks = false; + internal.addBlockInSelection(3); + + expect(blockSelection.selectBlockByIndex).not.toHaveBeenCalled(); + expect(internal.stackOfSelected).toEqual([2, 3]); + }); + it('updates rectangle size based on cursor position', () => { const { rectangleSelection, @@ -487,6 +674,110 @@ describe('RectangleSelection', () => { elementFromPointSpy.mockRestore(); }); + it('activates rectangle selection and updates state when cursor moves with pressed mouse', () => { + const { + rectangleSelection, + toolbar, + editorWrapper, + } = createRectangleSelection(); + + rectangleSelection.prepare(); + + const internal = rectangleSelection as unknown as { + changingRectangle: (event: MouseEvent) => void; + mousedown: boolean; + isRectSelectionActivated: boolean; + overlayRectangle: HTMLDivElement; + }; + + internal.mousedown = true; + internal.isRectSelectionActivated = false; + internal.overlayRectangle = editorWrapper.querySelector(`.${RectangleSelection.CSS.rect}`) as HTMLDivElement; + + const genInfoSpy = vi.spyOn( + rectangleSelection as unknown as { genInfoForMouseSelection: () => { rightPos: number; leftPos: number; index: number } }, + 'genInfoForMouseSelection' + ).mockReturnValue({ + leftPos: 0, + rightPos: 500, + index: 1, + }); + const trySelectSpy = vi.spyOn( + rectangleSelection as unknown as { trySelectNextBlock: (index: number) => void }, + 'trySelectNextBlock' + ); + const inverseSpy = vi.spyOn( + rectangleSelection as unknown as { inverseSelection: () => void }, + 'inverseSelection' + ); + const selectionRemove = vi.fn(); + const selectionSpy = vi.spyOn(SelectionUtils, 'get').mockReturnValue({ + removeAllRanges: selectionRemove, + } as unknown as Selection); + + internal.changingRectangle({ + pageX: 200, + pageY: 220, + } as MouseEvent); + + expect(internal.isRectSelectionActivated).toBe(true); + expect(internal.overlayRectangle.style.display).toBe('block'); + expect(toolbar.close).toHaveBeenCalled(); + expect(trySelectSpy).toHaveBeenCalledWith(1); + expect(inverseSpy).toHaveBeenCalled(); + expect(selectionRemove).toHaveBeenCalled(); + + genInfoSpy.mockRestore(); + selectionSpy.mockRestore(); + }); + + it('does not attempt block selection when no block is detected under cursor', () => { + const { + rectangleSelection, + toolbar, + editorWrapper, + } = createRectangleSelection(); + + rectangleSelection.prepare(); + + const internal = rectangleSelection as unknown as { + changingRectangle: (event: MouseEvent) => void; + mousedown: boolean; + isRectSelectionActivated: boolean; + overlayRectangle: HTMLDivElement; + }; + + internal.mousedown = true; + internal.isRectSelectionActivated = true; + internal.overlayRectangle = editorWrapper.querySelector(`.${RectangleSelection.CSS.rect}`) as HTMLDivElement; + + const genInfoSpy = vi.spyOn( + rectangleSelection as unknown as { genInfoForMouseSelection: () => { rightPos: number; leftPos: number; index: number | undefined } }, + 'genInfoForMouseSelection' + ).mockReturnValue({ + leftPos: 0, + rightPos: 500, + index: undefined, + }); + const trySelectSpy = vi.spyOn( + rectangleSelection as unknown as { trySelectNextBlock: (index: number) => void }, + 'trySelectNextBlock' + ); + const selectionSpy = vi.spyOn(SelectionUtils, 'get'); + + internal.changingRectangle({ + pageX: 120, + pageY: 140, + } as MouseEvent); + + expect(toolbar.close).toHaveBeenCalled(); + expect(trySelectSpy).not.toHaveBeenCalled(); + expect(selectionSpy).not.toHaveBeenCalled(); + + genInfoSpy.mockRestore(); + selectionSpy.mockRestore(); + }); + it('clears selection state on mouse leave and mouse up events', () => { const { rectangleSelection, @@ -510,6 +801,49 @@ describe('RectangleSelection', () => { expect(clearSpy).toHaveBeenCalledTimes(2); expect(endSpy).toHaveBeenCalledTimes(2); }); + + it('extends selection to skipped blocks in downward direction', () => { + const { + rectangleSelection, + blockSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + stackOfSelected: number[]; + rectCrossesBlocks: boolean; + trySelectNextBlock: (index: number) => void; + }; + + internal.stackOfSelected.push(0, 1); + internal.rectCrossesBlocks = true; + + internal.trySelectNextBlock(4); + + expect(internal.stackOfSelected).toEqual([0, 1, 2, 3, 4]); + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(2); + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(3); + expect(blockSelection.selectBlockByIndex).toHaveBeenCalledWith(4); + }); + + it('shrinks selection stack when cursor moves backwards', () => { + const { + rectangleSelection, + blockSelection, + } = createRectangleSelection(); + + const internal = rectangleSelection as unknown as { + stackOfSelected: number[]; + rectCrossesBlocks: boolean; + trySelectNextBlock: (index: number) => void; + }; + + internal.stackOfSelected.push(0, 1, 2, 3); + internal.rectCrossesBlocks = true; + + internal.trySelectNextBlock(1); + + expect(internal.stackOfSelected).toEqual([0, 1]); + expect(blockSelection.unSelectBlockByIndex).toHaveBeenCalledWith(3); + expect(blockSelection.unSelectBlockByIndex).toHaveBeenCalledWith(2); + }); }); - - diff --git a/test/unit/components/utils/scroll-locker.test.ts b/test/unit/components/utils/scroll-locker.test.ts new file mode 100644 index 00000000..f766d511 --- /dev/null +++ b/test/unit/components/utils/scroll-locker.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ScrollLocker from '../../../../src/components/utils/scroll-locker'; + +const { getIsIosDeviceValue, setIsIosDeviceValue } = vi.hoisted(() => { + let value = false; + + return { + getIsIosDeviceValue: () => value, + setIsIosDeviceValue: (nextValue: boolean) => { + value = nextValue; + }, + }; +}); + +vi.mock('../../../../src/components/utils', async () => { + const actual = await vi.importActual('../../../../src/components/utils'); + + return { + ...actual, + get isIosDevice() { + return getIsIosDeviceValue(); + }, + }; +}); + +const originalScrollTo = window.scrollTo; + +describe('ScrollLocker', () => { + beforeEach(() => { + document.body.className = ''; + document.body.innerHTML = ''; + document.documentElement?.style.removeProperty('--window-scroll-offset'); + setIsIosDeviceValue(false); + delete (window as { pageYOffset?: number }).pageYOffset; + }); + + afterEach(() => { + if (originalScrollTo) { + window.scrollTo = originalScrollTo; + } else { + delete (window as { scrollTo?: typeof window.scrollTo }).scrollTo; + } + document.body.className = ''; + document.documentElement?.style.removeProperty('--window-scroll-offset'); + setIsIosDeviceValue(false); + delete (window as { pageYOffset?: number }).pageYOffset; + }); + + it('adds and removes body class on non-iOS devices', () => { + setIsIosDeviceValue(false); + const locker = new ScrollLocker(); + + locker.lock(); + + expect(document.body.classList.contains('ce-scroll-locked')).toBe(true); + + locker.unlock(); + + expect(document.body.classList.contains('ce-scroll-locked')).toBe(false); + }); + + it('performs hard lock on iOS devices and restores scroll position', () => { + const storedScroll = 160; + + setIsIosDeviceValue(true); + Object.defineProperty(window, 'pageYOffset', { + configurable: true, + value: storedScroll, + }); + + const scrollTo = vi.fn(); + + window.scrollTo = scrollTo; + + const locker = new ScrollLocker(); + + locker.lock(); + + expect(document.body.classList.contains('ce-scroll-locked--hard')).toBe(true); + expect(document.documentElement?.style.getPropertyValue('--window-scroll-offset')).toBe(`${storedScroll}px`); + + locker.unlock(); + + expect(document.body.classList.contains('ce-scroll-locked--hard')).toBe(false); + expect(scrollTo).toHaveBeenCalledWith(0, storedScroll); + }); + + it('does not restore scroll when hard lock was never applied', () => { + setIsIosDeviceValue(true); + const scrollTo = vi.fn(); + + window.scrollTo = scrollTo; + + const locker = new ScrollLocker(); + + locker.unlock(); + + expect(scrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/polyfills.test.ts b/test/unit/polyfills.test.ts index beec92dd..f3a0f35b 100644 --- a/test/unit/polyfills.test.ts +++ b/test/unit/polyfills.test.ts @@ -7,197 +7,7 @@ const importPolyfills = async (): Promise => { await import(POLYFILLS_PATH); }; -type VendorMatchesKey = - | 'matchesSelector' - | 'mozMatchesSelector' - | 'msMatchesSelector' - | 'oMatchesSelector' - | 'webkitMatchesSelector'; - describe('polyfills', () => { - describe('Element.matches', () => { - type VendorImplementation = (selector: string) => boolean; - type MutableMatchesPrototype = Omit< - Element, - | 'matches' - | 'matchesSelector' - | 'mozMatchesSelector' - | 'msMatchesSelector' - | 'oMatchesSelector' - | 'webkitMatchesSelector' - > & { - matches: VendorImplementation | undefined; - } & Partial>; - - const prototype = Element.prototype as MutableMatchesPrototype; - const vendorKeys: VendorMatchesKey[] = [ - 'matchesSelector', - 'mozMatchesSelector', - 'msMatchesSelector', - 'oMatchesSelector', - 'webkitMatchesSelector', - ]; - - let originalMatches: ((selector: string) => boolean) | undefined; - const originalVendors: Partial> = {}; - - beforeEach(() => { - originalMatches = prototype.matches; - prototype.matches = undefined; - - vendorKeys.forEach((key) => { - originalVendors[key] = prototype[key]; - delete prototype[key]; - }); - }); - - afterEach(() => { - prototype.matches = originalMatches; - - vendorKeys.forEach((key) => { - const storedVendor = originalVendors[key]; - - if (typeof storedVendor === 'undefined') { - delete prototype[key]; - - return; - } - - prototype[key] = storedVendor; - }); - }); - - it('delegates to vendor-specific implementation when available', async () => { - const vendorImplementation = vi.fn((_selector: string) => true); - - prototype.matchesSelector = (selector: string): boolean => vendorImplementation(selector); - - await importPolyfills(); - - const element = document.createElement('div'); - - expect(element.matches('div')).toBe(true); - expect(vendorImplementation).toHaveBeenCalledTimes(1); - expect(vendorImplementation).toHaveBeenCalledWith('div'); - }); - - it('falls back to querySelectorAll when no vendor implementations exist', async () => { - await importPolyfills(); - - const container = document.createElement('div'); - const child = document.createElement('span'); - const other = document.createElement('p'); - - container.appendChild(child); - container.appendChild(other); - document.body.appendChild(container); - - expect(child.matches('span')).toBe(true); - expect(child.matches('button')).toBe(false); - - container.remove(); - }); - }); - - describe('Element.closest', () => { - type MutableClosestPrototype = Omit & { - closest: Element['closest'] | undefined; - }; - - const prototype = Element.prototype as MutableClosestPrototype; - let originalClosest: ((selector: string) => Element | null) | undefined; - - beforeEach(() => { - originalClosest = prototype.closest; - prototype.closest = undefined; - }); - - afterEach(() => { - prototype.closest = originalClosest; - }); - - it('returns the nearest ancestor that matches the selector', async () => { - await importPolyfills(); - - const wrapper = document.createElement('section'); - const parent = document.createElement('div'); - const target = document.createElement('button'); - - parent.className = 'parent'; - target.className = 'child'; - - parent.appendChild(target); - wrapper.appendChild(parent); - document.body.appendChild(wrapper); - - expect(target.closest('.parent')).toBe(parent); - expect(target.closest('section')).toBe(wrapper); - - wrapper.remove(); - }); - - it('returns null when no ancestor matches the selector', async () => { - await importPolyfills(); - - const parent = document.createElement('div'); - const target = document.createElement('button'); - - parent.appendChild(target); - document.body.appendChild(parent); - - expect(target.closest('.missing')).toBeNull(); - - parent.remove(); - }); - }); - - describe('Element.prepend', () => { - type MutablePrependPrototype = Omit & { - prepend: ((nodes: Array | Node | string) => void) | undefined; - }; - - const prototype = Element.prototype as MutablePrependPrototype; - let originalPrepend: ((nodes: Array | Node | string) => void) | undefined; - - beforeEach(() => { - originalPrepend = prototype.prepend; - prototype.prepend = undefined; - }); - - afterEach(() => { - prototype.prepend = originalPrepend; - }); - - it('inserts nodes and strings before the first child', async () => { - await importPolyfills(); - - const element = document.createElement('div'); - const existing = document.createElement('p'); - const newSpan = document.createElement('span'); - - existing.textContent = 'existing'; - newSpan.textContent = 'new'; - - element.appendChild(existing); - - const elementWithPolyfill = element as unknown as MutablePrependPrototype; - const prepend = elementWithPolyfill.prepend; - - if (typeof prepend !== 'function') { - throw new Error('Expected element.prepend to be defined after applying polyfills'); - } - - prepend.call(elementWithPolyfill, ['text', newSpan]); - - const childNodes = Array.from(element.childNodes); - - expect(childNodes).toHaveLength(3); - expect(childNodes[0].textContent).toBe('text'); - expect(childNodes[1]).toBe(newSpan); - expect(childNodes[2]).toBe(existing); - }); - }); - describe('Element.scrollIntoViewIfNeeded', () => { type MutableScrollPrototype = Omit & { scrollIntoViewIfNeeded: ((centerIfNeeded?: boolean) => void) | undefined; diff --git a/test/unit/tools/inline.test.ts b/test/unit/tools/inline.test.ts index 666cf8a7..35ba1703 100644 --- a/test/unit/tools/inline.test.ts +++ b/test/unit/tools/inline.test.ts @@ -86,6 +86,24 @@ describe('InlineToolAdapter', () => { expect(tool.title).toBe(constructable.title); }); + + it('returns empty string when constructable is undefined', () => { + const tool = new InlineToolAdapter({ + ...createInlineToolOptions(), + constructable: undefined as unknown as InlineToolAdapterOptions['constructable'], + }); + + expect(tool.title).toBe(''); + }); + + it('returns empty string when constructable title is undefined', () => { + const tool = new InlineToolAdapter({ + ...createInlineToolOptions(), + constructable: {} as InlineToolAdapterOptions['constructable'], + }); + + expect(tool.title).toBe(''); + }); }); describe('.isInternal', () => { diff --git a/test/unit/ui/toolbox.test.ts b/test/unit/ui/toolbox.test.ts index dc7419ca..ba2a59c3 100644 --- a/test/unit/ui/toolbox.test.ts +++ b/test/unit/ui/toolbox.test.ts @@ -7,7 +7,6 @@ import type { Popover } from '../../../src/components/utils/popover'; import { PopoverEvent } from '@/types/utils/popover/popover-event'; import { EditorMobileLayoutToggled } from '../../../src/components/events'; import Shortcuts from '../../../src/components/utils/shortcuts'; -import { BlockToolAPI } from '../../../src/components/block'; // Mock dependencies at module level const mockPopoverInstance = { @@ -86,13 +85,7 @@ describe('Toolbox', () => { const blockAPI = { id: 'test-block-id', isEmpty: true, - call: vi.fn((methodName: string) => { - if (methodName === BlockToolAPI.APPEND_CALLBACK) { - return undefined; - } - - return undefined; - }), + call: vi.fn(), } as unknown as BlockAPI; // Mock BlockToolAdapter @@ -760,4 +753,3 @@ describe('Toolbox', () => { }); }); }); - diff --git a/test/unit/utils/utils.test.ts b/test/unit/utils/utils.test.ts index 565c316e..1b62f0c8 100644 --- a/test/unit/utils/utils.test.ts +++ b/test/unit/utils/utils.test.ts @@ -2,16 +2,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { ChainData } from '../../../src/components/utils'; import { - typeOf, isFunction, isObject, isString, isBoolean, isNumber, isUndefined, - isClass, isEmpty, - isPromise, isPrintableKey, keyCodes, mouseButtons, @@ -30,7 +27,6 @@ import { capitalize, deepMerge, getUserOS, - isTouchSupported, beautifyShortcut, getValidUrl, generateBlockId, @@ -41,8 +37,7 @@ import { mobileScreenBreakpoint, isMobileScreen, isIosDevice, - equals, - copyTextToClipboard + equals } from '../../../src/components/utils'; // Mock VERSION global variable @@ -90,44 +85,6 @@ describe('utils', () => { }); }); - describe('typeOf', () => { - it('should return correct type for string', () => { - expect(typeOf('test')).toBe('string'); - }); - - it('should return correct type for number', () => { - expect(typeOf(123)).toBe('number'); - }); - - it('should return correct type for boolean', () => { - expect(typeOf(true)).toBe('boolean'); - }); - - it('should return correct type for object', () => { - expect(typeOf({})).toBe('object'); - }); - - it('should return correct type for array', () => { - expect(typeOf([])).toBe('array'); - }); - - it('should return correct type for function', () => { - expect(typeOf(() => {})).toBe('function'); - }); - - it('should return correct type for null', () => { - expect(typeOf(null)).toBe('null'); - }); - - it('should return correct type for undefined', () => { - expect(typeOf(undefined)).toBe('undefined'); - }); - - it('should return correct type for date', () => { - expect(typeOf(new Date())).toBe('date'); - }); - }); - describe('isFunction', () => { it('should return true for regular function', () => { const fn = function (): void {}; @@ -282,32 +239,6 @@ describe('utils', () => { }); }); - describe('isClass', () => { - it('should return true for class', () => { - /** - * - */ - class TestClass {} - expect(isClass(TestClass)).toBe(true); - }); - - it('should return false for regular function', () => { - const fn = function (): void {}; - - expect(isClass(fn)).toBe(false); - }); - - it('should return false for arrow function', () => { - const fn = (): void => {}; - - expect(isClass(fn)).toBe(false); - }); - - it('should return false for object', () => { - expect(isClass({})).toBe(false); - }); - }); - describe('isEmpty', () => { it('should return true for empty object', () => { expect(isEmpty({})).toBe(true); @@ -325,28 +256,11 @@ describe('utils', () => { expect(isEmpty(undefined)).toBe(true); }); - it('should return false for object created with Object.create (no constructor)', () => { + it('should return true for object created with Object.create (no constructor)', () => { const obj = Object.create(null); - expect(isEmpty(obj)).toBe(false); - }); - }); - - describe('isPromise', () => { - it('should return true for Promise', () => { - const promise = Promise.resolve(123); - - expect(isPromise(promise)).toBe(true); - }); - - it('should return false for non-Promise', () => { - expect(isPromise({})).toBe(false); - }); - - it('should return false for thenable object', () => { - const thenable = { then: () => {} }; - - expect(isPromise(thenable)).toBe(false); + // lodash isEmpty returns true for objects with no enumerable properties + expect(isEmpty(obj)).toBe(true); }); }); @@ -733,8 +647,8 @@ describe('utils', () => { expect(capitalize('Hello')).toBe('Hello'); }); - it('should throw when called with empty string', () => { - expect(() => capitalize('')).toThrow(TypeError); + it('should return empty string when called with empty string', () => { + expect(capitalize('')).toBe(''); }); }); @@ -803,13 +717,14 @@ describe('utils', () => { expect(result).toEqual({ value: null }); }); - it('should handle undefined values', () => { + it('should skip undefined values', () => { const target = { value: 'old' }; const source = { value: undefined }; const result = deepMerge(target, source); - expect(result).toEqual({ value: undefined }); + // lodash mergeWith skips undefined values (treats them as "not provided") + expect(result).toEqual({ value: 'old' }); }); }); @@ -818,7 +733,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: '' }, + value: { userAgent: '' }, configurable: true, }); @@ -839,7 +754,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Windows' }, + value: { userAgent: 'Windows' }, configurable: true, }); @@ -857,7 +772,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Mac' }, + value: { userAgent: 'Mac' }, configurable: true, }); @@ -872,12 +787,6 @@ describe('utils', () => { }); }); - describe('isTouchSupported', () => { - it('should be a boolean value', () => { - expect(typeof isTouchSupported).toBe('boolean'); - }); - }); - describe('beautifyShortcut', () => { it('should replace shift with ⇧', () => { expect(beautifyShortcut('Shift+B')).toContain('⇧'); @@ -887,7 +796,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Mac' }, + value: { userAgent: 'Mac' }, configurable: true, }); @@ -905,7 +814,7 @@ describe('utils', () => { const originalNavigator = navigator; Object.defineProperty(window, 'navigator', { - value: { appVersion: 'Windows' }, + value: { userAgent: 'Windows' }, configurable: true, }); @@ -1233,70 +1142,4 @@ describe('utils', () => { expect(equals(arr1, arr2)).toBe(false); }); }); - - describe('copyTextToClipboard', () => { - beforeEach(() => { - // Mock clipboard API - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: vi.fn().mockResolvedValue(undefined), - }, - configurable: true, - }); - - Object.defineProperty(window, 'isSecureContext', { - value: true, - configurable: true, - }); - }); - - it('should use Clipboard API when available', async () => { - const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText'); - - copyTextToClipboard('test text'); - - // Wait for promise to resolve - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - - expect(writeTextSpy).toHaveBeenCalledWith('test text'); - }); - - it('should fallback to legacy method when Clipboard API fails', async () => { - const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('Clipboard error')); - - // Mock execCommand since it doesn't exist in jsdom - const execCommandSpy = vi.fn().mockReturnValue(true); - - Object.defineProperty(document, 'execCommand', { - value: execCommandSpy, - configurable: true, - writable: true, - }); - - // Mock getSelection and createRange for fallback method - const mockRange = { - selectNode: vi.fn(), - }; - const mockSelection = { - removeAllRanges: vi.fn(), - addRange: vi.fn(), - }; - - vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection as unknown as Selection); - vi.spyOn(document, 'createRange').mockReturnValue(mockRange as unknown as Range); - - copyTextToClipboard('test text'); - - // Wait for promise to reject and fallback to execute - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(execCommandSpy).toHaveBeenCalledWith('copy'); - - writeTextSpy.mockRestore(); - }); - }); }); From 408b160e3948c3da62dbba234fa259ae856d6ea8 Mon Sep 17 00:00:00 2001 From: JackUait Date: Sun, 16 Nov 2025 01:00:11 +0300 Subject: [PATCH 2/7] fix: partially replace deprecated APIs with the modern ones --- docs/api.md | 3 - .../block-tunes/block-tune-move-down.ts | 4 +- .../block-tunes/block-tune-move-up.ts | 4 +- src/components/block/index.ts | 4 +- src/components/blocks.ts | 23 -- src/components/dom.ts | 24 -- .../inline-tools/inline-tool-bold.ts | 4 +- src/components/modules/api/blocks.ts | 18 -- src/components/modules/blockEvents.ts | 94 ++++++-- src/components/modules/blockManager.ts | 15 -- src/components/modules/blockSelection.ts | 3 +- test/unit/components/blocks.test.ts | 30 --- test/unit/components/dom.test.ts | 15 -- .../inline-tools/inline-tool-bold.test.ts | 186 +++++++++++++++ .../components/modules/api/blocks.test.ts | 18 -- .../components/modules/blockEvents.test.ts | 31 ++- .../components/modules/blockManager.test.ts | 222 +++++++++++++++++- types/api/blocks.d.ts | 8 - types/tools/tool-settings.d.ts | 12 - 19 files changed, 523 insertions(+), 195 deletions(-) create mode 100644 test/unit/components/inline-tools/inline-tool-bold.test.ts diff --git a/docs/api.md b/docs/api.md index bfde79da..95de75dc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,9 +59,6 @@ Methods that working with Blocks `renderFromHTML(data)` - parse and render passed HTML string (*not for production use*) -`swap(fromIndex, toIndex)` - swaps two Blocks by their positions (deprecated: -use 'move' instead) - `move(toIndex, fromIndex)` - moves block from one index to another position. `fromIndex` will be the current block's index by default. diff --git a/src/components/block-tunes/block-tune-move-down.ts b/src/components/block-tunes/block-tune-move-down.ts index a06308c8..9a979289 100644 --- a/src/components/block-tunes/block-tune-move-down.ts +++ b/src/components/block-tunes/block-tune-move-down.ts @@ -6,7 +6,7 @@ import type { API, BlockTune } from '../../../types'; import { IconChevronDown } from '@codexteam/icons'; -import type { TunesMenuConfig } from '../../../types/tools'; +import type { MenuConfig } from '../../../types/tools'; /** @@ -44,7 +44,7 @@ export default class MoveDownTune implements BlockTune { /** * Tune's appearance in block settings menu */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: IconChevronDown, title: this.api.i18n.t('Move down'), diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts index e007fbb9..d72b490b 100644 --- a/src/components/block-tunes/block-tune-move-up.ts +++ b/src/components/block-tunes/block-tune-move-up.ts @@ -5,7 +5,7 @@ */ import type { API, BlockTune } from '../../../types'; import { IconChevronUp } from '@codexteam/icons'; -import type { TunesMenuConfig } from '../../../types/tools'; +import type { MenuConfig } from '../../../types/tools'; /** * @@ -42,7 +42,7 @@ export default class MoveUpTune implements BlockTune { /** * Tune's appearance in block settings menu */ - public render(): TunesMenuConfig { + public render(): MenuConfig { return { icon: IconChevronUp, title: this.api.i18n.t('Move up'), diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 7f567f17..2a191978 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -21,7 +21,7 @@ import type BlockTuneAdapter from '../tools/tune'; import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import type ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; -import type { TunesMenuConfigItem } from '../../../types/tools'; +import type { MenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; import type { EditorEventMap } from '../events'; import { FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; @@ -386,7 +386,7 @@ export default class Block extends EventsDispatcher { const toolTunesPopoverParams: PopoverItemParams[] = []; const commonTunesPopoverParams: PopoverItemParams[] = []; const pushTuneConfig = ( - tuneConfig: TunesMenuConfigItem | TunesMenuConfigItem[] | HTMLElement | undefined, + tuneConfig: MenuConfigItem | MenuConfigItem[] | HTMLElement | undefined, target: PopoverItemParams[] ): void => { if (!tuneConfig) { diff --git a/src/components/blocks.ts b/src/components/blocks.ts index 69cb8e51..10132aaf 100644 --- a/src/components/blocks.ts +++ b/src/components/blocks.ts @@ -1,5 +1,4 @@ import * as _ from './utils'; -import $ from './dom'; import type Block from './block'; import { BlockToolAPI } from './block'; import type { MoveEvent } from '../../types/tools'; @@ -119,28 +118,6 @@ export default class Blocks { this.insertToDOM(block); } - /** - * Swaps blocks with indexes first and second - * - * @param {number} first - first block index - * @param {number} second - second block index - * @deprecated — use 'move' instead - */ - public swap(first: number, second: number): void { - const secondBlock = this.blocks[second]; - - /** - * Change in DOM - */ - $.swap(this.blocks[first].holder, secondBlock.holder); - - /** - * Change in array - */ - this.blocks[second] = this.blocks[first]; - this.blocks[first] = secondBlock; - } - /** * Move a block from one to another index * diff --git a/src/components/dom.ts b/src/components/dom.ts index 24b7af21..50a827a2 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -130,30 +130,6 @@ export default class Dom { } } - /** - * Swap two elements in parent - * - * @param {HTMLElement} el1 - from - * @param {HTMLElement} el2 - to - * @deprecated - */ - public static swap(el1: HTMLElement, el2: HTMLElement): void { - // create marker element and insert it where el1 is - const temp = document.createElement('div'); - const parent = el1.parentNode; - - parent?.insertBefore(temp, el1); - - // move el1 to right before el2 - parent?.insertBefore(el1, el2); - - // move el2 to right before where el1 used to be - parent?.insertBefore(el2, temp); - - // remove temporary marker node - parent?.removeChild(temp); - } - /** * Selector Decorator * diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 890d52b0..444a0290 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -1575,8 +1575,8 @@ export default class BoldInlineTool implements InlineTool { * @param event - The keyboard event to check */ private static isBoldShortcut(event: KeyboardEvent): boolean { - const platform = typeof navigator !== 'undefined' ? navigator.platform : ''; - const isMac = platform.toUpperCase().includes('MAC'); + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''; + const isMac = userAgent.includes('mac'); const primaryModifier = isMac ? event.metaKey : event.ctrlKey; if (!primaryModifier || event.altKey) { diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index 01c8338e..f96d46ef 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -23,7 +23,6 @@ export default class BlocksAPI extends Module { render: (data: OutputData): Promise => this.render(data), renderFromHTML: (data: string): Promise => this.renderFromHTML(data), delete: (index?: number): void => this.delete(index), - swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex), move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex), getBlockByIndex: (index: number): BlockAPIInterface | undefined => this.getBlockByIndex(index), getById: (id: string): BlockAPIInterface | null => this.getById(id), @@ -127,23 +126,6 @@ export default class BlocksAPI extends Module { return new BlockAPI(block); } - /** - * Call Block Manager method that swap Blocks - * - * @param {number} fromIndex - position of first Block - * @param {number} toIndex - position of second Block - * @deprecated — use 'move' instead - */ - public swap(fromIndex: number, toIndex: number): void { - _.log( - '`blocks.swap()` method is deprecated and will be removed in the next major release. ' + - 'Use `block.move()` method instead', - 'info' - ); - - this.Editor.BlockManager.swap(fromIndex, toIndex); - } - /** * Move block from one index to another * diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 22218ba1..ac89f9d1 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -10,6 +10,19 @@ import { areBlocksMergeable } from '../utils/blocks'; import * as caretUtils from '../utils/caret'; import { focus } from '@editorjs/caret'; +const KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP: Record = { + Backspace: _.keyCodes.BACKSPACE, + Delete: _.keyCodes.DELETE, + Enter: _.keyCodes.ENTER, + Tab: _.keyCodes.TAB, + ArrowDown: _.keyCodes.DOWN, + ArrowRight: _.keyCodes.RIGHT, + ArrowUp: _.keyCodes.UP, + ArrowLeft: _.keyCodes.LEFT, +}; + +const PRINTABLE_SPECIAL_KEYS = new Set(['Enter', 'Process', 'Spacebar', 'Space', 'Dead']); + /** * */ @@ -29,10 +42,12 @@ export default class BlockEvents extends Module { return; } + const keyCode = this.getKeyCode(event); + /** - * Fire keydown processor by event.keyCode + * Fire keydown processor by normalized keyboard code */ - switch (event.keyCode) { + switch (keyCode) { case _.keyCodes.BACKSPACE: this.backspace(event); break; @@ -87,7 +102,7 @@ export default class BlockEvents extends Module { */ private handleSelectedBlocksDeletion(event: KeyboardEvent): boolean { const { BlockSelection, BlockManager, Caret } = this.Editor; - const isRemoveKey = event.keyCode === _.keyCodes.BACKSPACE || event.keyCode === _.keyCodes.DELETE; + const isRemoveKey = event.key === 'Backspace' || event.key === 'Delete'; const selectionExists = SelectionUtils.isSelectionExists; const selectionCollapsed = SelectionUtils.isCollapsed === true; const shouldHandleSelectionDeletion = isRemoveKey && @@ -133,7 +148,7 @@ export default class BlockEvents extends Module { * - close Toolbar * - clear block highlighting */ - if (!_.isPrintableKey(event.keyCode)) { + if (!this.isPrintableKeyEvent(event)) { return; } @@ -601,8 +616,14 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keyboard event */ private arrowRightAndDown(event: KeyboardEvent): void { - const isFlipperCombination = Flipper.usedKeys.includes(event.keyCode) && - (!event.shiftKey || event.keyCode === _.keyCodes.TAB); + const keyCode = this.getKeyCode(event); + + if (keyCode === null) { + return; + } + + const isFlipperCombination = Flipper.usedKeys.includes(keyCode) && + (!event.shiftKey || keyCode === _.keyCodes.TAB); /** * Arrows might be handled on toolbars by flipper @@ -624,7 +645,7 @@ export default class BlockEvents extends Module { const caretAtEnd = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentBlock.currentInput) : undefined; const shouldEnableCBS = caretAtEnd || this.Editor.BlockSelection.anyBlockSelected; - const isShiftDownKey = event.shiftKey && event.keyCode === _.keyCodes.DOWN; + const isShiftDownKey = event.shiftKey && keyCode === _.keyCodes.DOWN; if (isShiftDownKey && shouldEnableCBS) { this.Editor.CrossBlockSelection.toggleBlockSelectedState(); @@ -636,7 +657,7 @@ export default class BlockEvents extends Module { void this.Editor.InlineToolbar.tryToShow(); } - const navigateNext = event.keyCode === _.keyCodes.DOWN || (event.keyCode === _.keyCodes.RIGHT && !this.isRtl); + const navigateNext = keyCode === _.keyCodes.DOWN || (keyCode === _.keyCodes.RIGHT && !this.isRtl); const isNavigated = navigateNext ? this.Editor.Caret.navigateNext() : this.Editor.Caret.navigatePrevious(); if (isNavigated) { @@ -677,7 +698,13 @@ export default class BlockEvents extends Module { */ const toolbarOpened = this.Editor.UI.someToolbarOpened; - if (toolbarOpened && Flipper.usedKeys.includes(event.keyCode) && (!event.shiftKey || event.keyCode === _.keyCodes.TAB)) { + const keyCode = this.getKeyCode(event); + + if (keyCode === null) { + return; + } + + if (toolbarOpened && Flipper.usedKeys.includes(keyCode) && (!event.shiftKey || keyCode === _.keyCodes.TAB)) { return; } @@ -696,7 +723,7 @@ export default class BlockEvents extends Module { const caretAtStart = currentBlock?.currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentBlock.currentInput) : undefined; const shouldEnableCBS = caretAtStart || this.Editor.BlockSelection.anyBlockSelected; - const isShiftUpKey = event.shiftKey && event.keyCode === _.keyCodes.UP; + const isShiftUpKey = event.shiftKey && keyCode === _.keyCodes.UP; if (isShiftUpKey && shouldEnableCBS) { this.Editor.CrossBlockSelection.toggleBlockSelectedState(false); @@ -708,7 +735,7 @@ export default class BlockEvents extends Module { void this.Editor.InlineToolbar.tryToShow(); } - const navigatePrevious = event.keyCode === _.keyCodes.UP || (event.keyCode === _.keyCodes.LEFT && !this.isRtl); + const navigatePrevious = keyCode === _.keyCodes.UP || (keyCode === _.keyCodes.LEFT && !this.isRtl); const isNavigated = navigatePrevious ? this.Editor.Caret.navigatePrevious() : this.Editor.Caret.navigateNext(); if (isNavigated) { @@ -743,10 +770,13 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keyboard event */ private needToolbarClosing(event: KeyboardEvent): boolean { - const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened); - const blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened); - const inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened); - const flippingToolbarItems = event.keyCode === _.keyCodes.TAB; + const keyCode = this.getKeyCode(event); + const isEnter = keyCode === _.keyCodes.ENTER; + const isTab = keyCode === _.keyCodes.TAB; + const toolboxItemSelected = (isEnter && this.Editor.Toolbar.toolbox.opened); + const blockSettingsItemSelected = (isEnter && this.Editor.BlockSettings.opened); + const inlineToolbarItemSelected = (isEnter && this.Editor.InlineToolbar.opened); + const flippingToolbarItems = isTab; /** * Do not close Toolbar in cases: @@ -798,4 +828,38 @@ export default class BlockEvents extends Module { }); } } + + /** + * Convert KeyboardEvent.key or code to the legacy numeric keyCode + * + * @param event - keyboard event + */ + private getKeyCode(event: KeyboardEvent): number | null { + const keyFromEvent = event.key && KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP[event.key]; + + if (keyFromEvent !== undefined && typeof keyFromEvent === 'number') { + return keyFromEvent; + } + + const codeFromEvent = event.code && KEYBOARD_EVENT_KEY_TO_KEY_CODE_MAP[event.code]; + + if (codeFromEvent !== undefined && typeof codeFromEvent === 'number') { + return codeFromEvent; + } + + return null; + } + + /** + * Detect whether KeyDown should be treated as printable input + * + * @param event - keyboard event + */ + private isPrintableKeyEvent(event: KeyboardEvent): boolean { + if (!event.key) { + return false; + } + + return event.key.length === 1 || PRINTABLE_SPECIAL_KEYS.has(event.key); + } } diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 965f5d64..0775711a 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -865,21 +865,6 @@ export default class BlockManager extends Module { return this.blocks.find((block) => block.holder === firstLevelBlock); } - /** - * Swap Blocks Position - * - * @param {number} fromIndex - index of first block - * @param {number} toIndex - index of second block - * @deprecated — use 'move' instead - */ - public swap(fromIndex: number, toIndex: number): void { - /** Move up current Block */ - this.blocksStore.swap(fromIndex, toIndex); - - /** Now actual block moved up so that current block index decreased */ - this.currentBlockIndex = toIndex; - } - /** * Move a block to a new index * diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index ae67afb5..752e42dd 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -238,7 +238,8 @@ export default class BlockSelection extends Module { this.readyToBlockSelection = false; const isKeyboard = reason && (reason instanceof KeyboardEvent); - const isPrintableKey = isKeyboard && _.isPrintableKey((reason as KeyboardEvent).keyCode); + const keyboardEvent = reason as KeyboardEvent; + const isPrintableKey = isKeyboard && keyboardEvent.key && keyboardEvent.key.length === 1; /** * If reason caused clear of the selection was printable key and any block is selected, diff --git a/test/unit/components/blocks.test.ts b/test/unit/components/blocks.test.ts index 0a319086..832405ad 100644 --- a/test/unit/components/blocks.test.ts +++ b/test/unit/components/blocks.test.ts @@ -233,36 +233,6 @@ describe('Blocks', () => { }); }); - describe('swap', () => { - it('should swap two blocks', () => { - const blocks = createBlocks(); - const block1 = createMockBlock('block-1'); - const block2 = createMockBlock('block-2'); - - blocks.push(block1); - blocks.push(block2); - - blocks.swap(0, 1); - - expect(blocks.blocks[0]).toBe(block2); - expect(blocks.blocks[1]).toBe(block1); - }); - - it('should swap blocks in DOM', () => { - const blocks = createBlocks(); - const block1 = createMockBlock('block-1'); - const block2 = createMockBlock('block-2'); - - blocks.push(block1); - blocks.push(block2); - - blocks.swap(0, 1); - - expect(workingArea.children[0]).toBe(block2.holder); - expect(workingArea.children[1]).toBe(block1.holder); - }); - }); - describe('move', () => { it('should move block from one index to another', () => { const blocks = createBlocks(); diff --git a/test/unit/components/dom.test.ts b/test/unit/components/dom.test.ts index 18af3451..f06443ba 100644 --- a/test/unit/components/dom.test.ts +++ b/test/unit/components/dom.test.ts @@ -63,21 +63,6 @@ describe('Dom helper utilities', () => { expect(Array.from(parent.children)).toEqual([second, first, initial]); }); - - it('swaps elements in place via swap()', () => { - const parent = document.createElement('div'); - const first = document.createElement('span'); - const second = document.createElement('span'); - - first.id = 'first'; - second.id = 'second'; - - parent.append(first, second); - - Dom.swap(first, second); - - expect(Array.from(parent.children).map((el) => el.id)).toEqual(['second', 'first']); - }); }); describe('selectors', () => { diff --git a/test/unit/components/inline-tools/inline-tool-bold.test.ts b/test/unit/components/inline-tools/inline-tool-bold.test.ts new file mode 100644 index 00000000..7affb0d3 --- /dev/null +++ b/test/unit/components/inline-tools/inline-tool-bold.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import BoldInlineTool from '../../../../src/components/inline-tools/inline-tool-bold'; +import SelectionUtils from '../../../../src/components/selection'; +import type { PopoverItemDefaultBaseParams } from '../../../../types/utils/popover'; + +type BoldInlineToolInternals = { + shortcutListenerRegistered: boolean; + selectionListenerRegistered: boolean; + inputListenerRegistered: boolean; + instances: Set; + handleShortcut: EventListener; + handleGlobalSelectionChange: EventListener; + handleGlobalInput: EventListener; +}; + +const getInternals = (): BoldInlineToolInternals => { + return BoldInlineTool as unknown as BoldInlineToolInternals; +}; + +const clearSelection = (): void => { + const selection = window.getSelection(); + + selection?.removeAllRanges(); +}; + +const resetBoldInlineTool = (): void => { + const internals = getInternals(); + + document.removeEventListener('keydown', internals.handleShortcut, true); + document.removeEventListener('selectionchange', internals.handleGlobalSelectionChange, true); + document.removeEventListener('input', internals.handleGlobalInput, true); + + internals.shortcutListenerRegistered = false; + internals.selectionListenerRegistered = false; + internals.inputListenerRegistered = false; + internals.instances.clear(); +}; + +const setupEditor = (html: string): { block: HTMLElement } => { + const wrapper = document.createElement('div'); + + wrapper.className = SelectionUtils.CSS.editorWrapper; + + const block = document.createElement('div'); + + block.dataset.blockTool = 'paragraph'; + block.contentEditable = 'true'; + block.innerHTML = html; + wrapper.appendChild(block); + + const toolbar = document.createElement('div'); + + toolbar.dataset.cy = 'inline-toolbar'; + + const button = document.createElement('button'); + + button.dataset.itemName = 'bold'; + toolbar.appendChild(button); + wrapper.appendChild(toolbar); + + document.body.appendChild(wrapper); + + return { block }; +}; + +const setRange = (node: Text, start: number, end?: number): void => { + const selection = window.getSelection(); + const range = document.createRange(); + + range.setStart(node, start); + + if (typeof end === 'number') { + range.setEnd(node, end); + } else { + range.collapse(true); + } + + selection?.removeAllRanges(); + selection?.addRange(range); +}; + +describe('BoldInlineTool', () => { + beforeEach(() => { + document.body.innerHTML = ''; + clearSelection(); + resetBoldInlineTool(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + clearSelection(); + resetBoldInlineTool(); + }); + + it('describes the tool metadata', () => { + expect(BoldInlineTool.isInline).toBe(true); + expect(BoldInlineTool.title).toBe('Bold'); + expect(BoldInlineTool.sanitize).toEqual({ + strong: {}, + b: {}, + }); + + const tool = new BoldInlineTool(); + + expect(tool.shortcut).toBe('CMD+B'); + }); + + it('wraps selected text and reports bold state', () => { + const { block } = setupEditor('Hello world'); + const textNode = block.firstChild as Text; + + setRange(textNode, 0, 5); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + const strong = block.querySelector('strong'); + + expect(strong?.textContent).toBe('Hello'); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(true); + }); + + it('unwraps existing bold text when toggled again', () => { + const { block } = setupEditor('Hello world'); + const strong = block.querySelector('strong'); + + expect(strong).not.toBeNull(); + + const textNode = strong?.firstChild as Text; + + setRange(textNode, 0, textNode.textContent?.length ?? 0); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + expect(block.querySelector('strong')).toBeNull(); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(false); + }); + + it('starts a collapsed bold segment when caret is not inside bold', () => { + const { block } = setupEditor('Hello'); + const textNode = block.firstChild as Text; + + setRange(textNode, 0); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + const strong = block.querySelector('strong'); + + expect(strong).not.toBeNull(); + expect(strong?.getAttribute('data-bold-collapsed-active')).toBe('true'); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(true); + expect(window.getSelection()?.anchorNode).toBe(strong?.firstChild ?? null); + }); + + it('exits collapsed bold when caret is inside bold content', () => { + const { block } = setupEditor('BOLD text'); + const strong = block.querySelector('strong'); + + expect(strong).not.toBeNull(); + + const textNode = strong?.firstChild as Text; + const length = textNode.textContent?.length ?? 0; + + setRange(textNode, length); + + const tool = new BoldInlineTool(); + const menu = tool.render() as PopoverItemDefaultBaseParams; + + menu.onActivate(menu); + + const strongAfter = block.querySelector('strong'); + + expect(strongAfter?.getAttribute('data-bold-collapsed-length')).toBe(length.toString()); + expect(strongAfter?.getAttribute('data-bold-collapsed-active')).toBeNull(); + expect(typeof menu.isActive === 'function' ? menu.isActive() : menu.isActive).toBe(false); + expect(window.getSelection()?.anchorNode?.parentNode).toBe(block); + }); +}); diff --git a/test/unit/components/modules/api/blocks.test.ts b/test/unit/components/modules/api/blocks.test.ts index 664be123..645cd81c 100644 --- a/test/unit/components/modules/api/blocks.test.ts +++ b/test/unit/components/modules/api/blocks.test.ts @@ -69,7 +69,6 @@ type BlockManagerMock = { getBlockById: ReturnType; getBlockIndex: ReturnType; getBlock: ReturnType; - swap: ReturnType; move: ReturnType; removeBlock: ReturnType; insert: ReturnType; @@ -97,7 +96,6 @@ const createBlockManagerMock = (initialBlocks: BlockStub[] = [ createBlockStub() getBlock: vi.fn((element: HTMLElement) => { return blockManager.blocks.find((block) => block.holder === element); }) as ReturnType, - swap: vi.fn() as ReturnType, move: vi.fn() as ReturnType, removeBlock: vi.fn((block?: BlockStub) => { if (!block) { @@ -437,22 +435,6 @@ describe('BlocksAPI', () => { }); describe('block ordering', () => { - it('delegates swap to BlockManager and logs deprecation notice', () => { - const { blocksApi, blockManager } = createBlocksApi(); - const logSpy = vi.spyOn(utils, 'log').mockImplementation(() => {}); - - blocksApi.swap(1, 2); - - expect(blockManager.swap).toHaveBeenCalledWith(1, 2); - expect(logSpy).toHaveBeenCalledWith( - '`blocks.swap()` method is deprecated and will be removed in the next major release. ' + - 'Use `block.move()` method instead', - 'info' - ); - - logSpy.mockRestore(); - }); - it('delegates move to BlockManager', () => { const { blocksApi, blockManager } = createBlocksApi(); diff --git a/test/unit/components/modules/blockEvents.test.ts b/test/unit/components/modules/blockEvents.test.ts index 7b67f518..b97fb7e0 100644 --- a/test/unit/components/modules/blockEvents.test.ts +++ b/test/unit/components/modules/blockEvents.test.ts @@ -16,6 +16,18 @@ import * as caretUtils from '../../../../src/components/utils/caret'; import * as blocksUtils from '../../../../src/components/utils/blocks'; import { keyCodes } from '../../../../src/components/utils'; +const KEY_CODE_TO_KEY_MAP: Record = { + [keyCodes.BACKSPACE]: 'Backspace', + [keyCodes.DELETE]: 'Delete', + [keyCodes.DOWN]: 'ArrowDown', + [keyCodes.ENTER]: 'Enter', + [keyCodes.LEFT]: 'ArrowLeft', + [keyCodes.RIGHT]: 'ArrowRight', + [keyCodes.SLASH]: '/', + [keyCodes.TAB]: 'Tab', + [keyCodes.UP]: 'ArrowUp', +}; + const createBlockEvents = (overrides: Partial = {}): BlockEvents => { const blockEvents = new BlockEvents({ config: {} as EditorConfig, @@ -109,10 +121,22 @@ const createBlockEvents = (overrides: Partial = {}): BlockEvents }; const createKeyboardEvent = (options: Partial): KeyboardEvent => { + let derivedKey: string; + + if (options.key !== undefined) { + derivedKey = options.key; + } else if (options.keyCode !== undefined) { + derivedKey = KEY_CODE_TO_KEY_MAP[options.keyCode] ?? String.fromCharCode(options.keyCode); + } else { + derivedKey = ''; + } + const derivedCode = options.code !== undefined ? options.code : derivedKey; + const derivedKeyCode = options.keyCode ?? 0; + return { - keyCode: 0, - key: '', - code: '', + keyCode: derivedKeyCode, + key: derivedKey, + code: derivedCode, ctrlKey: false, metaKey: false, altKey: false, @@ -1113,4 +1137,3 @@ describe('BlockEvents', () => { }); }); }); - diff --git a/test/unit/components/modules/blockManager.test.ts b/test/unit/components/modules/blockManager.test.ts index cffe8a12..208bbe2b 100644 --- a/test/unit/components/modules/blockManager.test.ts +++ b/test/unit/components/modules/blockManager.test.ts @@ -5,8 +5,9 @@ import BlockManager from '../../../../src/components/modules/blockManager'; import EventsDispatcher from '../../../../src/components/utils/events'; import type { EditorConfig } from '../../../../types'; import type { EditorModules } from '../../../../src/types-internal/editor-modules'; +import { BlockChanged } from '../../../../src/components/events'; import type { EditorEventMap } from '../../../../src/components/events'; -import type Block from '../../../../src/components/block'; +import Block from '../../../../src/components/block'; import { BlockAddedMutationType } from '../../../../types/events/block/BlockAdded'; import { BlockRemovedMutationType } from '../../../../types/events/block/BlockRemoved'; import { BlockMovedMutationType } from '../../../../types/events/block/BlockMoved'; @@ -40,6 +41,8 @@ const createBlockStub = (options: { tunes?: Record; } = {}): Block => { const holder = document.createElement('div'); + + holder.classList.add(Block.CSS.wrapper); const inputs = [ document.createElement('div') ]; const data = options.data ?? {}; @@ -392,4 +395,221 @@ describe('BlockManager', () => { expect(blocksStub.replace).not.toHaveBeenCalled(); expect(blockDidMutatedSpy).not.toHaveBeenCalled(); }); + + it('inserts default block at provided index and shifts current index when not focusing it', () => { + const existingBlock = createBlockStub({ id: 'existing' }); + const { blockManager, blocksStub } = createBlockManager({ + initialBlocks: [ existingBlock ], + }); + + blockManager.currentBlockIndex = 0; + const defaultBlock = createBlockStub({ id: 'default' }); + const composeBlockSpy = getComposeBlockSpy(blockManager).mockReturnValue(defaultBlock); + const blockDidMutatedSpy = getBlockDidMutatedSpy(blockManager); + + const result = blockManager.insertDefaultBlockAtIndex(0); + + expect(result).toBe(defaultBlock); + expect(blocksStub.blocks[0]).toBe(defaultBlock); + expect(blockManager.currentBlockIndex).toBe(1); + expect(composeBlockSpy).toHaveBeenCalledWith({ tool: 'paragraph' }); + expect(blockDidMutatedSpy).toHaveBeenCalledWith( + BlockAddedMutationType, + defaultBlock, + expect.objectContaining({ index: 0 }) + ); + }); + + it('focuses inserted default block when needToFocus is true', () => { + const { blockManager, blocksStub } = createBlockManager({ + initialBlocks: [createBlockStub({ id: 'first' }), createBlockStub({ id: 'second' })], + }); + + blockManager.currentBlockIndex = 1; + const defaultBlock = createBlockStub({ id: 'focus-me' }); + + getComposeBlockSpy(blockManager).mockReturnValue(defaultBlock); + + blockManager.insertDefaultBlockAtIndex(1, true); + + expect(blocksStub.blocks[1]).toBe(defaultBlock); + expect(blockManager.currentBlockIndex).toBe(1); + }); + + it('throws a descriptive error when default block tool is missing', () => { + const { blockManager } = createBlockManager(); + + (blockManager as unknown as { config: EditorConfig }).config.defaultBlock = undefined; + + expect(() => blockManager.insertDefaultBlockAtIndex(0)).toThrow('Could not insert default Block. Default block tool is not defined in the configuration.'); + }); + + it('removes only selected blocks and returns the first removed index', () => { + const blocks = [ + createBlockStub({ id: 'first' }), + createBlockStub({ id: 'second' }), + createBlockStub({ id: 'third' }), + ]; + + blocks[1].selected = true; + blocks[2].selected = true; + + const { blockManager } = createBlockManager({ + initialBlocks: blocks, + }); + + const removeSpy = vi + .spyOn(blockManager as unknown as { removeBlock: BlockManager['removeBlock'] }, 'removeBlock') + .mockResolvedValue(); + + const firstRemovedIndex = blockManager.removeSelectedBlocks(); + + expect(removeSpy).toHaveBeenNthCalledWith(1, blocks[2], false); + expect(removeSpy).toHaveBeenNthCalledWith(2, blocks[1], false); + expect(firstRemovedIndex).toBe(1); + }); + + it('splits the current block using caret fragment contents', () => { + const fragment = document.createDocumentFragment(); + + fragment.appendChild(document.createTextNode('Split content')); + + const caretStub = { + extractFragmentFromCaretPosition: vi.fn().mockReturnValue(fragment), + } as unknown as EditorModules['Caret']; + + const { blockManager } = createBlockManager({ + initialBlocks: [ createBlockStub({ id: 'origin' }) ], + editorOverrides: { + Caret: caretStub, + }, + }); + + const insertedBlock = createBlockStub({ id: 'split' }); + const insertSpy = vi.spyOn(blockManager as unknown as { insert: BlockManager['insert'] }, 'insert').mockReturnValue(insertedBlock); + + const result = blockManager.split(); + + expect(caretStub.extractFragmentFromCaretPosition).toHaveBeenCalledTimes(1); + expect(insertSpy).toHaveBeenCalledWith({ data: { text: 'Split content' } }); + expect(result).toBe(insertedBlock); + }); + + it('converts a block using the target tool conversion config', async () => { + const blockToConvert = createBlockStub({ id: 'paragraph', + name: 'paragraph' }); + + (blockToConvert.save as Mock).mockResolvedValue({ data: { text: 'Old' } }); + (blockToConvert.exportDataAsString as Mock).mockResolvedValue('

Converted

'); + + const replacingTool = { + name: 'header', + sanitizeConfig: { p: true }, + conversionConfig: { + import: (text: string, settings?: { level?: number }) => ({ + text: text.toUpperCase(), + level: settings?.level ?? 1, + }), + }, + settings: { level: 2 }, + }; + + const { blockManager } = createBlockManager({ + initialBlocks: [ blockToConvert ], + editorOverrides: { + Tools: { + blockTools: new Map([ [replacingTool.name, replacingTool] ]), + } as unknown as EditorModules['Tools'], + }, + }); + + const replacedBlock = createBlockStub({ id: 'header', + name: 'header' }); + const replaceSpy = vi.spyOn(blockManager as unknown as { replace: BlockManager['replace'] }, 'replace').mockReturnValue(replacedBlock); + + const result = await blockManager.convert(blockToConvert, 'header', { level: 4 }); + + expect(replaceSpy).toHaveBeenCalledWith(blockToConvert, 'header', { + text: '

CONVERTED

', + level: 4, + }); + expect(result).toBe(replacedBlock); + }); + + it('sets current block by a child node that belongs to the current editor instance', () => { + const blocks = [ + createBlockStub({ id: 'first' }), + createBlockStub({ id: 'second' }), + ]; + const { blockManager } = createBlockManager({ + initialBlocks: blocks, + }); + + const ui = (blockManager as unknown as { Editor: EditorModules }).Editor.UI; + + ui.nodes.wrapper.classList.add(ui.CSS.editorWrapper); + ui.nodes.wrapper.appendChild(blocks[0].holder); + ui.nodes.wrapper.appendChild(blocks[1].holder); + document.body.appendChild(ui.nodes.wrapper); + + const childNode = document.createElement('span'); + + blocks[1].holder.appendChild(childNode); + + const current = blockManager.setCurrentBlockByChildNode(childNode); + + expect(current).toBe(blocks[1]); + expect(blockManager.currentBlockIndex).toBe(1); + expect(blocks[1].updateCurrentInput).toHaveBeenCalledTimes(1); + + const alienWrapper = document.createElement('div'); + + alienWrapper.classList.add(ui.CSS.editorWrapper); + const alienBlock = createBlockStub({ id: 'alien' }); + + alienWrapper.appendChild(alienBlock.holder); + const alienChild = document.createElement('span'); + + alienBlock.holder.appendChild(alienChild); + + expect(blockManager.setCurrentBlockByChildNode(alienChild)).toBeUndefined(); + + ui.nodes.wrapper.remove(); + }); + + it('emits enumerable events when blockDidMutated is invoked', () => { + const block = createBlockStub({ id: 'block-1' }); + const { blockManager } = createBlockManager({ + initialBlocks: [ block ], + }); + + const emitSpy = vi.spyOn( + (blockManager as unknown as { eventsDispatcher: EventsDispatcher }).eventsDispatcher, + 'emit' + ); + + const detail = { index: 0 }; + const result = (blockManager as unknown as { blockDidMutated: BlockDidMutated }).blockDidMutated( + BlockAddedMutationType, + block, + detail + ); + + expect(result).toBe(block); + expect(emitSpy).toHaveBeenCalledTimes(1); + + const [eventName, payload] = emitSpy.mock.calls[0] as [string, { event: CustomEvent }]; + + expect(eventName).toBe(BlockChanged); + expect(payload.event).toBeInstanceOf(CustomEvent); + expect(payload.event.type).toBe(BlockAddedMutationType); + expect(payload.event.detail).toEqual(expect.objectContaining(detail)); + expect(payload.event.detail.target).toEqual(expect.objectContaining({ + id: block.id, + name: block.name, + })); + + expect(Object.prototype.propertyIsEnumerable.call(payload.event, 'type')).toBe(true); + expect(Object.prototype.propertyIsEnumerable.call(payload.event, 'detail')).toBe(true); + }); }); diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index 06baafb6..f10027cf 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -34,14 +34,6 @@ export interface Blocks { */ delete(index?: number): void; - /** - * Swaps two Blocks - * @param {number} fromIndex - block to swap - * @param {number} toIndex - block to swap with - * @deprecated — use 'move' instead - */ - swap(fromIndex: number, toIndex: number): void; - /** * Moves a block to a new index * @param {number} toIndex - index where the block is moved to diff --git a/types/tools/tool-settings.d.ts b/types/tools/tool-settings.d.ts index 1658ee9a..3dfa5e0f 100644 --- a/types/tools/tool-settings.d.ts +++ b/types/tools/tool-settings.d.ts @@ -68,18 +68,6 @@ export interface ExternalToolSettings { toolbox?: ToolboxConfig | false; } -/** - * Tool's tunes configuration. - * @deprecated use {@link MenuConfig} type instead - */ -export type TunesMenuConfig = MenuConfig; - -/** - * Single Tunes Menu Config item - * @deprecated use {@link MenuConfigItem} type instead - */ -export type TunesMenuConfigItem = MenuConfigItem; - /** * For internal Tools 'class' property is optional */ From 181e73f3c99561d613fe652d5c3208c2740b92ee Mon Sep 17 00:00:00 2001 From: JackUait Date: Sun, 16 Nov 2025 07:32:01 +0300 Subject: [PATCH 3/7] test: add missing test coverage --- .cursor/rules/fix-problems.mdc | 1 + .windsurf/rules/do-not-modify-configs.mdc | 23 + .windsurf/rules/fix-problems.mdc | 24 + .../rules/src/frontend/accessibility.mdc | 119 ++++ .../rules/src/frontend/code-style-eslint.mdc | 27 + .../src/frontend/fix-typescript-errors.mdc | 22 + .../src/frontend/frontend-simplicity.mdc | 69 ++ .../rules/src/frontend/lint-fix-policy.mdc | 24 + .windsurf/rules/test/e2e-best-practices.mdc | 37 + .yarn/install-state.gz | Bin 852421 -> 867165 bytes package.json | 5 +- src/components/polyfills.ts | 2 +- src/components/utils/notifier.ts | 4 +- src/components/utils/promise-queue.ts | 33 +- src/components/utils/resolve-aliases.ts | 10 +- .../block-tunes/block-tune-delete.test.ts | 70 ++ test/unit/components/block/block.test.ts | 355 ++++++++++ .../events/FakeCursorAboutToBeToggled.test.ts | 26 + .../components/events/block-settings.test.ts | 33 + test/unit/components/flipper.test.ts | 394 +++++++++++ test/unit/components/i18n/index.test.ts | 80 +++ .../inline-tools/inline-tool-convert.test.ts | 239 +++++++ .../inline-tools/inline-tool-link.test.ts | 293 ++++++++ test/unit/components/module-base.test.ts | 136 ++++ .../unit/components/modules/api/caret.test.ts | 218 ++++++ .../components/modules/api/events.test.ts | 78 +++ .../modules/api/inlineToolbar.test.ts | 67 ++ .../components/modules/api/listeners.test.ts | 89 +++ .../components/modules/api/readonly.test.ts | 92 +++ .../components/modules/api/sanitizer.test.ts | 48 ++ .../components/modules/api/selection.test.ts | 88 +++ .../unit/components/modules/api/tools.test.ts | 58 ++ .../components/modules/api/tooltip.test.ts | 93 +++ test/unit/components/modules/api/ui.test.ts | 91 +++ .../modules/toolbar/blockSettings.test.ts | 653 ++++++++++++++++++ test/unit/components/tools/factory.test.ts | 321 +++++++++ test/unit/components/utils/hint.test.ts | 68 ++ test/unit/components/utils/keyboard.test.ts | 72 ++ test/unit/components/utils/notifier.test.ts | 186 +++++ .../components/utils/popover-header.test.ts | 127 ++++ .../components/utils/resolve-aliases.test.ts | 63 ++ test/unit/components/utils/tools.test.ts | 60 ++ test/unit/tools/block-tune-adapter.test.ts | 156 +++++ test/unit/tools/stub.test.ts | 115 +++ test/unit/utils/api.test.ts | 85 +++ test/unit/utils/popover-item-html.test.ts | 157 +++++ .../unit/utils/popover-item-separator.test.ts | 35 + .../unit/utils/popover-states-history.test.ts | 82 +++ test/unit/utils/promise-queue.test.ts | 61 ++ yarn.lock | 172 ++++- 50 files changed, 5335 insertions(+), 26 deletions(-) create mode 100644 .windsurf/rules/do-not-modify-configs.mdc create mode 100644 .windsurf/rules/fix-problems.mdc create mode 100644 .windsurf/rules/src/frontend/accessibility.mdc create mode 100644 .windsurf/rules/src/frontend/code-style-eslint.mdc create mode 100644 .windsurf/rules/src/frontend/fix-typescript-errors.mdc create mode 100644 .windsurf/rules/src/frontend/frontend-simplicity.mdc create mode 100644 .windsurf/rules/src/frontend/lint-fix-policy.mdc create mode 100644 .windsurf/rules/test/e2e-best-practices.mdc create mode 100644 test/unit/components/block-tunes/block-tune-delete.test.ts create mode 100644 test/unit/components/block/block.test.ts create mode 100644 test/unit/components/events/FakeCursorAboutToBeToggled.test.ts create mode 100644 test/unit/components/events/block-settings.test.ts create mode 100644 test/unit/components/flipper.test.ts create mode 100644 test/unit/components/i18n/index.test.ts create mode 100644 test/unit/components/inline-tools/inline-tool-convert.test.ts create mode 100644 test/unit/components/inline-tools/inline-tool-link.test.ts create mode 100644 test/unit/components/module-base.test.ts create mode 100644 test/unit/components/modules/api/caret.test.ts create mode 100644 test/unit/components/modules/api/events.test.ts create mode 100644 test/unit/components/modules/api/inlineToolbar.test.ts create mode 100644 test/unit/components/modules/api/listeners.test.ts create mode 100644 test/unit/components/modules/api/readonly.test.ts create mode 100644 test/unit/components/modules/api/sanitizer.test.ts create mode 100644 test/unit/components/modules/api/selection.test.ts create mode 100644 test/unit/components/modules/api/tools.test.ts create mode 100644 test/unit/components/modules/api/tooltip.test.ts create mode 100644 test/unit/components/modules/api/ui.test.ts create mode 100644 test/unit/components/modules/toolbar/blockSettings.test.ts create mode 100644 test/unit/components/tools/factory.test.ts create mode 100644 test/unit/components/utils/hint.test.ts create mode 100644 test/unit/components/utils/keyboard.test.ts create mode 100644 test/unit/components/utils/notifier.test.ts create mode 100644 test/unit/components/utils/popover-header.test.ts create mode 100644 test/unit/components/utils/resolve-aliases.test.ts create mode 100644 test/unit/components/utils/tools.test.ts create mode 100644 test/unit/tools/block-tune-adapter.test.ts create mode 100644 test/unit/tools/stub.test.ts create mode 100644 test/unit/utils/api.test.ts create mode 100644 test/unit/utils/popover-item-html.test.ts create mode 100644 test/unit/utils/popover-item-separator.test.ts create mode 100644 test/unit/utils/popover-states-history.test.ts create mode 100644 test/unit/utils/promise-queue.test.ts diff --git a/.cursor/rules/fix-problems.mdc b/.cursor/rules/fix-problems.mdc index 2964987d..c7518773 100644 --- a/.cursor/rules/fix-problems.mdc +++ b/.cursor/rules/fix-problems.mdc @@ -12,6 +12,7 @@ VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript e - **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it. - **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines. - **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues. +- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely ## When to Apply - During any code editing, reviewing, or generation task. diff --git a/.windsurf/rules/do-not-modify-configs.mdc b/.windsurf/rules/do-not-modify-configs.mdc new file mode 100644 index 00000000..bc24d21d --- /dev/null +++ b/.windsurf/rules/do-not-modify-configs.mdc @@ -0,0 +1,23 @@ +--- +alwaysApply: true +--- + +# Rule: DO NOT MODIFY configuration files unless explicitly instructed + +## Description +You MUST **never modify any configuration files** (such as `vite.config.ts`, `tsconfig.json`, `.eslintrc`, `package.json`, `.env`, etc.) **unless explicitly told to do so** in the current request or accompanying instructions. + +## Examples + +✅ **Allowed** +- Editing TypeScript source files, tests, or component code. +- Updating imports, logic, or styles within non-config files. +- Adding configuration changes **only when explicitly requested** (e.g., “Add a new alias in `vite.config.ts`”). + +❌ **Not Allowed** +- Modifying or creating any config files without explicit instruction. +- Automatically adding dependencies or changing build/test settings. +- Altering environment variables or global project settings without being told to. + +## Enforcement +If you believe a configuration change might be required, **ask for confirmation first** before proceeding. diff --git a/.windsurf/rules/fix-problems.mdc b/.windsurf/rules/fix-problems.mdc new file mode 100644 index 00000000..561e6f14 --- /dev/null +++ b/.windsurf/rules/fix-problems.mdc @@ -0,0 +1,24 @@ +--- +alwaysApply: true +--- + +# Fix Problems Policy + +## Core Principle +VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript errors, linting issues, runtime bugs, accessibility violations, or performance problems—you or any other problem MUST find a proper way to fix it. Do NOT silence, suppress, or avoid the problem using workarounds like `// @ts-ignore`, `any` types, or ignoring linter warnings. + +## Preferred Approaches +- **Refactor for correctness**: Resolve issues by improving the code structure, using precise types, type guards, proper error handling, and best practices. +- **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it. +- **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines. +- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues. + +## When to Apply +- During any code editing, reviewing, or generation task. +- Proactively scan for and fix problems in affected files using available tools (e.g., read_lints, grep, codebase_search). +- If a problem persists after reasonable efforts, document it clearly and suggest next steps rather than suppressing it. +- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely + +## Notes +- This policy promotes robust, high-quality code that is easier to maintain and less prone to future issues. +- If unsure how to fix a problem, use tools to gather more information or break it into smaller, solvable parts rather than bypassing it. diff --git a/.windsurf/rules/src/frontend/accessibility.mdc b/.windsurf/rules/src/frontend/accessibility.mdc new file mode 100644 index 00000000..085ff843 --- /dev/null +++ b/.windsurf/rules/src/frontend/accessibility.mdc @@ -0,0 +1,119 @@ +--- +alwaysApply: true +description: Enforce accessibility best practices so all users can use the application +--- + +### Accessibility guidance (must follow) + +- Semantics first + - Prefer semantic HTML (`button`, `a`, `nav`, `main`, `header`, `footer`, `ul/ol/li`, `table/th/td`) over generic `div`/`span`. + - Use `button` for actions and `a`/`Link` for navigation. Do not use click handlers on non-interactive elements. If unavoidable, add `role="button"`, `tabIndex={0}`, and keyboard handlers for Enter/Space. + +- Keyboard support + - All interactive controls must be reachable via Tab and operable via keyboard. + - Do not remove focus outlines. If customizing, ensure visible `:focus-visible` styles with sufficient contrast. + - Preserve a logical tab order; avoid `tabIndex` > 0. + +- Focus management + - On opening modals/drawers/popovers: move focus inside, trap focus, and restore focus to the trigger on close. + - Provide a skip link to main content (e.g., `href="#main"`) and landmark roles (`
`, `