@simplysm/core-browser 13.0.0-beta.45 → 13.0.0-beta.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/extensions/element-ext.ts"],
4
- "sourcesContent": ["import { isFocusable } from \"tabbable\";\nimport { TimeoutError } from \"@simplysm/core-common\";\n\n/**\n * \uC694\uC18C bounds \uC815\uBCF4 \uD0C0\uC785\n */\nexport interface ElementBounds {\n /** \uCE21\uC815 \uB300\uC0C1 \uC694\uC18C */\n target: Element;\n /** \uBDF0\uD3EC\uD2B8 \uAE30\uC900 \uC0C1\uB2E8 \uC704\uCE58 */\n top: number;\n /** \uBDF0\uD3EC\uD2B8 \uAE30\uC900 \uC67C\uCABD \uC704\uCE58 */\n left: number;\n /** \uC694\uC18C \uB108\uBE44 */\n width: number;\n /** \uC694\uC18C \uB192\uC774 */\n height: number;\n}\n\ndeclare global {\n interface Element {\n /**\n * \uC140\uB809\uD130\uB85C \uD558\uC704 \uC694\uC18C \uC804\uCCB4 \uAC80\uC0C9\n *\n * @param selector - CSS \uC140\uB809\uD130\n * @returns \uB9E4\uCE6D\uB41C \uC694\uC18C \uBC30\uC5F4 (\uBE48 \uC140\uB809\uD130\uB294 \uBE48 \uBC30\uC5F4 \uBC18\uD658)\n */\n findAll<T extends Element = Element>(selector: string): T[];\n\n /**\n * \uC140\uB809\uD130\uB85C \uCCAB \uBC88\uC9F8 \uB9E4\uCE6D \uC694\uC18C \uAC80\uC0C9\n *\n * @param selector - CSS \uC140\uB809\uD130\n * @returns \uCCAB \uBC88\uC9F8 \uB9E4\uCE6D \uC694\uC18C \uB610\uB294 undefined (\uBE48 \uC140\uB809\uD130\uB294 undefined \uBC18\uD658)\n */\n findFirst<T extends Element = Element>(selector: string): T | undefined;\n\n /**\n * \uC694\uC18C\uB97C \uCCAB \uBC88\uC9F8 \uC790\uC2DD\uC73C\uB85C \uC0BD\uC785\n *\n * @param child - \uC0BD\uC785\uD560 \uC790\uC2DD \uC694\uC18C\n * @returns \uC0BD\uC785\uB41C \uC790\uC2DD \uC694\uC18C\n */\n prependChild<T extends Element>(child: T): T;\n\n /**\n * \uBAA8\uB4E0 \uBD80\uBAA8 \uC694\uC18C \uBAA9\uB85D \uBC18\uD658 (\uAC00\uAE4C\uC6B4 \uC21C\uC11C)\n *\n * @returns \uBD80\uBAA8 \uC694\uC18C \uBC30\uC5F4 (\uAC00\uAE4C\uC6B4 \uBD80\uBAA8\uBD80\uD130 \uC21C\uC11C\uB300\uB85C)\n */\n getParents(): Element[];\n\n /**\n * \uBD80\uBAA8 \uC911 \uCCAB \uBC88\uC9F8 \uD3EC\uCEE4\uC2A4 \uAC00\uB2A5 \uC694\uC18C \uAC80\uC0C9 (tabbable \uC0AC\uC6A9)\n *\n * @returns \uD3EC\uCEE4\uC2A4 \uAC00\uB2A5\uD55C \uCCAB \uBC88\uC9F8 \uBD80\uBAA8 \uC694\uC18C \uB610\uB294 undefined\n */\n findFocusableParent(): HTMLElement | undefined;\n\n /**\n * \uC790\uC2DD \uC911 \uCCAB \uBC88\uC9F8 \uD3EC\uCEE4\uC2A4 \uAC00\uB2A5 \uC694\uC18C \uAC80\uC0C9 (tabbable \uC0AC\uC6A9)\n *\n * @returns \uD3EC\uCEE4\uC2A4 \uAC00\uB2A5\uD55C \uCCAB \uBC88\uC9F8 \uC790\uC2DD \uC694\uC18C \uB610\uB294 undefined\n */\n findFirstFocusableChild(): HTMLElement | undefined;\n\n /**\n * \uC694\uC18C\uAC00 offset \uAE30\uC900 \uC694\uC18C\uC778\uC9C0 \uD655\uC778 (position: relative/absolute/fixed/sticky)\n *\n * @returns position \uC18D\uC131\uC774 relative, absolute, fixed, sticky \uC911 \uD558\uB098\uBA74 true\n */\n isOffsetElement(): boolean;\n\n /**\n * \uC694\uC18C\uAC00 \uD654\uBA74\uC5D0 \uBCF4\uC774\uB294\uC9C0 \uD655\uC778\n *\n * @remarks\n * clientRects \uC874\uC7AC \uC5EC\uBD80, visibility: hidden, opacity: 0 \uC5EC\uBD80\uB97C \uD655\uC778\uD55C\uB2E4.\n *\n * @returns \uC694\uC18C\uAC00 \uD654\uBA74\uC5D0 \uBCF4\uC774\uBA74 true\n */\n isVisible(): boolean;\n }\n}\n\nElement.prototype.findAll = function <T extends Element = Element>(selector: string): T[] {\n const trimmed = selector.trim();\n if (trimmed === \"\") return [];\n return Array.from(this.querySelectorAll<T>(trimmed));\n};\n\nElement.prototype.findFirst = function <T extends Element = Element>(selector: string): T | undefined {\n const trimmed = selector.trim();\n if (trimmed === \"\") return undefined;\n return this.querySelector<T>(trimmed) ?? undefined;\n};\n\nElement.prototype.prependChild = function <T extends Element>(child: T): T {\n return this.insertBefore(child, this.firstElementChild);\n};\n\nElement.prototype.getParents = function (): Element[] {\n const result: Element[] = [];\n let cursor = this.parentNode;\n while (cursor !== null && cursor instanceof Element) {\n result.push(cursor);\n cursor = cursor.parentNode;\n }\n return result;\n};\n\nElement.prototype.findFocusableParent = function (): HTMLElement | undefined {\n let parentEl = this.parentElement;\n while (parentEl !== null) {\n if (isFocusable(parentEl)) {\n return parentEl;\n }\n parentEl = parentEl.parentElement;\n }\n return undefined;\n};\n\nElement.prototype.findFirstFocusableChild = function (): HTMLElement | undefined {\n const walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT);\n let node = walker.nextNode();\n while (node !== null) {\n if (node instanceof HTMLElement && isFocusable(node)) {\n return node;\n }\n node = walker.nextNode();\n }\n return undefined;\n};\n\nElement.prototype.isOffsetElement = function (): boolean {\n return [\"relative\", \"absolute\", \"fixed\", \"sticky\"].includes(getComputedStyle(this).position);\n};\n\nElement.prototype.isVisible = function (): boolean {\n const style = getComputedStyle(this);\n return this.getClientRects().length > 0 && style.visibility !== \"hidden\" && style.opacity !== \"0\";\n};\n\n// ============================================================================\n// \uC815\uC801 \uD568\uC218 (\uC774\uBCA4\uD2B8 \uD578\uB4E4\uB7EC\uC6A9 \uB610\uB294 \uC5EC\uB7EC \uC694\uC18C \uB300\uC0C1)\n// ============================================================================\n\n/**\n * \uC694\uC18C \uB0B4\uC6A9\uC744 \uD074\uB9BD\uBCF4\uB4DC\uC5D0 \uBCF5\uC0AC (copy \uC774\uBCA4\uD2B8 \uD578\uB4E4\uB7EC\uC5D0\uC11C \uC0AC\uC6A9)\n *\n * @param event - copy \uC774\uBCA4\uD2B8 \uAC1D\uCCB4\n */\nexport function copyElement(event: ClipboardEvent): void {\n const clipboardData = event.clipboardData;\n const target = event.target;\n if (clipboardData == null || !(target instanceof Element)) return;\n\n const firstInputEl = target.querySelector<HTMLInputElement | HTMLTextAreaElement>(\"input, textarea\");\n if (firstInputEl != null) {\n clipboardData.setData(\"text/plain\", firstInputEl.value);\n event.preventDefault();\n }\n}\n\n/**\n * \uD074\uB9BD\uBCF4\uB4DC \uB0B4\uC6A9\uC744 \uC694\uC18C\uC5D0 \uBD99\uC5EC\uB123\uAE30 (paste \uC774\uBCA4\uD2B8 \uD578\uB4E4\uB7EC\uC5D0\uC11C \uC0AC\uC6A9)\n *\n * @remarks\n * \uB300\uC0C1 \uC694\uC18C \uB0B4\uC758 \uCCAB \uBC88\uC9F8 input/textarea\uB97C \uCC3E\uC544 \uC804\uCCB4 \uAC12\uC744 \uD074\uB9BD\uBCF4\uB4DC \uB0B4\uC6A9\uC73C\uB85C \uAD50\uCCB4\uD55C\uB2E4.\n * \uCEE4\uC11C \uC704\uCE58\uB098 \uC120\uD0DD \uC601\uC5ED\uC744 \uACE0\uB824\uD558\uC9C0 \uC54A\uB294\uB2E4.\n *\n * @param event - paste \uC774\uBCA4\uD2B8 \uAC1D\uCCB4\n */\nexport function pasteToElement(event: ClipboardEvent): void {\n const clipboardData = event.clipboardData;\n const target = event.target;\n if (clipboardData == null || !(target instanceof Element)) return;\n\n const contentText = clipboardData.getData(\"text/plain\");\n\n const firstInputEl = target.findFirst<HTMLInputElement | HTMLTextAreaElement>(\"input, textarea\");\n if (firstInputEl !== undefined) {\n firstInputEl.value = contentText;\n firstInputEl.dispatchEvent(new Event(\"input\", { bubbles: true }));\n event.preventDefault();\n }\n}\n\n/**\n * IntersectionObserver\uB97C \uC0AC\uC6A9\uD558\uC5EC \uC694\uC18C\uB4E4\uC758 bounds \uC815\uBCF4 \uC870\uD68C\n *\n * @param els - \uB300\uC0C1 \uC694\uC18C \uBC30\uC5F4\n * @param timeout - \uD0C0\uC784\uC544\uC6C3 (\uBC00\uB9AC\uCD08, \uAE30\uBCF8: 5000)\n * @throws {TimeoutError} \uD0C0\uC784\uC544\uC6C3 \uC2DC\uAC04 \uB0B4\uC5D0 \uC751\uB2F5\uC774 \uC5C6\uC744 \uACBD\uC6B0\n */\nexport async function getBounds(els: Element[], timeout: number = 5000): Promise<ElementBounds[]> {\n // \uC911\uBCF5 \uC81C\uAC70 \uBC0F \uC785\uB825 \uC21C\uC11C\uB300\uB85C \uACB0\uACFC\uB97C \uC815\uB82C\uD558\uAE30 \uC704\uD55C \uC778\uB371\uC2A4 \uB9F5\n const indexMap = new Map(els.map((el, i) => [el, i] as const));\n if (indexMap.size === 0) {\n return [];\n }\n\n // \uC815\uB82C \uC131\uB2A5 \uCD5C\uC801\uD654\uB97C \uC704\uD55C \uC778\uB371\uC2A4 \uB9F5\n const sortIndexMap = new Map(els.map((el, i) => [el, i] as const));\n\n let observer: IntersectionObserver | undefined;\n\n try {\n return await Promise.race([\n new Promise<ElementBounds[]>((resolve) => {\n const results: ElementBounds[] = [];\n\n observer = new IntersectionObserver((entries) => {\n for (const entry of entries) {\n const target = entry.target;\n if (indexMap.has(target)) {\n indexMap.delete(target);\n results.push({\n target,\n top: entry.boundingClientRect.top,\n left: entry.boundingClientRect.left,\n width: entry.boundingClientRect.width,\n height: entry.boundingClientRect.height,\n });\n }\n }\n\n if (indexMap.size === 0) {\n observer?.disconnect();\n // \uC785\uB825 \uC21C\uC11C\uB300\uB85C \uC815\uB82C\n resolve(results.sort((a, b) => sortIndexMap.get(a.target)! - sortIndexMap.get(b.target)!));\n }\n });\n\n for (const el of indexMap.keys()) {\n observer.observe(el);\n }\n }),\n new Promise<ElementBounds[]>((_, reject) =>\n setTimeout(() => reject(new TimeoutError(undefined, `${timeout}ms \uCD08\uACFC`)), timeout),\n ),\n ]);\n } finally {\n observer?.disconnect();\n }\n}\n"],
5
4
  "mappings": "AAAA,SAAS,mBAAmB;AAC5B,SAAS,oBAAoB;AAoF7B,QAAQ,UAAU,UAAU,SAAuC,UAAuB;AACxF,QAAM,UAAU,SAAS,KAAK;AAC9B,MAAI,YAAY,GAAI,QAAO,CAAC;AAC5B,SAAO,MAAM,KAAK,KAAK,iBAAoB,OAAO,CAAC;AACrD;AAEA,QAAQ,UAAU,YAAY,SAAuC,UAAiC;AACpG,QAAM,UAAU,SAAS,KAAK;AAC9B,MAAI,YAAY,GAAI,QAAO;AAC3B,SAAO,KAAK,cAAiB,OAAO,KAAK;AAC3C;AAEA,QAAQ,UAAU,eAAe,SAA6B,OAAa;AACzE,SAAO,KAAK,aAAa,OAAO,KAAK,iBAAiB;AACxD;AAEA,QAAQ,UAAU,aAAa,WAAuB;AACpD,QAAM,SAAoB,CAAC;AAC3B,MAAI,SAAS,KAAK;AAClB,SAAO,WAAW,QAAQ,kBAAkB,SAAS;AACnD,WAAO,KAAK,MAAM;AAClB,aAAS,OAAO;AAAA,EAClB;AACA,SAAO;AACT;AAEA,QAAQ,UAAU,sBAAsB,WAAqC;AAC3E,MAAI,WAAW,KAAK;AACpB,SAAO,aAAa,MAAM;AACxB,QAAI,YAAY,QAAQ,GAAG;AACzB,aAAO;AAAA,IACT;AACA,eAAW,SAAS;AAAA,EACtB;AACA,SAAO;AACT;AAEA,QAAQ,UAAU,0BAA0B,WAAqC;AAC/E,QAAM,SAAS,SAAS,iBAAiB,MAAM,WAAW,YAAY;AACtE,MAAI,OAAO,OAAO,SAAS;AAC3B,SAAO,SAAS,MAAM;AACpB,QAAI,gBAAgB,eAAe,YAAY,IAAI,GAAG;AACpD,aAAO;AAAA,IACT;AACA,WAAO,OAAO,SAAS;AAAA,EACzB;AACA,SAAO;AACT;AAEA,QAAQ,UAAU,kBAAkB,WAAqB;AACvD,SAAO,CAAC,YAAY,YAAY,SAAS,QAAQ,EAAE,SAAS,iBAAiB,IAAI,EAAE,QAAQ;AAC7F;AAEA,QAAQ,UAAU,YAAY,WAAqB;AACjD,QAAM,QAAQ,iBAAiB,IAAI;AACnC,SAAO,KAAK,eAAe,EAAE,SAAS,KAAK,MAAM,eAAe,YAAY,MAAM,YAAY;AAChG;AAWO,SAAS,YAAY,OAA6B;AACvD,QAAM,gBAAgB,MAAM;AAC5B,QAAM,SAAS,MAAM;AACrB,MAAI,iBAAiB,QAAQ,EAAE,kBAAkB,SAAU;AAE3D,QAAM,eAAe,OAAO,cAAsD,iBAAiB;AACnG,MAAI,gBAAgB,MAAM;AACxB,kBAAc,QAAQ,cAAc,aAAa,KAAK;AACtD,UAAM,eAAe;AAAA,EACvB;AACF;AAWO,SAAS,eAAe,OAA6B;AAC1D,QAAM,gBAAgB,MAAM;AAC5B,QAAM,SAAS,MAAM;AACrB,MAAI,iBAAiB,QAAQ,EAAE,kBAAkB,SAAU;AAE3D,QAAM,cAAc,cAAc,QAAQ,YAAY;AAEtD,QAAM,eAAe,OAAO,UAAkD,iBAAiB;AAC/F,MAAI,iBAAiB,QAAW;AAC9B,iBAAa,QAAQ;AACrB,iBAAa,cAAc,IAAI,MAAM,SAAS,EAAE,SAAS,KAAK,CAAC,CAAC;AAChE,UAAM,eAAe;AAAA,EACvB;AACF;AASA,eAAsB,UAAU,KAAgB,UAAkB,KAAgC;AAEhG,QAAM,WAAW,IAAI,IAAI,IAAI,IAAI,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAU,CAAC;AAC7D,MAAI,SAAS,SAAS,GAAG;AACvB,WAAO,CAAC;AAAA,EACV;AAGA,QAAM,eAAe,IAAI,IAAI,IAAI,IAAI,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAU,CAAC;AAEjE,MAAI;AAEJ,MAAI;AACF,WAAO,MAAM,QAAQ,KAAK;AAAA,MACxB,IAAI,QAAyB,CAAC,YAAY;AACxC,cAAM,UAA2B,CAAC;AAElC,mBAAW,IAAI,qBAAqB,CAAC,YAAY;AAC/C,qBAAW,SAAS,SAAS;AAC3B,kBAAM,SAAS,MAAM;AACrB,gBAAI,SAAS,IAAI,MAAM,GAAG;AACxB,uBAAS,OAAO,MAAM;AACtB,sBAAQ,KAAK;AAAA,gBACX;AAAA,gBACA,KAAK,MAAM,mBAAmB;AAAA,gBAC9B,MAAM,MAAM,mBAAmB;AAAA,gBAC/B,OAAO,MAAM,mBAAmB;AAAA,gBAChC,QAAQ,MAAM,mBAAmB;AAAA,cACnC,CAAC;AAAA,YACH;AAAA,UACF;AAEA,cAAI,SAAS,SAAS,GAAG;AACvB,iDAAU;AAEV,oBAAQ,QAAQ,KAAK,CAAC,GAAG,MAAM,aAAa,IAAI,EAAE,MAAM,IAAK,aAAa,IAAI,EAAE,MAAM,CAAE,CAAC;AAAA,UAC3F;AAAA,QACF,CAAC;AAED,mBAAW,MAAM,SAAS,KAAK,GAAG;AAChC,mBAAS,QAAQ,EAAE;AAAA,QACrB;AAAA,MACF,CAAC;AAAA,MACD,IAAI;AAAA,QAAyB,CAAC,GAAG,WAC/B,WAAW,MAAM,OAAO,IAAI,aAAa,QAAW,GAAG,OAAO,iBAAO,CAAC,GAAG,OAAO;AAAA,MAClF;AAAA,IACF,CAAC;AAAA,EACH,UAAE;AACA,yCAAU;AAAA,EACZ;AACF;",
6
5
  "names": []
7
6
  }
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/extensions/html-element-ext.ts"],
4
- "sourcesContent": ["import { ArgumentError } from \"@simplysm/core-common\";\n\ndeclare global {\n interface HTMLElement {\n /**\n * \uAC15\uC81C \uB9AC\uD398\uC778\uD2B8 (reflow \uD2B8\uB9AC\uAC70)\n */\n repaint(): void;\n\n /**\n * \uBD80\uBAA8 \uC694\uC18C \uAE30\uC900 \uC0C1\uB300 \uC704\uCE58 \uACC4\uC0B0 (CSS \uD3EC\uC9C0\uC154\uB2DD\uC6A9)\n *\n * @remarks\n * \uC774 \uD568\uC218\uB294 \uC694\uC18C\uC758 \uC704\uCE58\uB97C \uBD80\uBAA8 \uC694\uC18C \uAE30\uC900\uC73C\uB85C \uACC4\uC0B0\uD558\uB418, `window.scrollX/Y`\uB97C \uD3EC\uD568\uD558\uC5EC\n * CSS `top`/`left` \uC18D\uC131\uC5D0 \uC9C1\uC811 \uC0AC\uC6A9\uD560 \uC218 \uC788\uB294 \uBB38\uC11C \uAE30\uC900 \uC88C\uD45C\uB97C \uBC18\uD658\uD55C\uB2E4.\n *\n * \uC8FC\uC694 \uC0AC\uC6A9 \uC0AC\uB840:\n * - \uB4DC\uB86D\uB2E4\uC6B4, \uD31D\uC5C5 \uB4F1\uC744 `document.body`\uC5D0 append \uD6C4 \uC704\uCE58 \uC9C0\uC815\n * - \uC2A4\uD06C\uB864\uB41C \uD398\uC774\uC9C0\uC5D0\uC11C\uB3C4 \uC62C\uBC14\uB974\uAC8C \uB3D9\uC791\n *\n * \uACC4\uC0B0\uC5D0 \uD3EC\uD568\uB418\uB294 \uC694\uC18C:\n * - \uBDF0\uD3EC\uD2B8 \uAE30\uC900 \uC704\uCE58 (getBoundingClientRect)\n * - \uBB38\uC11C \uC2A4\uD06C\uB864 \uC704\uCE58 (window.scrollX/Y)\n * - \uBD80\uBAA8 \uC694\uC18C \uB0B4\uBD80 \uC2A4\uD06C\uB864 (parentEl.scrollTop/Left)\n * - \uC911\uAC04 \uC694\uC18C\uB4E4\uC758 border \uB450\uAED8\n * - CSS transform \uBCC0\uD658\n *\n * @param parent - \uAE30\uC900\uC774 \uB420 \uBD80\uBAA8 \uC694\uC18C \uB610\uB294 \uC140\uB809\uD130 (\uC608: document.body, \".container\")\n * @returns CSS top/left \uC18D\uC131\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC788\uB294 \uC88C\uD45C\n * @throws {ArgumentError} \uBD80\uBAA8 \uC694\uC18C\uB97C \uCC3E\uC744 \uC218 \uC5C6\uB294 \uACBD\uC6B0\n */\n getRelativeOffset(parent: HTMLElement | string): { top: number; left: number };\n\n /**\n * \uB300\uC0C1\uC774 offset \uC601\uC5ED(\uACE0\uC815 \uD5E4\uB354/\uACE0\uC815 \uC5F4 \uB4F1)\uC5D0 \uAC00\uB824\uC9C4 \uACBD\uC6B0, \uBCF4\uC774\uB3C4\uB85D \uC2A4\uD06C\uB864\n *\n * @remarks\n * \uC774 \uD568\uC218\uB294 \uB300\uC0C1\uC774 \uC2A4\uD06C\uB864 \uC601\uC5ED\uC758 \uC704\uCABD/\uC67C\uCABD \uACBD\uACC4\uB97C \uBC97\uC5B4\uB09C \uACBD\uC6B0\uB9CC \uCC98\uB9AC\uD55C\uB2E4.\n * \uC544\uB798\uCABD/\uC624\uB978\uCABD\uC73C\uB85C \uC2A4\uD06C\uB864\uC774 \uD544\uC694\uD55C \uACBD\uC6B0\uB294 \uBE0C\uB77C\uC6B0\uC800\uC758 \uAE30\uBCF8 \uD3EC\uCEE4\uC2A4 \uC2A4\uD06C\uB864 \uB3D9\uC791\uC5D0 \uC758\uC874\uD55C\uB2E4.\n * \uC8FC\uB85C \uACE0\uC815 \uD5E4\uB354\uB098 \uACE0\uC815 \uC5F4\uC774 \uC788\uB294 \uD14C\uC774\uBE14\uC5D0\uC11C \uD3EC\uCEE4\uC2A4 \uC774\uBCA4\uD2B8\uC640 \uD568\uAED8 \uC0AC\uC6A9\uB41C\uB2E4.\n *\n * @param target - \uB300\uC0C1\uC758 \uCEE8\uD14C\uC774\uB108 \uB0B4 \uC704\uCE58 (offsetTop, offsetLeft)\n * @param offset - \uAC00\uB824\uC9C0\uBA74 \uC548 \uB418\uB294 \uC601\uC5ED \uD06C\uAE30 (\uC608: \uACE0\uC815 \uD5E4\uB354 \uB192\uC774, \uACE0\uC815 \uC5F4 \uB108\uBE44)\n */\n scrollIntoViewIfNeeded(target: { top: number; left: number }, offset?: { top: number; left: number }): void;\n }\n}\n\nHTMLElement.prototype.repaint = function (): void {\n // offsetHeight \uC811\uADFC \uC2DC \uBE0C\uB77C\uC6B0\uC800\uB294 \uB3D9\uAE30\uC801 \uB808\uC774\uC544\uC6C3 \uACC4\uC0B0(forced synchronous layout)\uC744 \uC218\uD589\uD558\uBA70,\n // \uC774\uB85C \uC778\uD574 \uD604\uC7AC \uBC30\uCE58\uB41C \uC2A4\uD0C0\uC77C \uBCC0\uACBD\uC0AC\uD56D\uC774 \uC989\uC2DC \uC801\uC6A9\uB418\uC5B4 \uB9AC\uD398\uC778\uD2B8\uAC00 \uD2B8\uB9AC\uAC70\uB41C\uB2E4.\n void this.offsetHeight;\n};\n\nHTMLElement.prototype.getRelativeOffset = function (parent: HTMLElement | string): { top: number; left: number } {\n const parentEl = typeof parent === \"string\" ? this.closest(parent) : parent;\n\n if (!(parentEl instanceof HTMLElement)) {\n throw new ArgumentError({ parent });\n }\n\n const elementRect = this.getBoundingClientRect();\n const parentRect = parentEl.getBoundingClientRect();\n\n const scrollLeft = window.scrollX;\n const scrollTop = window.scrollY;\n\n const relativeOffset = {\n top: elementRect.top - parentRect.top + scrollTop + (parentEl.scrollTop || 0),\n left: elementRect.left - parentRect.left + scrollLeft + (parentEl.scrollLeft || 0),\n };\n\n let currentEl = this.parentElement;\n while (currentEl !== null && currentEl !== parentEl) {\n const style = getComputedStyle(currentEl);\n relativeOffset.top += parseFloat(style.borderTopWidth) || 0;\n relativeOffset.left += parseFloat(style.borderLeftWidth) || 0;\n currentEl = currentEl.parentElement;\n }\n\n const elTransform = getComputedStyle(this).transform;\n const parentTransform = getComputedStyle(parentEl).transform;\n\n if (elTransform !== \"none\" || parentTransform !== \"none\") {\n const elementMatrix = new DOMMatrix(elTransform);\n const parentMatrix = new DOMMatrix(parentTransform);\n\n if (!elementMatrix.isIdentity || !parentMatrix.isIdentity) {\n const transformedPoint = parentMatrix\n .inverse()\n .multiply(elementMatrix)\n .transformPoint(new DOMPoint(relativeOffset.left, relativeOffset.top));\n\n relativeOffset.left = transformedPoint.x;\n relativeOffset.top = transformedPoint.y;\n }\n }\n\n return relativeOffset;\n};\n\nHTMLElement.prototype.scrollIntoViewIfNeeded = function (\n target: { top: number; left: number },\n offset: { top: number; left: number } = { top: 0, left: 0 },\n): void {\n const scroll = {\n top: this.scrollTop,\n left: this.scrollLeft,\n };\n\n if (target.top - scroll.top < offset.top) {\n this.scrollTop = target.top - offset.top;\n }\n if (target.left - scroll.left < offset.left) {\n this.scrollLeft = target.left - offset.left;\n }\n};\n"],
5
4
  "mappings": "AAAA,SAAS,qBAAqB;AAgD9B,YAAY,UAAU,UAAU,WAAkB;AAGhD,OAAK,KAAK;AACZ;AAEA,YAAY,UAAU,oBAAoB,SAAU,QAA6D;AAC/G,QAAM,WAAW,OAAO,WAAW,WAAW,KAAK,QAAQ,MAAM,IAAI;AAErE,MAAI,EAAE,oBAAoB,cAAc;AACtC,UAAM,IAAI,cAAc,EAAE,OAAO,CAAC;AAAA,EACpC;AAEA,QAAM,cAAc,KAAK,sBAAsB;AAC/C,QAAM,aAAa,SAAS,sBAAsB;AAElD,QAAM,aAAa,OAAO;AAC1B,QAAM,YAAY,OAAO;AAEzB,QAAM,iBAAiB;AAAA,IACrB,KAAK,YAAY,MAAM,WAAW,MAAM,aAAa,SAAS,aAAa;AAAA,IAC3E,MAAM,YAAY,OAAO,WAAW,OAAO,cAAc,SAAS,cAAc;AAAA,EAClF;AAEA,MAAI,YAAY,KAAK;AACrB,SAAO,cAAc,QAAQ,cAAc,UAAU;AACnD,UAAM,QAAQ,iBAAiB,SAAS;AACxC,mBAAe,OAAO,WAAW,MAAM,cAAc,KAAK;AAC1D,mBAAe,QAAQ,WAAW,MAAM,eAAe,KAAK;AAC5D,gBAAY,UAAU;AAAA,EACxB;AAEA,QAAM,cAAc,iBAAiB,IAAI,EAAE;AAC3C,QAAM,kBAAkB,iBAAiB,QAAQ,EAAE;AAEnD,MAAI,gBAAgB,UAAU,oBAAoB,QAAQ;AACxD,UAAM,gBAAgB,IAAI,UAAU,WAAW;AAC/C,UAAM,eAAe,IAAI,UAAU,eAAe;AAElD,QAAI,CAAC,cAAc,cAAc,CAAC,aAAa,YAAY;AACzD,YAAM,mBAAmB,aACtB,QAAQ,EACR,SAAS,aAAa,EACtB,eAAe,IAAI,SAAS,eAAe,MAAM,eAAe,GAAG,CAAC;AAEvE,qBAAe,OAAO,iBAAiB;AACvC,qBAAe,MAAM,iBAAiB;AAAA,IACxC;AAAA,EACF;AAEA,SAAO;AACT;AAEA,YAAY,UAAU,yBAAyB,SAC7C,QACA,SAAwC,EAAE,KAAK,GAAG,MAAM,EAAE,GACpD;AACN,QAAM,SAAS;AAAA,IACb,KAAK,KAAK;AAAA,IACV,MAAM,KAAK;AAAA,EACb;AAEA,MAAI,OAAO,MAAM,OAAO,MAAM,OAAO,KAAK;AACxC,SAAK,YAAY,OAAO,MAAM,OAAO;AAAA,EACvC;AACA,MAAI,OAAO,OAAO,OAAO,OAAO,OAAO,MAAM;AAC3C,SAAK,aAAa,OAAO,OAAO,OAAO;AAAA,EACzC;AACF;",
6
5
  "names": []
7
6
  }
package/dist/index.js.map CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["// core-browser: \uBE0C\uB77C\uC6B0\uC800 \uC804\uC6A9 \uC720\uD2F8\uB9AC\uD2F0\n\n// extensions (side-effect)\nimport \"./extensions/element-ext\";\nimport \"./extensions/html-element-ext\";\n\n// re-exports\nexport * from \"./extensions/element-ext\";\nexport * from \"./extensions/html-element-ext\";\nexport * from \"./utils/download\";\nexport * from \"./utils/fetch\";\nexport * from \"./utils/file-dialog\";\n"],
5
4
  "mappings": "AAGA,OAAO;AACP,OAAO;AAGP,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
6
5
  "names": []
7
6
  }
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/utils/download.ts"],
4
- "sourcesContent": ["/**\n * Blob\uC744 \uD30C\uC77C\uB85C \uB2E4\uC6B4\uB85C\uB4DC\n *\n * @param blob - \uB2E4\uC6B4\uB85C\uB4DC\uD560 Blob \uAC1D\uCCB4\n * @param fileName - \uC800\uC7A5\uB420 \uD30C\uC77C \uC774\uB984\n */\nexport function downloadBlob(blob: Blob, fileName: string): void {\n const url = URL.createObjectURL(blob);\n try {\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = fileName;\n link.click();\n } finally {\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n }\n}\n"],
5
4
  "mappings": "AAMO,SAAS,aAAa,MAAY,UAAwB;AAC/D,QAAM,MAAM,IAAI,gBAAgB,IAAI;AACpC,MAAI;AACF,UAAM,OAAO,SAAS,cAAc,GAAG;AACvC,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,MAAM;AAAA,EACb,UAAE;AACA,eAAW,MAAM,IAAI,gBAAgB,GAAG,GAAG,GAAI;AAAA,EACjD;AACF;",
6
5
  "names": []
7
6
  }
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/utils/fetch.ts"],
4
- "sourcesContent": ["export interface DownloadProgress {\n receivedLength: number;\n contentLength: number;\n}\n\n/**\n * URL\uC5D0\uC11C \uBC14\uC774\uB108\uB9AC \uB370\uC774\uD130 \uB2E4\uC6B4\uB85C\uB4DC (\uC9C4\uD589\uB960 \uCF5C\uBC31 \uC9C0\uC6D0)\n */\nexport async function fetchUrlBytes(\n url: string,\n options?: { onProgress?: (progress: DownloadProgress) => void },\n): Promise<Uint8Array> {\n const response = await fetch(url);\n if (!response.ok) {\n throw new Error(`Download failed: ${response.status} ${response.statusText}`);\n }\n\n const contentLength = Number(response.headers.get(\"Content-Length\") ?? 0);\n const reader = response.body?.getReader();\n if (!reader) {\n throw new Error(\"Response body is not readable\");\n }\n\n try {\n // Content-Length\uB97C \uC54C \uC218 \uC788\uC73C\uBA74 \uBBF8\uB9AC \uD560\uB2F9\uD558\uC5EC \uBA54\uBAA8\uB9AC \uD6A8\uC728\uC131 \uD5A5\uC0C1\n if (contentLength > 0) {\n const result = new Uint8Array(contentLength);\n let receivedLength = 0;\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n result.set(value, receivedLength);\n receivedLength += value.length;\n options?.onProgress?.({ receivedLength, contentLength });\n }\n\n return result;\n }\n\n // Content-Length\uB97C \uBAA8\uB974\uBA74 \uCCAD\uD06C \uC218\uC9D1 \uD6C4 \uBCD1\uD569 (chunked encoding)\n const chunks: Uint8Array[] = [];\n let receivedLength = 0;\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n chunks.push(value);\n receivedLength += value.length;\n }\n\n // \uCCAD\uD06C \uBCD1\uD569\n const result = new Uint8Array(receivedLength);\n let position = 0;\n for (const chunk of chunks) {\n result.set(chunk, position);\n position += chunk.length;\n }\n\n return result;\n } finally {\n reader.releaseLock();\n }\n}\n"],
5
4
  "mappings": "AAQA,eAAsB,cACpB,KACA,SACqB;AAXvB;AAYE,QAAM,WAAW,MAAM,MAAM,GAAG;AAChC,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,oBAAoB,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE;AAAA,EAC9E;AAEA,QAAM,gBAAgB,OAAO,SAAS,QAAQ,IAAI,gBAAgB,KAAK,CAAC;AACxE,QAAM,UAAS,cAAS,SAAT,mBAAe;AAC9B,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,+BAA+B;AAAA,EACjD;AAEA,MAAI;AAEF,QAAI,gBAAgB,GAAG;AACrB,YAAMA,UAAS,IAAI,WAAW,aAAa;AAC3C,UAAIC,kBAAiB;AAErB,aAAO,MAAM;AACX,cAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,YAAI,KAAM;AAEV,QAAAD,QAAO,IAAI,OAAOC,eAAc;AAChC,QAAAA,mBAAkB,MAAM;AACxB,iDAAS,eAAT,iCAAsB,EAAE,gBAAAA,iBAAgB,cAAc;AAAA,MACxD;AAEA,aAAOD;AAAA,IACT;AAGA,UAAM,SAAuB,CAAC;AAC9B,QAAI,iBAAiB;AAErB,WAAO,MAAM;AACX,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,UAAI,KAAM;AAEV,aAAO,KAAK,KAAK;AACjB,wBAAkB,MAAM;AAAA,IAC1B;AAGA,UAAM,SAAS,IAAI,WAAW,cAAc;AAC5C,QAAI,WAAW;AACf,eAAW,SAAS,QAAQ;AAC1B,aAAO,IAAI,OAAO,QAAQ;AAC1B,kBAAY,MAAM;AAAA,IACpB;AAEA,WAAO;AAAA,EACT,UAAE;AACA,WAAO,YAAY;AAAA,EACrB;AACF;",
6
5
  "names": ["result", "receivedLength"]
7
6
  }
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/utils/file-dialog.ts"],
4
- "sourcesContent": ["/**\n * \uD30C\uC77C \uC120\uD0DD \uB2E4\uC774\uC5BC\uB85C\uADF8\uB97C \uD504\uB85C\uADF8\uB798\uBC0D \uBC29\uC2DD\uC73C\uB85C \uC5F4\uAE30\n */\nexport function openFileDialog(options?: { accept?: string; multiple?: boolean }): Promise<File[] | undefined> {\n return new Promise((resolve) => {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.multiple = options?.multiple ?? false;\n if (options?.accept != null) {\n input.accept = options.accept;\n }\n input.onchange = () => {\n resolve(input.files != null && input.files.length > 0 ? [...input.files] : undefined);\n };\n input.click();\n });\n}\n"],
5
4
  "mappings": "AAGO,SAAS,eAAe,SAAgF;AAC7G,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,UAAM,QAAQ,SAAS,cAAc,OAAO;AAC5C,UAAM,OAAO;AACb,UAAM,YAAW,mCAAS,aAAY;AACtC,SAAI,mCAAS,WAAU,MAAM;AAC3B,YAAM,SAAS,QAAQ;AAAA,IACzB;AACA,UAAM,WAAW,MAAM;AACrB,cAAQ,MAAM,SAAS,QAAQ,MAAM,MAAM,SAAS,IAAI,CAAC,GAAG,MAAM,KAAK,IAAI,MAAS;AAAA,IACtF;AACA,UAAM,MAAM;AAAA,EACd,CAAC;AACH;",
6
5
  "names": []
7
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/core-browser",
3
- "version": "13.0.0-beta.45",
3
+ "version": "13.0.0-beta.47",
4
4
  "description": "심플리즘 패키지 - 코어 모듈 (browser)",
5
5
  "author": "김석래",
6
6
  "repository": {
@@ -13,7 +13,8 @@
13
13
  "main": "./dist/index.js",
14
14
  "types": "./dist/index.d.ts",
15
15
  "files": [
16
- "dist"
16
+ "dist",
17
+ "src"
17
18
  ],
18
19
  "sideEffects": [
19
20
  "./dist/extensions/element-ext.js",
@@ -21,7 +22,7 @@
21
22
  ],
22
23
  "dependencies": {
23
24
  "tabbable": "^6.4.0",
24
- "@simplysm/core-common": "13.0.0-beta.45"
25
+ "@simplysm/core-common": "13.0.0-beta.47"
25
26
  },
26
27
  "devDependencies": {
27
28
  "happy-dom": "^20.6.1"
@@ -0,0 +1,246 @@
1
+ import { isFocusable } from "tabbable";
2
+ import { TimeoutError } from "@simplysm/core-common";
3
+
4
+ /**
5
+ * 요소 bounds 정보 타입
6
+ */
7
+ export interface ElementBounds {
8
+ /** 측정 대상 요소 */
9
+ target: Element;
10
+ /** 뷰포트 기준 상단 위치 */
11
+ top: number;
12
+ /** 뷰포트 기준 왼쪽 위치 */
13
+ left: number;
14
+ /** 요소 너비 */
15
+ width: number;
16
+ /** 요소 높이 */
17
+ height: number;
18
+ }
19
+
20
+ declare global {
21
+ interface Element {
22
+ /**
23
+ * 셀렉터로 하위 요소 전체 검색
24
+ *
25
+ * @param selector - CSS 셀렉터
26
+ * @returns 매칭된 요소 배열 (빈 셀렉터는 빈 배열 반환)
27
+ */
28
+ findAll<T extends Element = Element>(selector: string): T[];
29
+
30
+ /**
31
+ * 셀렉터로 첫 번째 매칭 요소 검색
32
+ *
33
+ * @param selector - CSS 셀렉터
34
+ * @returns 첫 번째 매칭 요소 또는 undefined (빈 셀렉터는 undefined 반환)
35
+ */
36
+ findFirst<T extends Element = Element>(selector: string): T | undefined;
37
+
38
+ /**
39
+ * 요소를 첫 번째 자식으로 삽입
40
+ *
41
+ * @param child - 삽입할 자식 요소
42
+ * @returns 삽입된 자식 요소
43
+ */
44
+ prependChild<T extends Element>(child: T): T;
45
+
46
+ /**
47
+ * 모든 부모 요소 목록 반환 (가까운 순서)
48
+ *
49
+ * @returns 부모 요소 배열 (가까운 부모부터 순서대로)
50
+ */
51
+ getParents(): Element[];
52
+
53
+ /**
54
+ * 부모 중 첫 번째 포커스 가능 요소 검색 (tabbable 사용)
55
+ *
56
+ * @returns 포커스 가능한 첫 번째 부모 요소 또는 undefined
57
+ */
58
+ findFocusableParent(): HTMLElement | undefined;
59
+
60
+ /**
61
+ * 자식 중 첫 번째 포커스 가능 요소 검색 (tabbable 사용)
62
+ *
63
+ * @returns 포커스 가능한 첫 번째 자식 요소 또는 undefined
64
+ */
65
+ findFirstFocusableChild(): HTMLElement | undefined;
66
+
67
+ /**
68
+ * 요소가 offset 기준 요소인지 확인 (position: relative/absolute/fixed/sticky)
69
+ *
70
+ * @returns position 속성이 relative, absolute, fixed, sticky 중 하나면 true
71
+ */
72
+ isOffsetElement(): boolean;
73
+
74
+ /**
75
+ * 요소가 화면에 보이는지 확인
76
+ *
77
+ * @remarks
78
+ * clientRects 존재 여부, visibility: hidden, opacity: 0 여부를 확인한다.
79
+ *
80
+ * @returns 요소가 화면에 보이면 true
81
+ */
82
+ isVisible(): boolean;
83
+ }
84
+ }
85
+
86
+ Element.prototype.findAll = function <T extends Element = Element>(selector: string): T[] {
87
+ const trimmed = selector.trim();
88
+ if (trimmed === "") return [];
89
+ return Array.from(this.querySelectorAll<T>(trimmed));
90
+ };
91
+
92
+ Element.prototype.findFirst = function <T extends Element = Element>(selector: string): T | undefined {
93
+ const trimmed = selector.trim();
94
+ if (trimmed === "") return undefined;
95
+ return this.querySelector<T>(trimmed) ?? undefined;
96
+ };
97
+
98
+ Element.prototype.prependChild = function <T extends Element>(child: T): T {
99
+ return this.insertBefore(child, this.firstElementChild);
100
+ };
101
+
102
+ Element.prototype.getParents = function (): Element[] {
103
+ const result: Element[] = [];
104
+ let cursor = this.parentNode;
105
+ while (cursor !== null && cursor instanceof Element) {
106
+ result.push(cursor);
107
+ cursor = cursor.parentNode;
108
+ }
109
+ return result;
110
+ };
111
+
112
+ Element.prototype.findFocusableParent = function (): HTMLElement | undefined {
113
+ let parentEl = this.parentElement;
114
+ while (parentEl !== null) {
115
+ if (isFocusable(parentEl)) {
116
+ return parentEl;
117
+ }
118
+ parentEl = parentEl.parentElement;
119
+ }
120
+ return undefined;
121
+ };
122
+
123
+ Element.prototype.findFirstFocusableChild = function (): HTMLElement | undefined {
124
+ const walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT);
125
+ let node = walker.nextNode();
126
+ while (node !== null) {
127
+ if (node instanceof HTMLElement && isFocusable(node)) {
128
+ return node;
129
+ }
130
+ node = walker.nextNode();
131
+ }
132
+ return undefined;
133
+ };
134
+
135
+ Element.prototype.isOffsetElement = function (): boolean {
136
+ return ["relative", "absolute", "fixed", "sticky"].includes(getComputedStyle(this).position);
137
+ };
138
+
139
+ Element.prototype.isVisible = function (): boolean {
140
+ const style = getComputedStyle(this);
141
+ return this.getClientRects().length > 0 && style.visibility !== "hidden" && style.opacity !== "0";
142
+ };
143
+
144
+ // ============================================================================
145
+ // 정적 함수 (이벤트 핸들러용 또는 여러 요소 대상)
146
+ // ============================================================================
147
+
148
+ /**
149
+ * 요소 내용을 클립보드에 복사 (copy 이벤트 핸들러에서 사용)
150
+ *
151
+ * @param event - copy 이벤트 객체
152
+ */
153
+ export function copyElement(event: ClipboardEvent): void {
154
+ const clipboardData = event.clipboardData;
155
+ const target = event.target;
156
+ if (clipboardData == null || !(target instanceof Element)) return;
157
+
158
+ const firstInputEl = target.querySelector<HTMLInputElement | HTMLTextAreaElement>("input, textarea");
159
+ if (firstInputEl != null) {
160
+ clipboardData.setData("text/plain", firstInputEl.value);
161
+ event.preventDefault();
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 클립보드 내용을 요소에 붙여넣기 (paste 이벤트 핸들러에서 사용)
167
+ *
168
+ * @remarks
169
+ * 대상 요소 내의 첫 번째 input/textarea를 찾아 전체 값을 클립보드 내용으로 교체한다.
170
+ * 커서 위치나 선택 영역을 고려하지 않는다.
171
+ *
172
+ * @param event - paste 이벤트 객체
173
+ */
174
+ export function pasteToElement(event: ClipboardEvent): void {
175
+ const clipboardData = event.clipboardData;
176
+ const target = event.target;
177
+ if (clipboardData == null || !(target instanceof Element)) return;
178
+
179
+ const contentText = clipboardData.getData("text/plain");
180
+
181
+ const firstInputEl = target.findFirst<HTMLInputElement | HTMLTextAreaElement>("input, textarea");
182
+ if (firstInputEl !== undefined) {
183
+ firstInputEl.value = contentText;
184
+ firstInputEl.dispatchEvent(new Event("input", { bubbles: true }));
185
+ event.preventDefault();
186
+ }
187
+ }
188
+
189
+ /**
190
+ * IntersectionObserver를 사용하여 요소들의 bounds 정보 조회
191
+ *
192
+ * @param els - 대상 요소 배열
193
+ * @param timeout - 타임아웃 (밀리초, 기본: 5000)
194
+ * @throws {TimeoutError} 타임아웃 시간 내에 응답이 없을 경우
195
+ */
196
+ export async function getBounds(els: Element[], timeout: number = 5000): Promise<ElementBounds[]> {
197
+ // 중복 제거 및 입력 순서대로 결과를 정렬하기 위한 인덱스 맵
198
+ const indexMap = new Map(els.map((el, i) => [el, i] as const));
199
+ if (indexMap.size === 0) {
200
+ return [];
201
+ }
202
+
203
+ // 정렬 성능 최적화를 위한 인덱스 맵
204
+ const sortIndexMap = new Map(els.map((el, i) => [el, i] as const));
205
+
206
+ let observer: IntersectionObserver | undefined;
207
+
208
+ try {
209
+ return await Promise.race([
210
+ new Promise<ElementBounds[]>((resolve) => {
211
+ const results: ElementBounds[] = [];
212
+
213
+ observer = new IntersectionObserver((entries) => {
214
+ for (const entry of entries) {
215
+ const target = entry.target;
216
+ if (indexMap.has(target)) {
217
+ indexMap.delete(target);
218
+ results.push({
219
+ target,
220
+ top: entry.boundingClientRect.top,
221
+ left: entry.boundingClientRect.left,
222
+ width: entry.boundingClientRect.width,
223
+ height: entry.boundingClientRect.height,
224
+ });
225
+ }
226
+ }
227
+
228
+ if (indexMap.size === 0) {
229
+ observer?.disconnect();
230
+ // 입력 순서대로 정렬
231
+ resolve(results.sort((a, b) => sortIndexMap.get(a.target)! - sortIndexMap.get(b.target)!));
232
+ }
233
+ });
234
+
235
+ for (const el of indexMap.keys()) {
236
+ observer.observe(el);
237
+ }
238
+ }),
239
+ new Promise<ElementBounds[]>((_, reject) =>
240
+ setTimeout(() => reject(new TimeoutError(undefined, `${timeout}ms 초과`)), timeout),
241
+ ),
242
+ ]);
243
+ } finally {
244
+ observer?.disconnect();
245
+ }
246
+ }
@@ -0,0 +1,117 @@
1
+ import { ArgumentError } from "@simplysm/core-common";
2
+
3
+ declare global {
4
+ interface HTMLElement {
5
+ /**
6
+ * 강제 리페인트 (reflow 트리거)
7
+ */
8
+ repaint(): void;
9
+
10
+ /**
11
+ * 부모 요소 기준 상대 위치 계산 (CSS 포지셔닝용)
12
+ *
13
+ * @remarks
14
+ * 이 함수는 요소의 위치를 부모 요소 기준으로 계산하되, `window.scrollX/Y`를 포함하여
15
+ * CSS `top`/`left` 속성에 직접 사용할 수 있는 문서 기준 좌표를 반환한다.
16
+ *
17
+ * 주요 사용 사례:
18
+ * - 드롭다운, 팝업 등을 `document.body`에 append 후 위치 지정
19
+ * - 스크롤된 페이지에서도 올바르게 동작
20
+ *
21
+ * 계산에 포함되는 요소:
22
+ * - 뷰포트 기준 위치 (getBoundingClientRect)
23
+ * - 문서 스크롤 위치 (window.scrollX/Y)
24
+ * - 부모 요소 내부 스크롤 (parentEl.scrollTop/Left)
25
+ * - 중간 요소들의 border 두께
26
+ * - CSS transform 변환
27
+ *
28
+ * @param parent - 기준이 될 부모 요소 또는 셀렉터 (예: document.body, ".container")
29
+ * @returns CSS top/left 속성에 사용할 수 있는 좌표
30
+ * @throws {ArgumentError} 부모 요소를 찾을 수 없는 경우
31
+ */
32
+ getRelativeOffset(parent: HTMLElement | string): { top: number; left: number };
33
+
34
+ /**
35
+ * 대상이 offset 영역(고정 헤더/고정 열 등)에 가려진 경우, 보이도록 스크롤
36
+ *
37
+ * @remarks
38
+ * 이 함수는 대상이 스크롤 영역의 위쪽/왼쪽 경계를 벗어난 경우만 처리한다.
39
+ * 아래쪽/오른쪽으로 스크롤이 필요한 경우는 브라우저의 기본 포커스 스크롤 동작에 의존한다.
40
+ * 주로 고정 헤더나 고정 열이 있는 테이블에서 포커스 이벤트와 함께 사용된다.
41
+ *
42
+ * @param target - 대상의 컨테이너 내 위치 (offsetTop, offsetLeft)
43
+ * @param offset - 가려지면 안 되는 영역 크기 (예: 고정 헤더 높이, 고정 열 너비)
44
+ */
45
+ scrollIntoViewIfNeeded(target: { top: number; left: number }, offset?: { top: number; left: number }): void;
46
+ }
47
+ }
48
+
49
+ HTMLElement.prototype.repaint = function (): void {
50
+ // offsetHeight 접근 시 브라우저는 동기적 레이아웃 계산(forced synchronous layout)을 수행하며,
51
+ // 이로 인해 현재 배치된 스타일 변경사항이 즉시 적용되어 리페인트가 트리거된다.
52
+ void this.offsetHeight;
53
+ };
54
+
55
+ HTMLElement.prototype.getRelativeOffset = function (parent: HTMLElement | string): { top: number; left: number } {
56
+ const parentEl = typeof parent === "string" ? this.closest(parent) : parent;
57
+
58
+ if (!(parentEl instanceof HTMLElement)) {
59
+ throw new ArgumentError({ parent });
60
+ }
61
+
62
+ const elementRect = this.getBoundingClientRect();
63
+ const parentRect = parentEl.getBoundingClientRect();
64
+
65
+ const scrollLeft = window.scrollX;
66
+ const scrollTop = window.scrollY;
67
+
68
+ const relativeOffset = {
69
+ top: elementRect.top - parentRect.top + scrollTop + (parentEl.scrollTop || 0),
70
+ left: elementRect.left - parentRect.left + scrollLeft + (parentEl.scrollLeft || 0),
71
+ };
72
+
73
+ let currentEl = this.parentElement;
74
+ while (currentEl !== null && currentEl !== parentEl) {
75
+ const style = getComputedStyle(currentEl);
76
+ relativeOffset.top += parseFloat(style.borderTopWidth) || 0;
77
+ relativeOffset.left += parseFloat(style.borderLeftWidth) || 0;
78
+ currentEl = currentEl.parentElement;
79
+ }
80
+
81
+ const elTransform = getComputedStyle(this).transform;
82
+ const parentTransform = getComputedStyle(parentEl).transform;
83
+
84
+ if (elTransform !== "none" || parentTransform !== "none") {
85
+ const elementMatrix = new DOMMatrix(elTransform);
86
+ const parentMatrix = new DOMMatrix(parentTransform);
87
+
88
+ if (!elementMatrix.isIdentity || !parentMatrix.isIdentity) {
89
+ const transformedPoint = parentMatrix
90
+ .inverse()
91
+ .multiply(elementMatrix)
92
+ .transformPoint(new DOMPoint(relativeOffset.left, relativeOffset.top));
93
+
94
+ relativeOffset.left = transformedPoint.x;
95
+ relativeOffset.top = transformedPoint.y;
96
+ }
97
+ }
98
+
99
+ return relativeOffset;
100
+ };
101
+
102
+ HTMLElement.prototype.scrollIntoViewIfNeeded = function (
103
+ target: { top: number; left: number },
104
+ offset: { top: number; left: number } = { top: 0, left: 0 },
105
+ ): void {
106
+ const scroll = {
107
+ top: this.scrollTop,
108
+ left: this.scrollLeft,
109
+ };
110
+
111
+ if (target.top - scroll.top < offset.top) {
112
+ this.scrollTop = target.top - offset.top;
113
+ }
114
+ if (target.left - scroll.left < offset.left) {
115
+ this.scrollLeft = target.left - offset.left;
116
+ }
117
+ };
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ // core-browser: 브라우저 전용 유틸리티
2
+
3
+ // extensions (side-effect)
4
+ import "./extensions/element-ext";
5
+ import "./extensions/html-element-ext";
6
+
7
+ // re-exports
8
+ export * from "./extensions/element-ext";
9
+ export * from "./extensions/html-element-ext";
10
+ export * from "./utils/download";
11
+ export * from "./utils/fetch";
12
+ export * from "./utils/file-dialog";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Blob을 파일로 다운로드
3
+ *
4
+ * @param blob - 다운로드할 Blob 객체
5
+ * @param fileName - 저장될 파일 이름
6
+ */
7
+ export function downloadBlob(blob: Blob, fileName: string): void {
8
+ const url = URL.createObjectURL(blob);
9
+ try {
10
+ const link = document.createElement("a");
11
+ link.href = url;
12
+ link.download = fileName;
13
+ link.click();
14
+ } finally {
15
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
16
+ }
17
+ }
@@ -0,0 +1,66 @@
1
+ export interface DownloadProgress {
2
+ receivedLength: number;
3
+ contentLength: number;
4
+ }
5
+
6
+ /**
7
+ * URL에서 바이너리 데이터 다운로드 (진행률 콜백 지원)
8
+ */
9
+ export async function fetchUrlBytes(
10
+ url: string,
11
+ options?: { onProgress?: (progress: DownloadProgress) => void },
12
+ ): Promise<Uint8Array> {
13
+ const response = await fetch(url);
14
+ if (!response.ok) {
15
+ throw new Error(`Download failed: ${response.status} ${response.statusText}`);
16
+ }
17
+
18
+ const contentLength = Number(response.headers.get("Content-Length") ?? 0);
19
+ const reader = response.body?.getReader();
20
+ if (!reader) {
21
+ throw new Error("Response body is not readable");
22
+ }
23
+
24
+ try {
25
+ // Content-Length를 알 수 있으면 미리 할당하여 메모리 효율성 향상
26
+ if (contentLength > 0) {
27
+ const result = new Uint8Array(contentLength);
28
+ let receivedLength = 0;
29
+
30
+ while (true) {
31
+ const { done, value } = await reader.read();
32
+ if (done) break;
33
+
34
+ result.set(value, receivedLength);
35
+ receivedLength += value.length;
36
+ options?.onProgress?.({ receivedLength, contentLength });
37
+ }
38
+
39
+ return result;
40
+ }
41
+
42
+ // Content-Length를 모르면 청크 수집 후 병합 (chunked encoding)
43
+ const chunks: Uint8Array[] = [];
44
+ let receivedLength = 0;
45
+
46
+ while (true) {
47
+ const { done, value } = await reader.read();
48
+ if (done) break;
49
+
50
+ chunks.push(value);
51
+ receivedLength += value.length;
52
+ }
53
+
54
+ // 청크 병합
55
+ const result = new Uint8Array(receivedLength);
56
+ let position = 0;
57
+ for (const chunk of chunks) {
58
+ result.set(chunk, position);
59
+ position += chunk.length;
60
+ }
61
+
62
+ return result;
63
+ } finally {
64
+ reader.releaseLock();
65
+ }
66
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 파일 선택 다이얼로그를 프로그래밍 방식으로 열기
3
+ */
4
+ export function openFileDialog(options?: { accept?: string; multiple?: boolean }): Promise<File[] | undefined> {
5
+ return new Promise((resolve) => {
6
+ const input = document.createElement("input");
7
+ input.type = "file";
8
+ input.multiple = options?.multiple ?? false;
9
+ if (options?.accept != null) {
10
+ input.accept = options.accept;
11
+ }
12
+ input.onchange = () => {
13
+ resolve(input.files != null && input.files.length > 0 ? [...input.files] : undefined);
14
+ };
15
+ input.click();
16
+ });
17
+ }