From 51e0099beacce133d0ecf5f1e19fa3e5f5e97f2e Mon Sep 17 00:00:00 2001 From: how2ice Date: Mon, 25 May 2026 17:29:21 +0900 Subject: [PATCH] feat: add play apps and layout tools --- src/features/layout/draw/LayoutDrawPage.css | 1046 ++++ src/features/layout/draw/LayoutDrawPage.tsx | 3516 ++++++++++++ src/features/layout/draw/index.ts | 1 + .../layout/draw/layoutDrawComponentStorage.ts | 246 + src/features/layout/draw/layoutDrawHistory.ts | 68 + src/features/layout/draw/layoutDrawRegions.ts | 237 + .../layout/draw/layoutDrawSelectionUtils.ts | 99 + .../layout/draw/layoutDrawShapeUtils.ts | 32 + src/features/layout/draw/layoutDrawStorage.ts | 244 + .../layout/draw/layoutDrawStorageShapes.ts | 24 + src/features/layout/draw/layoutDrawTypes.ts | 62 + src/features/layout/draw/lineDraft.ts | 107 + .../feature-menu/FeatureMenuLayoutPage.css | 8 +- .../feature-menu/FeatureMenuLayoutPage.tsx | 2 + src/features/planBoard/PlanBoardPage.tsx | 12 +- src/features/planBoard/PlanSchedulePage.tsx | 13 +- src/views/play/apps/apps/AppsLibraryView.css | 217 + src/views/play/apps/apps/AppsLibraryView.tsx | 133 + src/views/play/apps/apps/appsRegistry.tsx | 114 + .../play/apps/e-reader/EReaderAppView.css | 1228 +++++ .../play/apps/e-reader/EReaderAppView.tsx | 3201 +++++++++++ src/views/play/apps/e-reader/eReaderApi.ts | 308 ++ .../apps/photo-puzzle/PhotoPuzzleAppView.css | 277 + .../apps/photo-puzzle/PhotoPuzzleAppView.tsx | 652 +++ .../apps/photo-puzzle/photoPuzzleAppView.css | 800 +++ .../apps/photo-puzzle/photoPuzzleCatalog.ts | 315 ++ .../apps/photo-puzzle/photoPuzzleLibrary.ts | 413 ++ .../apps/photoprism/PhotoPrismAppView.css | 3236 +++++++++++ .../apps/photoprism/PhotoPrismAppView.tsx | 4907 +++++++++++++++++ .../play/apps/photoprism/photoPrismApi.ts | 359 ++ src/views/play/apps/test/TestPlayAppView.css | 642 ++- src/views/play/apps/test/TestPlayAppView.tsx | 718 ++- src/views/play/apps/test/testPlayAppApi.ts | 260 + src/views/play/apps/tetris/TetrisAppView.css | 1213 ++++ src/views/play/apps/tetris/TetrisAppView.tsx | 1299 +++++ .../play/apps/the-quest/TheQuestAppView.css | 4140 ++++++++++++++ .../play/apps/the-quest/TheQuestAppView.tsx | 2385 ++++++++ .../apps/the-quest/game/createTheQuestGame.ts | 2090 +++++++ .../play/apps/the-quest/game/theQuestData.ts | 683 +++ .../play/apps/the-quest/game/theQuestStore.ts | 1495 +++++ tests/layoutDraw/layoutDrawHistory.test.ts | 56 + tests/layoutDraw/layoutDrawRegions.test.ts | 65 + .../layoutDrawSelectionUtils.test.ts | 45 + tests/layoutDraw/layoutDrawShapeUtils.test.ts | 86 + tests/layoutDraw/layoutDrawStorage.test.ts | 46 + tests/layoutDraw/lineDraft.test.ts | 171 + 46 files changed, 37152 insertions(+), 119 deletions(-) create mode 100644 src/features/layout/draw/LayoutDrawPage.css create mode 100644 src/features/layout/draw/LayoutDrawPage.tsx create mode 100644 src/features/layout/draw/index.ts create mode 100644 src/features/layout/draw/layoutDrawComponentStorage.ts create mode 100644 src/features/layout/draw/layoutDrawHistory.ts create mode 100644 src/features/layout/draw/layoutDrawRegions.ts create mode 100644 src/features/layout/draw/layoutDrawSelectionUtils.ts create mode 100644 src/features/layout/draw/layoutDrawShapeUtils.ts create mode 100644 src/features/layout/draw/layoutDrawStorage.ts create mode 100644 src/features/layout/draw/layoutDrawStorageShapes.ts create mode 100644 src/features/layout/draw/layoutDrawTypes.ts create mode 100644 src/features/layout/draw/lineDraft.ts create mode 100644 src/views/play/apps/apps/AppsLibraryView.css create mode 100644 src/views/play/apps/apps/AppsLibraryView.tsx create mode 100644 src/views/play/apps/apps/appsRegistry.tsx create mode 100644 src/views/play/apps/e-reader/EReaderAppView.css create mode 100644 src/views/play/apps/e-reader/EReaderAppView.tsx create mode 100644 src/views/play/apps/e-reader/eReaderApi.ts create mode 100644 src/views/play/apps/photo-puzzle/PhotoPuzzleAppView.css create mode 100644 src/views/play/apps/photo-puzzle/PhotoPuzzleAppView.tsx create mode 100644 src/views/play/apps/photo-puzzle/photoPuzzleAppView.css create mode 100644 src/views/play/apps/photo-puzzle/photoPuzzleCatalog.ts create mode 100644 src/views/play/apps/photo-puzzle/photoPuzzleLibrary.ts create mode 100644 src/views/play/apps/photoprism/PhotoPrismAppView.css create mode 100644 src/views/play/apps/photoprism/PhotoPrismAppView.tsx create mode 100644 src/views/play/apps/photoprism/photoPrismApi.ts create mode 100644 src/views/play/apps/test/testPlayAppApi.ts create mode 100644 src/views/play/apps/tetris/TetrisAppView.css create mode 100644 src/views/play/apps/tetris/TetrisAppView.tsx create mode 100644 src/views/play/apps/the-quest/TheQuestAppView.css create mode 100644 src/views/play/apps/the-quest/TheQuestAppView.tsx create mode 100644 src/views/play/apps/the-quest/game/createTheQuestGame.ts create mode 100644 src/views/play/apps/the-quest/game/theQuestData.ts create mode 100644 src/views/play/apps/the-quest/game/theQuestStore.ts create mode 100644 tests/layoutDraw/layoutDrawHistory.test.ts create mode 100644 tests/layoutDraw/layoutDrawRegions.test.ts create mode 100644 tests/layoutDraw/layoutDrawSelectionUtils.test.ts create mode 100644 tests/layoutDraw/layoutDrawShapeUtils.test.ts create mode 100644 tests/layoutDraw/layoutDrawStorage.test.ts create mode 100644 tests/layoutDraw/lineDraft.test.ts diff --git a/src/features/layout/draw/LayoutDrawPage.css b/src/features/layout/draw/LayoutDrawPage.css new file mode 100644 index 0000000..00d1979 --- /dev/null +++ b/src/features/layout/draw/LayoutDrawPage.css @@ -0,0 +1,1046 @@ +.layout-draw-page { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + overscroll-behavior: none; + overscroll-behavior-x: none; + touch-action: none; + background: #f8f8f2; +} + +.layout-draw-page__toolbar { + position: absolute; + z-index: 4; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + background: rgba(255, 252, 245, 0.94); + border: 1px solid rgba(148, 163, 184, 0.24); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(8px); + touch-action: none; + user-select: none; +} + +.layout-draw-page__toolbar--attached { + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); +} + +.layout-draw-page__toolbar-chrome { + display: flex; + align-items: center; + gap: 6px; +} + +.layout-draw-page__toolbar-buttons { + display: flex; + gap: 8px; +} + +.layout-draw-page__toolbar-buttons--tools, +.layout-draw-page__toolbar-buttons--actions { + flex-direction: column; +} + +.layout-draw-page__toolbar-buttons--row, +.layout-draw-page__toolbar-buttons--mobile-actions { + flex-wrap: wrap; +} + +.layout-draw-page__tool-group { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border: 1px solid rgba(148, 163, 184, 0.34); + background: rgba(255, 255, 255, 0.44); +} + +.layout-draw-page__tool-group--compact, +.layout-draw-page__tool-group--toggle { + padding: 8px; +} + +.layout-draw-page__paint-palette { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.layout-draw-page__paint-swatch { + width: 24px; + height: 24px; + border: 1px solid rgba(15, 23, 42, 0.18); + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.62); + cursor: pointer; +} + +.layout-draw-page__paint-swatch--active { + border-color: #7c8fb8; + box-shadow: 0 0 0 2px rgba(124, 143, 184, 0.22); +} + +.layout-draw-page__paint-swatch--clear { + display: inline-flex; + align-items: center; + justify-content: center; + background: + linear-gradient(135deg, transparent 0 46%, rgba(220, 38, 38, 0.86) 46% 54%, transparent 54% 100%), + rgba(255, 255, 255, 0.92); + color: transparent; +} + +.layout-draw-page__label-editor { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + min-width: 0; + padding-top: 2px; +} + +.layout-draw-page__label-editor .ant-input { + min-width: 0; + font-size: 12px; +} + +.layout-draw-page__box-handle { + display: inline-flex; + flex: 1; + align-items: center; + justify-content: center; + height: 20px; + padding: 0; + border: 0; + background: rgba(226, 232, 240, 0.96); + color: #475569; + cursor: grab; + touch-action: none; +} + +.layout-draw-page__box-handle:active { + cursor: grabbing; +} + +.layout-draw-page__box-handle:disabled { + cursor: default; + opacity: 0.45; +} + +.layout-draw-page__box-handle--status { + width: 28px; + flex: 0 0 auto; +} + +.layout-draw-page__icon-button { + width: 44px; + min-width: 44px; + height: 44px; + padding: 0; + border-radius: 0; + box-shadow: none; + pointer-events: auto; +} + +.layout-draw-page__icon-button--chrome { + width: 30px; + min-width: 30px; + height: 20px; + border-radius: 0; + padding: 0; +} + +.layout-draw-page__toolbar-anchor { + position: absolute; + z-index: 4; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: 1px solid rgba(148, 163, 184, 0.32); + background: rgba(255, 252, 245, 0.94); + color: #334155; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(8px); + cursor: pointer; +} + +.layout-draw-page__toolbar-anchor:hover { + border-color: rgba(124, 143, 184, 0.56); +} + +.layout-draw-page__status { + position: absolute; + z-index: 4; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 200px; + max-width: min(280px, calc(100vw - 16px)); + padding: 8px 12px; + background: rgba(255, 252, 245, 0.92); + border: 1px solid rgba(148, 163, 184, 0.26); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(8px); + font-size: 13px; + touch-action: none; +} + +.layout-draw-page__status-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.layout-draw-page__status strong { + color: #0f172a; + font-size: 13px; + line-height: 1.2; +} + +.layout-draw-page__status span { + color: #475569; + font-size: 12px; +} + +.layout-draw-page__panel { + position: absolute; + top: 12px; + right: 72px; + z-index: 7; + width: min(320px, calc(100vw - 96px)); + max-height: calc(100% - 24px); + display: flex; + flex-direction: column; + background: rgba(255, 252, 245, 0.96); + border: 1px solid rgba(148, 163, 184, 0.28); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16); + backdrop-filter: blur(10px); +} + +.layout-draw-page__panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.layout-draw-page__panel-header strong { + color: #0f172a; + font-size: 14px; +} + +.layout-draw-page__panel-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + overflow: auto; +} + +.layout-draw-page__panel-label, +.layout-draw-page__panel-meta, +.layout-draw-page__saved-copy span { + color: #475569; + font-size: 12px; +} + +.layout-draw-page__panel-error { + margin: 0; + color: #b91c1c; + font-size: 12px; +} + +.layout-draw-page__panel-actions { + display: flex; + justify-content: flex-end; +} + +.layout-draw-page__panel-loading { + display: flex; + justify-content: center; + padding: 24px 0; +} + +.layout-draw-page__saved-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.layout-draw-page__saved-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(255, 255, 255, 0.72); +} + +.layout-draw-page__saved-copy { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.layout-draw-page__saved-copy strong { + color: #0f172a; + font-size: 13px; + line-height: 1.3; +} + +.layout-draw-page__saved-actions { + display: flex; + flex-direction: column; + gap: 6px; +} + +.layout-draw-page__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; + background: #f8f8f2; +} + +.layout-draw-page__paper { + fill: #f8f8f2; +} + +.layout-draw-page__grid-minor { + stroke: rgba(148, 163, 184, 0.2); + stroke-width: 1; +} + +.layout-draw-page__grid-major { + stroke: rgba(100, 116, 139, 0.34); + stroke-width: 1.2; +} + +.layout-draw-page__region { + stroke: transparent; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.layout-draw-page__region--selected { + stroke: rgba(2, 132, 199, 0.86); + stroke-dasharray: 6 4; +} + +.layout-draw-page__line { + stroke: #0f172a; + stroke-width: 2; + stroke-linecap: square; + shape-rendering: crispEdges; + vector-effect: non-scaling-stroke; +} + +.layout-draw-page__line--selected, +.layout-draw-page__rect--selected { + stroke: #0284c7; +} + +.layout-draw-page__line--draft, +.layout-draw-page__rect--draft { + stroke: #0ea5e9; + stroke-dasharray: 8 6; +} + +.layout-draw-page__rect { + fill: rgba(14, 165, 233, 0.08); + stroke: #0f172a; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.layout-draw-page__region { + stroke: none; +} + +.layout-draw-page__region--selected { + stroke: #0284c7; + stroke-width: 2; + stroke-dasharray: 6 4; + vector-effect: non-scaling-stroke; +} + +.layout-draw-page__rect--draft { + fill: rgba(14, 165, 233, 0.12); +} + +.layout-draw-page__shape-label { + fill: #0f172a; + font-size: 14px; + font-weight: 700; + pointer-events: none; + user-select: none; +} + +.layout-draw-page__resize-handle { + fill: #fff; + stroke: #0284c7; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.layout-draw-page__selection-box { + fill: rgba(2, 132, 199, 0.12); + stroke: #0284c7; + stroke-width: 2; + stroke-dasharray: 8 6; + vector-effect: non-scaling-stroke; +} + +.layout-draw-page__component-modal .ant-modal-content { + overflow: hidden; + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 252, 245, 0.98), rgba(255, 255, 255, 0.98)), + linear-gradient(135deg, rgba(191, 219, 254, 0.2), rgba(253, 224, 71, 0.08)); +} + +.layout-draw-page__component-modal .ant-modal-header { + margin-bottom: 12px; + padding-bottom: 0; + background: transparent; +} + +.layout-draw-page__component-modal .ant-modal-body { + display: flex; + max-height: min(76vh, 920px); + flex-direction: column; + gap: 16px; + overflow: hidden; +} + +.layout-draw-page__component-modal-copy { + display: flex; + flex-direction: column; + gap: 6px; +} + +.layout-draw-page__component-modal-copy strong { + color: #0f172a; + font-size: 15px; +} + +.layout-draw-page__component-modal-copy span { + color: #475569; + font-size: 13px; + line-height: 1.5; +} + +.layout-draw-page__component-save-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.layout-draw-page__component-tabs { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.layout-draw-page__component-tabs .ant-tabs-nav { + margin-bottom: 20px; +} + +.layout-draw-page__component-tabs .ant-tabs-nav::before { + border-bottom-color: rgba(226, 232, 240, 0.96); +} + +.layout-draw-page__component-tabs .ant-tabs-tab { + padding: 6px 0 14px; + margin: 0 28px 0 0; + color: #94a3b8; + font-size: 15px; + font-weight: 600; + background: transparent; + border-radius: 0; +} + +.layout-draw-page__component-tabs .ant-tabs-tab-active { + background: transparent; +} + +.layout-draw-page__component-tabs .ant-tabs-tab:hover { + color: #475569; +} + +.layout-draw-page__component-tabs .ant-tabs-tab-active .ant-tabs-tab-btn { + color: #0f172a; +} + +.layout-draw-page__component-tabs .ant-tabs-ink-bar { + height: 2px; + border-radius: 0; + background: #0f172a; +} + +.layout-draw-page__component-tabs .ant-tabs-content-holder, +.layout-draw-page__component-tabs .ant-tabs-content, +.layout-draw-page__component-tabs .ant-tabs-tabpane { + min-height: 0; +} + +.layout-draw-page__component-modal-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; +} + +.layout-draw-page__component-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + overflow: auto; + padding-right: 4px; +} + +.layout-draw-page__component-card { + min-width: 0; + border-radius: 18px; + border-color: rgba(148, 163, 184, 0.22); + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.06); +} + +.layout-draw-page__component-card .ant-card-head { + min-height: 44px; +} + +.layout-draw-page__component-card .ant-card-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.layout-draw-page__component-card-copy { + display: flex; + flex-direction: column; + gap: 8px; +} + +.layout-draw-page__component-card-copy .ant-typography { + margin-bottom: 0; +} + +.layout-draw-page__component-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.layout-draw-page__component-card-preview { + min-width: 0; + overflow: hidden; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: + radial-gradient(circle at top left, rgba(226, 232, 240, 0.56), rgba(255, 255, 255, 0) 44%), + linear-gradient(180deg, #ffffff, #f8fafc); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.92), + 0 12px 28px rgba(15, 23, 42, 0.08); +} + +.layout-draw-page__component-preview-column { + display: flex; + flex-direction: column; + gap: 8px; +} + +.layout-draw-page__component-preview-column--grow { + flex: 1; +} + +.layout-draw-page__component-preview-row { + display: flex; + align-items: center; + gap: 12px; +} + +.layout-draw-page__component-preview-row--tight { + gap: 0; +} + +.layout-draw-page__component-preview-row--end { + justify-content: flex-end; +} + +.layout-draw-page__component-preview-row--form { + align-items: flex-start; +} + +.layout-draw-page__component-preview-shot { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid rgba(226, 232, 240, 0.96); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(248, 250, 252, 0.96)); + box-shadow: 0 18px 36px rgba(148, 163, 184, 0.16); +} + +.layout-draw-page__component-preview-shot--dashboard, +.layout-draw-page__component-preview-shot--detail, +.layout-draw-page__component-preview-shot--stats { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)), + linear-gradient(135deg, rgba(148, 163, 184, 0.12), rgba(255, 255, 255, 0)); +} + +.layout-draw-page__component-preview-shot-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.layout-draw-page__component-preview-eyebrow { + color: #64748b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.layout-draw-page__component-preview-hero-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + padding: 0 18px; + border-radius: 14px; + background: linear-gradient(135deg, #0f172a, #334155); + color: #f8fafc; + font-size: 13px; + font-weight: 700; +} + +.layout-draw-page__component-preview-window { + display: flex; + flex-direction: column; + gap: 14px; + padding: 6px; + border-radius: 16px; + background: linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(255, 255, 255, 0.98)); +} + +.layout-draw-page__component-preview-window-header { + display: flex; + align-items: center; + gap: 10px; +} + +.layout-draw-page__component-preview-window-dots { + display: inline-flex; + gap: 5px; +} + +.layout-draw-page__component-preview-window-dots span { + width: 7px; + height: 7px; + border-radius: 999px; + background: #cbd5e1; +} + +.layout-draw-page__component-preview-window-bar { + flex: 1; + height: 10px; + border-radius: 999px; + background: rgba(226, 232, 240, 0.92); +} + +.layout-draw-page__component-preview-tabbar { + display: flex; + align-items: center; + gap: 24px; + padding: 2px 0 0; + border-bottom: 1px solid rgba(226, 232, 240, 0.96); + color: #64748b; + font-size: 13px; + font-weight: 600; +} + +.layout-draw-page__component-preview-tabbar-active { + position: relative; + color: #0f172a; +} + +.layout-draw-page__component-preview-tabbar-active::after { + content: ''; + position: absolute; + right: 0; + bottom: -9px; + left: 0; + height: 2px; + border-radius: 999px; + background: #0f172a; +} + +.layout-draw-page__component-preview-filter-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.layout-draw-page__component-preview-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.layout-draw-page__component-preview-metric-card { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + padding: 14px 12px; + border: 1px solid rgba(226, 232, 240, 0.96); + border-radius: 16px; + background: rgba(255, 255, 255, 0.96); +} + +.layout-draw-page__component-preview-metric { + color: #0f172a; + font-size: 24px; + font-weight: 700; + line-height: 1; +} + +.layout-draw-page__component-preview-spark { + height: 36px; + border-radius: 12px; + background: + linear-gradient(180deg, rgba(59, 130, 246, 0.12), rgba(59, 130, 246, 0)), + linear-gradient(90deg, rgba(59, 130, 246, 0.18) 10%, rgba(59, 130, 246, 0.7) 45%, rgba(59, 130, 246, 0.2) 100%); +} + +.layout-draw-page__component-preview-spark--green { + background: + linear-gradient(180deg, rgba(34, 197, 94, 0.12), rgba(34, 197, 94, 0)), + linear-gradient(90deg, rgba(34, 197, 94, 0.18) 10%, rgba(34, 197, 94, 0.68) 45%, rgba(34, 197, 94, 0.2) 100%); +} + +.layout-draw-page__component-preview-spark--amber { + background: + linear-gradient(180deg, rgba(249, 115, 22, 0.12), rgba(249, 115, 22, 0)), + linear-gradient(90deg, rgba(249, 115, 22, 0.18) 10%, rgba(249, 115, 22, 0.72) 45%, rgba(249, 115, 22, 0.2) 100%); +} + +.layout-draw-page__component-preview-text-block { + display: flex; + flex-direction: column; + gap: 8px; +} + +.layout-draw-page__component-preview-text-line { + height: 10px; + border-radius: 999px; + background: rgba(203, 213, 225, 0.72); +} + +.layout-draw-page__component-preview-text-line--title { + width: 48%; + height: 14px; + background: rgba(148, 163, 184, 0.9); +} + +.layout-draw-page__component-preview-text-line--short { + width: 62%; +} + +.layout-draw-page__component-preview-divider { + height: 1px; + background: rgba(226, 232, 240, 0.96); +} + +.layout-draw-page__component-preview-property-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.layout-draw-page__component-preview-property-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid rgba(226, 232, 240, 0.96); + border-radius: 12px; + background: rgba(248, 250, 252, 0.92); +} + +.layout-draw-page__component-preview-property-item span { + color: #64748b; + font-size: 11px; + font-weight: 600; +} + +.layout-draw-page__component-preview-property-item strong { + color: #0f172a; + font-size: 12px; + font-weight: 700; +} + +.layout-draw-page__component-preview-button, +.layout-draw-page__component-preview-space, +.layout-draw-page__component-preview-title, +.layout-draw-page__component-preview-caption, +.layout-draw-page__component-preview-segment, +.layout-draw-page__component-preview-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.24); + color: #1e293b; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.layout-draw-page__component-preview-button { + min-width: 84px; + height: 40px; + padding: 0 18px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)); + box-shadow: 0 6px 14px rgba(148, 163, 184, 0.1); +} + +.layout-draw-page__component-preview-button--primary, +.layout-draw-page__component-preview-segment--active, +.layout-draw-page__component-preview-chip--active { + border-color: rgba(59, 130, 246, 0.2); + background: linear-gradient(180deg, #eff6ff, #dbeafe); +} + +.layout-draw-page__component-preview-button--compact { + min-width: 64px; + height: 32px; + padding: 0 14px; +} + +.layout-draw-page__component-preview-space { + min-height: 46px; + justify-content: flex-start; + padding: 0 14px; + border-style: solid; + background: rgba(248, 250, 252, 0.92); +} + +.layout-draw-page__component-preview-space--input { + flex: 1; +} + +.layout-draw-page__component-preview-space--body { + min-height: 56px; +} + +.layout-draw-page__component-preview-title { + justify-content: flex-start; + min-height: 34px; + padding: 0 14px; + border-color: rgba(226, 232, 240, 0.96); + background: rgba(255, 255, 255, 0.98); + font-size: 14px; + font-weight: 700; +} + +.layout-draw-page__component-preview-title--flex { + flex: 1; +} + +.layout-draw-page__component-preview-title--label { + min-width: 76px; +} + +.layout-draw-page__component-preview-caption { + justify-content: flex-start; + min-height: 24px; + width: fit-content; + padding: 0; + border: none; + background: transparent; + font-size: 11px; + font-weight: 500; + color: #64748b; +} + +.layout-draw-page__component-preview-segment { + min-height: 34px; + padding: 0 16px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.94); +} + +.layout-draw-page__component-preview-chip { + min-height: 30px; + padding: 0 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.96); +} + +@media (max-width: 768px) { + .layout-draw-page__toolbar { + gap: 5px; + padding: 5px; + max-width: calc(100vw - 16px); + } + + .layout-draw-page__icon-button { + width: 32px; + min-width: 32px; + height: 32px; + } + + .layout-draw-page__icon-button .anticon, + .layout-draw-page__toolbar-anchor .anticon, + .layout-draw-page__box-handle .anticon { + font-size: 13px; + } + + .layout-draw-page__icon-button--chrome { + width: 24px; + min-width: 24px; + height: 18px; + } + + .layout-draw-page__toolbar-buttons { + gap: 5px; + } + + .layout-draw-page__toolbar-buttons--tools { + flex-direction: row; + } + + .layout-draw-page__toolbar-buttons--actions, + .layout-draw-page__toolbar-buttons--row, + .layout-draw-page__toolbar-buttons--mobile-actions { + flex-direction: row; + max-width: 146px; + } + + .layout-draw-page__label-editor { + max-width: 146px; + } + + .layout-draw-page__paint-palette { + max-width: 146px; + gap: 5px; + } + + .layout-draw-page__paint-swatch { + width: 20px; + height: 20px; + } + + .layout-draw-page__toolbar--mobile-expanded { + gap: 6px; + } + + .layout-draw-page__tool-group { + gap: 4px; + padding: 6px; + } + + .layout-draw-page__toolbar-anchor { + width: 32px; + height: 32px; + } + + .layout-draw-page__action-button--background { + opacity: 0.68; + } + + .layout-draw-page__status { + min-width: min(220px, calc(100vw - 16px)); + max-width: min(260px, calc(100vw - 16px)); + padding: 7px 10px; + } + + .layout-draw-page__panel { + top: auto; + right: 8px; + bottom: calc(56px + max(16px, env(safe-area-inset-bottom))); + left: 8px; + width: auto; + max-height: min(320px, calc(100% - 88px)); + } + + .layout-draw-page__saved-item { + flex-direction: column; + } + + .layout-draw-page__saved-actions { + width: 100%; + flex-direction: row; + } + + .layout-draw-page__saved-actions .ant-btn { + flex: 1; + } + + .layout-draw-page__component-modal { + max-width: calc(100vw - 16px); + } + + .layout-draw-page__component-modal .ant-modal-body { + max-height: min(78vh, calc(100vh - 96px)); + gap: 12px; + } + + .layout-draw-page__component-tabs .ant-tabs-nav { + margin-bottom: 10px; + } + + .layout-draw-page__component-tabs .ant-tabs-tab { + margin-right: 16px; + font-size: 13px; + padding-bottom: 10px; + } + + .layout-draw-page__component-grid { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + .layout-draw-page__component-card-preview { + padding: 10px; + } + + .layout-draw-page__component-card-meta { + align-items: stretch; + flex-direction: column; + } + + .layout-draw-page__component-preview-row { + flex-wrap: wrap; + gap: 8px; + } + + .layout-draw-page__component-preview-card-grid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/features/layout/draw/LayoutDrawPage.tsx b/src/features/layout/draw/LayoutDrawPage.tsx new file mode 100644 index 0000000..c28ec56 --- /dev/null +++ b/src/features/layout/draw/LayoutDrawPage.tsx @@ -0,0 +1,3516 @@ +import { + AppstoreOutlined, + BgColorsOutlined, + BorderOutlined, + CopyOutlined, + DeleteOutlined, + DisconnectOutlined, + DragOutlined, + EyeInvisibleOutlined, + FolderOpenOutlined, + InfoCircleOutlined, + LineOutlined, + LinkOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PlusOutlined, + PushpinOutlined, + RedoOutlined, + SaveOutlined, + UndoOutlined, +} from '@ant-design/icons'; +import { Button, Card, Empty, Input, Modal, Spin, Tabs, Typography, message } from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + commitLayoutDrawHistory, + createLayoutDrawHistoryState, + redoLayoutDrawHistory, + undoLayoutDrawHistory, +} from './layoutDrawHistory'; +import { findRegionAtPoint, resolveDrawRegions, splitDrawShapes } from './layoutDrawRegions'; +import { duplicateShapeWithLabel } from './layoutDrawShapeUtils'; +import { + deleteLayoutDrawComponent, + listSavedLayoutDrawComponents, + saveLayoutDrawComponent, +} from './layoutDrawComponentStorage'; +import { + clampSelectionRect, + findShapesInSelection, + rebaseShapesToComponentBlueprint, + resolveGroupedShapeIds, +} from './layoutDrawSelectionUtils'; +import { deleteLayoutDraw, listSavedLayoutDraws, saveLayoutDraw } from './layoutDrawStorage'; +import type { + BackgroundMode, + DrawLine, + DrawRect, + DrawRegion, + DrawShape, + DrawTool, + DrawableShape, + SavedLayoutDrawComponentRecord, + SavedLayoutDrawRecord, +} from './layoutDrawTypes'; +import { resolveLineDraft, type LineOrientation } from './lineDraft'; +import './LayoutDrawPage.css'; + +type DraftState = + | null + | { + type: 'line'; + startX: number; + startY: number; + x1: number; + y1: number; + orientation: LineOrientation | null; + x2: number; + y2: number; + } + | { + type: 'rect'; + startX: number; + startY: number; + endX: number; + endY: number; + }; + +type DragState = + | null + | { + shapeIds: string[]; + pointerX: number; + pointerY: number; + }; + +type SelectionBoxState = + | null + | { + startX: number; + startY: number; + endX: number; + endY: number; + additive: boolean; + }; + +type ResizeHandle = + | 'n' + | 's' + | 'e' + | 'w' + | 'ne' + | 'nw' + | 'se' + | 'sw' + | 'line-start' + | 'line-end'; + +type ResizeState = + | null + | { + shapeId: string; + handle: ResizeHandle; + originX: number; + originY: number; + baseShape: DrawableShape; + groupedIds: Set; + baseShapes: DrawableShape[]; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + }; + +type SelectedTarget = null | { kind: 'shape'; id: string } | { kind: 'region'; regionKey: string }; + +type CanvasSize = { + width: number; + height: number; +}; + +type FloatingBoxKey = 'tools' | 'actions' | 'guide'; + +type FloatingBoxPosition = { + x: number; + y: number; +}; + +type FloatingBoxSize = { + width: number; + height: number; +}; + +type AttachableToolbarKey = 'tools' | 'actions'; + +const DRAW_LINE_HIT_TOLERANCE = 12; +const DRAW_RECT_HIT_PADDING = 8; +const DRAW_LINE_ORIENTATION_LOCK_DISTANCE = 10; +const DRAW_RECT_MIN_SIZE = 24; +const DRAW_RESIZE_HANDLE_SIZE = 10; +const ATTACHED_TOOLBAR_IDLE_HIDE_MS = 12000; +const RECT_DEFAULT_FILL_COLOR = 'rgba(147, 197, 253, 0.22)'; +const PAINT_COLOR_OPTIONS = [ + { value: '#f9e2d2', label: '피치' }, + { value: '#f7edc6', label: '버터' }, + { value: '#d8e9fb', label: '스카이' }, + { value: '#d8f2e1', label: '민트' }, + { value: '#f8d8e5', label: '로즈' }, + { value: '#e7ddfb', label: '라일락' }, + { value: 'transparent', label: '지우기' }, +] as const; +const DRAW_TOOL_OPTIONS = [ + { key: 'select', icon: }, + { key: 'line', icon: }, + { key: 'rect', icon: }, + { key: 'paint', icon: }, +] as const; + +function resolveRectResizeHandles(shape: DrawRect) { + const left = shape.x; + const right = shape.x + shape.width; + const top = shape.y; + const bottom = shape.y + shape.height; + const midX = shape.x + shape.width / 2; + const midY = shape.y + shape.height / 2; + + return [ + { handle: 'nw' as const, x: left, y: top, cursor: 'nwse-resize' }, + { handle: 'n' as const, x: midX, y: top, cursor: 'ns-resize' }, + { handle: 'ne' as const, x: right, y: top, cursor: 'nesw-resize' }, + { handle: 'e' as const, x: right, y: midY, cursor: 'ew-resize' }, + { handle: 'se' as const, x: right, y: bottom, cursor: 'nwse-resize' }, + { handle: 's' as const, x: midX, y: bottom, cursor: 'ns-resize' }, + { handle: 'sw' as const, x: left, y: bottom, cursor: 'nesw-resize' }, + { handle: 'w' as const, x: left, y: midY, cursor: 'ew-resize' }, + ]; +} + +function resolveLineResizeHandles(shape: DrawLine) { + return [ + { + handle: 'line-start' as const, + x: shape.x1, + y: shape.y1, + cursor: shape.orientation === 'horizontal' ? 'ew-resize' : 'ns-resize', + }, + { + handle: 'line-end' as const, + x: shape.x2, + y: shape.y2, + cursor: shape.orientation === 'horizontal' ? 'ew-resize' : 'ns-resize', + }, + ]; +} + +function createShapeId(prefix: string) { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `${prefix}-${crypto.randomUUID()}`; + } + + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function clampRect(startX: number, startY: number, endX: number, endY: number) { + const x = Math.min(startX, endX); + const y = Math.min(startY, endY); + const width = Math.abs(endX - startX); + const height = Math.abs(endY - startY); + return { x, y, width, height }; +} + +function resolveCanvasSize(element: SVGSVGElement): CanvasSize { + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return { + width: rect.width, + height: rect.height, + }; + } + + const viewBox = element.viewBox.baseVal; + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { + width: viewBox.width, + height: viewBox.height, + }; + } + + return { + width: rect.width || element.clientWidth || 1, + height: rect.height || element.clientHeight || 1, + }; +} + +function resolveSvgPoint(event: React.PointerEvent, element: SVGSVGElement) { + const rect = element.getBoundingClientRect(); + const canvasSize = resolveCanvasSize(element); + return { + x: ((event.clientX - rect.left) / Math.max(rect.width, 1)) * canvasSize.width, + y: ((event.clientY - rect.top) / Math.max(rect.height, 1)) * canvasSize.height, + }; +} + +function moveShape(shape: DrawableShape, dx: number, dy: number): DrawableShape { + if (shape.type === 'line') { + return { + ...shape, + x1: shape.x1 + dx, + y1: shape.y1 + dy, + x2: shape.x2 + dx, + y2: shape.y2 + dy, + }; + } + + return { + ...shape, + x: shape.x + dx, + y: shape.y + dy, + }; +} + +function clampSize(value: number, minimum: number) { + return Math.max(minimum, value); +} + +function resizeLine(shape: DrawLine, handle: ResizeHandle, pointerX: number, pointerY: number): DrawLine { + if (shape.orientation === 'horizontal') { + if (handle === 'line-start') { + const nextX1 = Math.min(pointerX, shape.x2 - 1); + return { + ...shape, + x1: nextX1, + y1: shape.y2, + }; + } + + const nextX2 = Math.max(pointerX, shape.x1 + 1); + return { + ...shape, + x2: nextX2, + y2: shape.y1, + }; + } + + if (handle === 'line-start') { + const nextY1 = Math.min(pointerY, shape.y2 - 1); + return { + ...shape, + x1: shape.x2, + y1: nextY1, + }; + } + + const nextY2 = Math.max(pointerY, shape.y1 + 1); + return { + ...shape, + x2: shape.x1, + y2: nextY2, + }; +} + +function resizeRect(shape: DrawRect, handle: ResizeHandle, pointerX: number, pointerY: number): DrawRect { + const left = shape.x; + const right = shape.x + shape.width; + const top = shape.y; + const bottom = shape.y + shape.height; + + let nextLeft = left; + let nextRight = right; + let nextTop = top; + let nextBottom = bottom; + + if (handle.includes('w')) { + nextLeft = Math.min(pointerX, right - DRAW_RECT_MIN_SIZE); + } + if (handle.includes('e')) { + nextRight = Math.max(pointerX, left + DRAW_RECT_MIN_SIZE); + } + if (handle.includes('n')) { + nextTop = Math.min(pointerY, bottom - DRAW_RECT_MIN_SIZE); + } + if (handle.includes('s')) { + nextBottom = Math.max(pointerY, top + DRAW_RECT_MIN_SIZE); + } + + return { + ...shape, + x: nextLeft, + y: nextTop, + width: clampSize(nextRight - nextLeft, DRAW_RECT_MIN_SIZE), + height: clampSize(nextBottom - nextTop, DRAW_RECT_MIN_SIZE), + }; +} + +function resizeShape(shape: DrawableShape, handle: ResizeHandle, pointerX: number, pointerY: number): DrawableShape { + if (shape.type === 'line') { + return resizeLine(shape, handle, pointerX, pointerY); + } + + return resizeRect(shape, handle, pointerX, pointerY); +} + +function resolveShapeBounds(shape: DrawableShape) { + if (shape.type === 'line') { + return { + x: Math.min(shape.x1, shape.x2), + y: Math.min(shape.y1, shape.y2), + width: Math.max(1, Math.abs(shape.x2 - shape.x1)), + height: Math.max(1, Math.abs(shape.y2 - shape.y1)), + }; + } + + return { + x: shape.x, + y: shape.y, + width: shape.width, + height: shape.height, + }; +} + +function resolveDrawableShapesBounds(shapes: DrawableShape[]) { + if (shapes.length === 0) { + return null; + } + + const firstBounds = resolveShapeBounds(shapes[0]); + let left = firstBounds.x; + let top = firstBounds.y; + let right = firstBounds.x + firstBounds.width; + let bottom = firstBounds.y + firstBounds.height; + + shapes.slice(1).forEach((shape) => { + const bounds = resolveShapeBounds(shape); + left = Math.min(left, bounds.x); + top = Math.min(top, bounds.y); + right = Math.max(right, bounds.x + bounds.width); + bottom = Math.max(bottom, bounds.y + bounds.height); + }); + + return { + x: left, + y: top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; +} + +function resizeGroupedShapes( + shapes: DrawableShape[], + bounds: { x: number; y: number; width: number; height: number }, + handle: ResizeHandle, + pointerX: number, + pointerY: number, +) { + const nextBounds = resizeRect( + { + id: 'group-resize-frame', + type: 'rect', + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + label: '', + }, + handle, + pointerX, + pointerY, + ); + const scaleX = nextBounds.width / Math.max(bounds.width, 1); + const scaleY = nextBounds.height / Math.max(bounds.height, 1); + + return shapes.map((shape) => { + if (shape.type === 'line') { + return { + ...shape, + x1: nextBounds.x + (shape.x1 - bounds.x) * scaleX, + y1: nextBounds.y + (shape.y1 - bounds.y) * scaleY, + x2: nextBounds.x + (shape.x2 - bounds.x) * scaleX, + y2: nextBounds.y + (shape.y2 - bounds.y) * scaleY, + }; + } + + return { + ...shape, + x: nextBounds.x + (shape.x - bounds.x) * scaleX, + y: nextBounds.y + (shape.y - bounds.y) * scaleY, + width: Math.max(8, shape.width * scaleX), + height: Math.max(8, shape.height * scaleY), + }; + }); +} + +function resolveRectFillColor(shape: DrawRect) { + if (!shape.fillColor || shape.fillColor === 'transparent') { + return RECT_DEFAULT_FILL_COLOR; + } + + return shape.fillColor; +} + +function resolveLineLabelPosition(line: DrawLine) { + return { + x: (line.x1 + line.x2) / 2, + y: (line.y1 + line.y2) / 2, + }; +} + +function resolveRectLabelPosition(shape: DrawRect) { + return { + x: shape.x + shape.width / 2, + y: shape.y + shape.height / 2, + }; +} + +function isPointNearLine(shape: DrawLine, x: number, y: number, tolerance = DRAW_LINE_HIT_TOLERANCE) { + if (shape.orientation === 'horizontal') { + const minX = Math.min(shape.x1, shape.x2) - tolerance; + const maxX = Math.max(shape.x1, shape.x2) + tolerance; + return x >= minX && x <= maxX && Math.abs(y - shape.y1) <= tolerance; + } + + const minY = Math.min(shape.y1, shape.y2) - tolerance; + const maxY = Math.max(shape.y1, shape.y2) + tolerance; + return y >= minY && y <= maxY && Math.abs(x - shape.x1) <= tolerance; +} + +function isPointInsideRect(shape: DrawRect, x: number, y: number, padding = DRAW_RECT_HIT_PADDING) { + return ( + x >= shape.x - padding && + x <= shape.x + shape.width + padding && + y >= shape.y - padding && + y <= shape.y + shape.height + padding + ); +} + +function findShapeAtPoint(shapes: DrawableShape[], x: number, y: number) { + for (let index = shapes.length - 1; index >= 0; index -= 1) { + const shape = shapes[index]; + const isHit = + shape.type === 'line' ? isPointNearLine(shape, x, y) : isPointInsideRect(shape, x, y); + + if (isHit) { + return shape; + } + } + + return null; +} + +const DRAW_HISTORY_GUARD_KEY = '__layoutDrawBackGestureGuard'; +const DRAW_EDGE_GESTURE_HOTZONE_PX = 28; +const DRAW_EDGE_GESTURE_MIN_SWIPE_PX = 10; +const DRAW_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX = 56; +const DRAW_BACKGROUND_MODE_STORAGE_KEY = 'layout-draw-background-mode'; +const INITIAL_BACKGROUND_MODE: BackgroundMode = 'grid'; +const MOBILE_LAYOUT_BREAKPOINT_PX = 768; +const FLOATING_BOX_BASE_Z_INDEX = 8; +const COMPONENT_LIBRARY_SECTIONS = [ + { + key: 'featured', + label: '추천 화면', + presetIds: ['filter-toolbar', 'content-card', 'stats-summary', 'detail-panel'], + }, + { + key: 'form', + label: '폼 · 상태', + presetIds: ['section-header', 'field-row', 'segmented-switch', 'chip-filter-row'], + }, + { + key: 'action', + label: '액션 · 탐색', + presetIds: ['title-with-actions', 'primary-button', 'button-row', 'footer-actions', 'toggle-button'], + }, +] as const; + +type ComponentLibrarySectionKey = (typeof COMPONENT_LIBRARY_SECTIONS)[number]['key'] | 'saved'; + +type ComponentPreviewKind = + | 'single-button' + | 'button-row' + | 'footer-actions' + | 'button-space' + | 'toggle-button' + | 'segmented-switch' + | 'chip-filter-row' + | 'title-label' + | 'section-header' + | 'title-with-actions' + | 'field-row' + | 'content-card' + | 'filter-toolbar' + | 'stats-summary' + | 'detail-panel'; + +type ComponentLibraryItem = { + presetId: string; + title: string; + description: string; + category: string; + previewKind: ComponentPreviewKind; + blueprints: DrawableShape[]; + source?: 'preset' | 'saved'; + savedRecordId?: string; +}; + +const COMPONENT_LIBRARY_PRESETS = [ + { + presetId: 'primary-button', + title: '기본 버튼', + description: '단일 CTA 버튼을 빠르게 깔아 두고 길이, 강조색, 라벨만 바로 조정할 때 씁니다.', + category: '버튼/액션', + previewKind: 'single-button', + blueprints: [{ type: 'rect', x: 0, y: 0, width: 168, height: 44, label: '버튼', fillColor: '#d8e9fb' }], + }, + { + presetId: 'button-row', + title: '버튼 묶음', + description: '나란한 액션 2개를 한 번에 배치할 때 쓰는 기본 간격 프리셋입니다.', + category: '버튼/액션', + previewKind: 'button-row', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 112, height: 44, label: '취소' }, + { type: 'rect', x: 128, y: 0, width: 112, height: 44, label: '확인', fillColor: '#d8e9fb' }, + ], + }, + { + presetId: 'footer-actions', + title: '하단 액션바', + description: '취소/저장 같은 하단 고정 액션 블록을 도면 하단에 바로 깔 수 있습니다.', + category: '버튼/액션', + previewKind: 'footer-actions', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 304, height: 68, label: '하단 액션 영역' }, + { type: 'rect', x: 20, y: 12, width: 120, height: 44, label: '취소' }, + { type: 'rect', x: 164, y: 12, width: 120, height: 44, label: '저장', fillColor: '#d8e9fb' }, + ], + }, + { + presetId: 'button-space', + title: '버튼 공간', + description: '추후 버튼을 넣을 자리만 먼저 잡아 두고 여백 구조를 본 뒤 채우는 용도입니다.', + category: '영역/레이아웃', + previewKind: 'button-space', + blueprints: [{ type: 'rect', x: 0, y: 0, width: 224, height: 60, label: '버튼 공간', fillColor: '#f8f8f2' }], + }, + { + presetId: 'toggle-button', + title: '토글 버튼', + description: '켜짐/꺼짐이나 보기 전환처럼 2상태 액션을 직관적으로 배치할 때 적합합니다.', + category: '토글/선택', + previewKind: 'toggle-button', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 112, height: 44, label: 'ON', fillColor: '#d8f2e1' }, + { type: 'rect', x: 112, y: 0, width: 112, height: 44, label: 'OFF' }, + ], + }, + { + presetId: 'segmented-switch', + title: '분할 선택', + description: '리스트 상단 보기 전환처럼 텍스트 중심 상태 전환 바를 빠르게 배치합니다.', + category: '토글/선택', + previewKind: 'segmented-switch', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 312, height: 52, label: '상단 전환 바', fillColor: '#ffffff' }, + { type: 'rect', x: 20, y: 12, width: 64, height: 20, label: '전체', fillColor: '#f8fafc' }, + { type: 'rect', x: 124, y: 12, width: 64, height: 20, label: '웹', fillColor: '#f8fafc' }, + { type: 'rect', x: 228, y: 12, width: 64, height: 20, label: '앱', fillColor: '#f8fafc' }, + { type: 'rect', x: 124, y: 38, width: 64, height: 2, label: '', fillColor: '#93c5fd' }, + ], + }, + { + presetId: 'chip-filter-row', + title: '필터 칩 묶음', + description: '상태 필터와 건수 배지가 함께 있는 필터 바를 빠르게 깔 수 있습니다.', + category: '토글/선택', + previewKind: 'chip-filter-row', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 94, height: 32, label: '전체 24', fillColor: '#dbeafe' }, + { type: 'rect', x: 106, y: 0, width: 108, height: 32, label: '진행중 12', fillColor: '#ffffff' }, + { type: 'rect', x: 226, y: 0, width: 94, height: 32, label: '대기 4', fillColor: '#ffffff' }, + ], + }, + { + presetId: 'title-label', + title: '타이틀 라벨', + description: '섹션명이나 카드 헤더용 라벨을 바로 올려 둘 수 있는 최소 단위입니다.', + category: '타이틀/라벨', + previewKind: 'title-label', + blueprints: [{ type: 'rect', x: 0, y: 0, width: 196, height: 36, label: '타이틀 라벨', fillColor: '#f7edc6' }], + }, + { + presetId: 'section-header', + title: '섹션 헤더', + description: '업무 화면 상단 제목, 설명, 우측 액션까지 포함한 헤더를 한 번에 배치합니다.', + category: '타이틀/라벨', + previewKind: 'section-header', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 328, height: 78, label: '섹션 헤더', fillColor: '#ffffff' }, + { type: 'rect', x: 20, y: 18, width: 136, height: 22, label: '섹션 제목', fillColor: '#fef3c7' }, + { type: 'rect', x: 20, y: 48, width: 164, height: 14, label: '보조 설명', fillColor: '#f8fafc' }, + { type: 'rect', x: 236, y: 16, width: 72, height: 32, label: '추가', fillColor: '#dbeafe' }, + ], + }, + { + presetId: 'title-with-actions', + title: '타이틀 + 액션', + description: '업무 목록 헤더와 우측 등록 버튼을 카드형 헤더로 바로 배치합니다.', + category: '타이틀/라벨', + previewKind: 'title-with-actions', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 320, height: 60, label: '목록 헤더', fillColor: '#ffffff' }, + { type: 'rect', x: 18, y: 16, width: 148, height: 20, label: '프로젝트 목록', fillColor: '#fef3c7' }, + { type: 'rect', x: 232, y: 12, width: 70, height: 32, label: '등록', fillColor: '#dbeafe' }, + ], + }, + { + presetId: 'field-row', + title: '라벨 + 입력 영역', + description: '입력 라벨과 필드, 도움 문구까지 포함한 폼 행을 빠르게 만듭니다.', + category: '영역/레이아웃', + previewKind: 'field-row', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 360, height: 84, label: '폼 행', fillColor: '#ffffff' }, + { type: 'rect', x: 18, y: 18, width: 88, height: 18, label: '이름', fillColor: '#fef3c7' }, + { type: 'rect', x: 122, y: 12, width: 220, height: 34, label: '입력 값', fillColor: '#ffffff' }, + { type: 'rect', x: 122, y: 56, width: 146, height: 12, label: '설명', fillColor: '#f8fafc' }, + ], + }, + { + presetId: 'filter-toolbar', + title: '업무 목록 헤더', + description: '검색, 필터, 요약 탭, 우측 CTA까지 들어간 실무형 목록 헤더 화면을 바로 깔아 둡니다.', + category: '추천 화면', + previewKind: 'filter-toolbar', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 396, height: 212, label: '', fillColor: '#ffffff' }, + { type: 'rect', x: 0, y: 0, width: 396, height: 44, label: '', fillColor: '#f8fafc' }, + { type: 'rect', x: 20, y: 16, width: 112, height: 12, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 302, y: 10, width: 74, height: 24, label: '등록', fillColor: '#dbeafe' }, + { type: 'rect', x: 20, y: 62, width: 184, height: 14, label: '', fillColor: '#1e293b' }, + { type: 'rect', x: 20, y: 84, width: 118, height: 8, label: '', fillColor: '#cbd5e1' }, + { type: 'rect', x: 20, y: 112, width: 174, height: 34, label: '', fillColor: '#f8fafc' }, + { type: 'rect', x: 206, y: 112, width: 72, height: 34, label: '', fillColor: '#f8fafc' }, + { type: 'rect', x: 290, y: 112, width: 86, height: 34, label: '', fillColor: '#f8fafc' }, + { type: 'rect', x: 20, y: 164, width: 76, height: 26, label: '', fillColor: '#e2e8f0' }, + { type: 'rect', x: 108, y: 164, width: 92, height: 26, label: '', fillColor: '#eff6ff' }, + { type: 'rect', x: 212, y: 164, width: 82, height: 26, label: '', fillColor: '#e2e8f0' }, + { type: 'rect', x: 306, y: 164, width: 44, height: 26, label: '', fillColor: '#e2e8f0' }, + ], + }, + { + presetId: 'stats-summary', + title: '대시보드 요약', + description: '수치 카드와 추이 바가 섞인 대시보드 상단 영역을 디자인된 상태로 바로 배치합니다.', + category: '추천 화면', + previewKind: 'stats-summary', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 396, height: 226, label: '', fillColor: '#ffffff' }, + { type: 'rect', x: 20, y: 18, width: 108, height: 12, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 20, y: 42, width: 76, height: 8, label: '', fillColor: '#cbd5e1' }, + { type: 'rect', x: 20, y: 76, width: 110, height: 122, label: '', fillColor: '#eff6ff' }, + { type: 'rect', x: 143, y: 76, width: 110, height: 122, label: '', fillColor: '#ecfdf5' }, + { type: 'rect', x: 266, y: 76, width: 110, height: 122, label: '', fillColor: '#fff7ed' }, + { type: 'rect', x: 36, y: 96, width: 48, height: 8, label: '', fillColor: '#94a3b8' }, + { type: 'rect', x: 36, y: 118, width: 58, height: 20, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 36, y: 156, width: 58, height: 18, label: '', fillColor: '#bfdbfe' }, + { type: 'rect', x: 159, y: 96, width: 48, height: 8, label: '', fillColor: '#94a3b8' }, + { type: 'rect', x: 159, y: 118, width: 62, height: 20, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 159, y: 156, width: 58, height: 18, label: '', fillColor: '#bbf7d0' }, + { type: 'rect', x: 282, y: 96, width: 48, height: 8, label: '', fillColor: '#94a3b8' }, + { type: 'rect', x: 282, y: 118, width: 42, height: 20, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 282, y: 156, width: 58, height: 18, label: '', fillColor: '#fed7aa' }, + ], + }, + { + presetId: 'content-card', + title: '업무 카드', + description: '썸네일, 상태, 설명, CTA가 같이 있는 실무형 카드 블록을 바로 배치합니다.', + category: '추천 화면', + previewKind: 'content-card', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 348, height: 220, label: '', fillColor: '#ffffff' }, + { type: 'rect', x: 18, y: 18, width: 84, height: 82, label: '', fillColor: '#dbeafe' }, + { type: 'rect', x: 120, y: 18, width: 62, height: 10, label: '', fillColor: '#cbd5e1' }, + { type: 'rect', x: 120, y: 38, width: 146, height: 16, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 120, y: 64, width: 182, height: 8, label: '', fillColor: '#cbd5e1' }, + { type: 'rect', x: 120, y: 80, width: 164, height: 8, label: '', fillColor: '#cbd5e1' }, + { type: 'rect', x: 18, y: 122, width: 312, height: 1, label: '', fillColor: '#e2e8f0' }, + { type: 'rect', x: 18, y: 142, width: 66, height: 24, label: '', fillColor: '#f1f5f9' }, + { type: 'rect', x: 94, y: 142, width: 74, height: 24, label: '', fillColor: '#f1f5f9' }, + { type: 'rect', x: 236, y: 138, width: 94, height: 32, label: '열기', fillColor: '#dbeafe' }, + { type: 'rect', x: 18, y: 186, width: 132, height: 10, label: '', fillColor: '#cbd5e1' }, + ], + }, + { + presetId: 'detail-panel', + title: '상세 사이드 패널', + description: '헤더, 본문, 메타, 하단 액션까지 정리된 우측 상세 패널을 화면형 구조로 배치합니다.', + category: '추천 화면', + previewKind: 'detail-panel', + blueprints: [ + { type: 'rect', x: 0, y: 0, width: 356, height: 286, label: '', fillColor: '#ffffff' }, + { type: 'rect', x: 0, y: 0, width: 356, height: 54, label: '', fillColor: '#f8fafc' }, + { type: 'rect', x: 20, y: 20, width: 122, height: 12, label: '', fillColor: '#0f172a' }, + { type: 'rect', x: 20, y: 72, width: 316, height: 96, label: '', fillColor: '#f8fafc' }, + { type: 'rect', x: 20, y: 186, width: 54, height: 8, label: '', fillColor: '#94a3b8' }, + { type: 'rect', x: 92, y: 178, width: 68, height: 22, label: '', fillColor: '#dbeafe' }, + { type: 'rect', x: 20, y: 220, width: 54, height: 8, label: '', fillColor: '#94a3b8' }, + { type: 'rect', x: 92, y: 212, width: 82, height: 22, label: '', fillColor: '#f1f5f9' }, + { type: 'rect', x: 20, y: 252, width: 112, height: 1, label: '', fillColor: '#e2e8f0' }, + { type: 'rect', x: 236, y: 244, width: 100, height: 30, label: '확인', fillColor: '#dbeafe' }, + ], + }, +] as const satisfies readonly ComponentLibraryItem[]; + +const { Paragraph, Text } = Typography; + +function resolveBlueprintBounds(blueprints: DrawableShape[]) { + const right = Math.max( + ...blueprints.map((blueprint) => (blueprint.type === 'line' ? Math.max(blueprint.x1, blueprint.x2) : blueprint.x + blueprint.width)), + ); + const bottom = Math.max( + ...blueprints.map((blueprint) => (blueprint.type === 'line' ? Math.max(blueprint.y1, blueprint.y2) : blueprint.y + blueprint.height)), + ); + return { width: right, height: bottom }; +} + +function renderComponentPreview(kind: ComponentPreviewKind) { + if (kind === 'single-button') { + return ( +
+
+ Quick Action +
+
승인 요청
+
+ ); + } + + if (kind === 'button-row') { + return ( +
+
+ Modal Footer +
+
+
닫기
+
저장
+
+
+ ); + } + + if (kind === 'footer-actions') { + return ( +
+
+
+
+
+
+
+
취소
+
저장
+
+
+ ); + } + + if (kind === 'button-space') { + return ( +
+
버튼 영역 예약
+
+ ); + } + + if (kind === 'toggle-button') { + return ( +
+
+ Mode +
+
+
편집
+
미리보기
+
+
+ ); + } + + if (kind === 'segmented-switch') { + return ( +
+
+ 전체 + + +
+
+ ); + } + + if (kind === 'chip-filter-row') { + return ( +
+
+
전체 24
+
진행중 12
+
대기 4
+
+
+ ); + } + + if (kind === 'title-label') { + return ( +
+
타이틀 라벨
+
+ ); + } + + if (kind === 'section-header') { + return ( +
+
+
+
섹션 제목
+
보조 설명
+
+
추가
+
+
+ ); + } + + if (kind === 'title-with-actions') { + return ( +
+
+
프로젝트 목록
+
등록
+
+
+ ); + } + + if (kind === 'field-row') { + return ( +
+
+
담당자
+
+
이름을 입력하세요
+
필수 입력 항목
+
+
+
+ ); + } + + if (kind === 'filter-toolbar') { + return ( +
+
+
+
+ + + +
+
+
+
+
+
프로젝트 목록
+
검색과 필터를 함께 쓰는 업무형 상단 레이아웃
+
+
등록
+
+
+
검색
+
담당자
+
상태
+
+
+ 전체 + 진행중 + 검수 + 보류 +
+
+
+ ); + } + + if (kind === 'stats-summary') { + return ( +
+
+ Dashboard +
주간 현황
+
+
+
+
총 요청
+
128
+
+
+
+
완료율
+
92%
+
+
+
+
지연 건
+
6
+
+
+
+
+ ); + } + + if (kind === 'detail-panel') { + return ( +
+
+
상세 정보
+
닫기
+
+
선택한 항목의 설명과 메타 정보를 보여주는 영역
+
+
+ 상태 + 진행중 +
+
+ 담당 + 디자인팀 +
+
+
+
+
메모
+
확인
+
+
+ ); + } + + return ( +
+
제목
+
설명 영역
+
+
액션
+
+
+ ); +} + +function resolveFloatingBoxDefaultPosition(key: FloatingBoxKey, viewportWidth: number, viewportHeight: number) { + const isMobile = viewportWidth <= MOBILE_LAYOUT_BREAKPOINT_PX; + const topInset = isMobile ? 8 : 12; + const sideInset = isMobile ? 8 : 12; + const mobileBottomY = Math.max(sideInset, viewportHeight - 62); + + if (key === 'tools') { + return isMobile + ? { + x: sideInset, + y: mobileBottomY, + } + : { + x: sideInset, + y: topInset, + }; + } + + if (key === 'actions') { + return isMobile + ? { + x: Math.max(sideInset, viewportWidth - 56), + y: mobileBottomY, + } + : { + x: Math.max(sideInset, viewportWidth - 64), + y: topInset, + }; + } + + return isMobile + ? { + x: sideInset, + y: topInset, + } + : { + x: 72, + y: topInset, + }; +} + +function clampFloatingBoxPosition( + position: FloatingBoxPosition, + element: HTMLElement | null, + boundary: HTMLElement | null, + sizeOverride?: FloatingBoxSize, +) { + if (typeof window === 'undefined') { + return position; + } + + const boundaryWidth = boundary?.clientWidth ?? window.innerWidth; + const boundaryHeight = boundary?.clientHeight ?? window.innerHeight; + const width = sizeOverride?.width ?? element?.offsetWidth ?? 0; + const height = sizeOverride?.height ?? element?.offsetHeight ?? 0; + const maxX = Math.max(0, boundaryWidth - width); + const maxY = Math.max(0, boundaryHeight - height); + + return { + x: Math.min(Math.max(0, position.x), maxX), + y: Math.min(Math.max(0, position.y), maxY), + }; +} + +function resolveFloatingBoxSize(element: HTMLElement | null, fallback: FloatingBoxSize): FloatingBoxSize { + return { + width: element?.offsetWidth ?? fallback.width, + height: element?.offsetHeight ?? fallback.height, + }; +} + +function snapFloatingBoxToClosestEdge( + position: FloatingBoxPosition, + size: FloatingBoxSize, + boundary: HTMLElement | null, +) { + const clamped = clampFloatingBoxPosition(position, null, boundary, size); + + if (typeof window === 'undefined') { + return clamped; + } + + const boundaryWidth = boundary?.clientWidth ?? window.innerWidth; + const boundaryHeight = boundary?.clientHeight ?? window.innerHeight; + const maxX = Math.max(0, boundaryWidth - size.width); + const maxY = Math.max(0, boundaryHeight - size.height); + const distances = [ + { edge: 'left', distance: clamped.x }, + { edge: 'right', distance: maxX - clamped.x }, + { edge: 'top', distance: clamped.y }, + { edge: 'bottom', distance: maxY - clamped.y }, + ] as const; + const nearestEdge = distances.reduce((closest, candidate) => + candidate.distance < closest.distance ? candidate : closest, + ); + + if (nearestEdge.edge === 'left') { + return { x: 0, y: clamped.y }; + } + + if (nearestEdge.edge === 'right') { + return { x: maxX, y: clamped.y }; + } + + if (nearestEdge.edge === 'top') { + return { x: clamped.x, y: 0 }; + } + + return { x: clamped.x, y: maxY }; +} + +function createInitialDocument(backgroundMode: BackgroundMode) { + return { + backgroundMode, + shapes: [], + }; +} + +function createSavedDrawId() { + return createShapeId('draw'); +} + +function createDefaultSaveName() { + const now = new Date(); + const pad = (value: number) => value.toString().padStart(2, '0'); + return `도면 ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`; +} + +function cloneShape(shape: DrawShape): DrawShape { + return { ...shape }; +} + +function isDrawableShape(shape: DrawShape): shape is DrawableShape { + return shape.type === 'line' || shape.type === 'rect'; +} + +function isRegionShape(shape: DrawShape): shape is DrawRegion { + return shape.type === 'region'; +} + +function formatSavedAt(value: string) { + const time = Date.parse(value); + if (Number.isNaN(time)) { + return value; + } + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(time)); +} + +export function LayoutDrawPage() { + const pageRef = useRef(null); + const svgRef = useRef(null); + const floatingBoxRefs = useRef>({ + tools: null, + actions: null, + guide: null, + }); + const [canvasSize, setCanvasSize] = useState({ width: 1, height: 1 }); + const [tool, setTool] = useState('select'); + const [historyState, setHistoryState] = useState(() => + createLayoutDrawHistoryState( + createInitialDocument( + (() => { + if (typeof window === 'undefined') { + return INITIAL_BACKGROUND_MODE; + } + + return window.localStorage.getItem(DRAW_BACKGROUND_MODE_STORAGE_KEY) === 'plain' ? 'plain' : 'grid'; + })(), + ), + ), + ); + const [selectedTarget, setSelectedTarget] = useState(null); + const [selectedShapeIdsState, setSelectedShapeIdsState] = useState([]); + const [draft, setDraft] = useState(null); + const [dragState, setDragState] = useState(null); + const [selectionBox, setSelectionBox] = useState(null); + const [resizeState, setResizeState] = useState(null); + const [liveShapes, setLiveShapes] = useState(null); + const [storagePanel, setStoragePanel] = useState<'save' | 'load' | null>(null); + const [saveName, setSaveName] = useState(() => createDefaultSaveName()); + const [savedDraws, setSavedDraws] = useState([]); + const [savedComponents, setSavedComponents] = useState([]); + const [isStorageLoading, setIsStorageLoading] = useState(false); + const [isStorageSaving, setIsStorageSaving] = useState(false); + const [isComponentStorageLoading, setIsComponentStorageLoading] = useState(false); + const [isComponentSaving, setIsComponentSaving] = useState(false); + const [componentSaveOpen, setComponentSaveOpen] = useState(false); + const [componentSaveName, setComponentSaveName] = useState(''); + const [componentSaveCategory, setComponentSaveCategory] = useState('내 컴포넌트'); + const [shapeLabelDraft, setShapeLabelDraft] = useState(''); + const [paintColor, setPaintColor] = useState(PAINT_COLOR_OPTIONS[0].value); + const [storageError, setStorageError] = useState(null); + const [guideVisible, setGuideVisible] = useState(true); + const [attachedToolbars, setAttachedToolbars] = useState>({ + tools: false, + actions: false, + }); + const [collapsedAttachedToolbars, setCollapsedAttachedToolbars] = useState>({ + tools: false, + actions: false, + }); + const [isMobileViewport, setIsMobileViewport] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + + return window.innerWidth <= MOBILE_LAYOUT_BREAKPOINT_PX; + }); + const [mobileActionsExpanded, setMobileActionsExpanded] = useState(false); + const [componentLibraryOpen, setComponentLibraryOpen] = useState(false); + const [componentLibrarySection, setComponentLibrarySection] = useState('featured'); + const [floatingBoxPositions, setFloatingBoxPositions] = useState>(() => { + if (typeof window === 'undefined') { + return { + tools: { x: 12, y: 12 }, + actions: { x: 12, y: 12 }, + guide: { x: 72, y: 12 }, + }; + } + + return { + tools: resolveFloatingBoxDefaultPosition('tools', window.innerWidth, window.innerHeight), + actions: resolveFloatingBoxDefaultPosition('actions', window.innerWidth, window.innerHeight), + guide: resolveFloatingBoxDefaultPosition('guide', window.innerWidth, window.innerHeight), + }; + }); + const [floatingBoxLayerOrder, setFloatingBoxLayerOrder] = useState(['tools', 'actions', 'guide']); + const [draggingBox, setDraggingBox] = useState<{ + key: FloatingBoxKey; + pointerOffsetX: number; + pointerOffsetY: number; + } | null>(null); + const dragStartShapesRef = useRef(null); + const shapesRef = useRef([]); + const attachedToolbarLastUsedRef = useRef>({ + tools: Date.now(), + actions: Date.now(), + }); + const [messageApi, messageContextHolder] = message.useMessage(); + const backgroundMode = historyState.present.backgroundMode; + const shapes = liveShapes ?? historyState.present.shapes; + const { drawableShapes, regionAssignments } = splitDrawShapes(shapes); + const drawRegions = resolveDrawRegions(shapes, canvasSize.width, canvasSize.height); + const hasRegionLayout = drawableShapes.some((shape) => shape.type === 'line') || regionAssignments.length > 0; + const selectedShapeId = selectedTarget?.kind === 'shape' ? selectedTarget.id : null; + const selectedRegionKey = selectedTarget?.kind === 'region' ? selectedTarget.regionKey : null; + const selectedShapeIds = useMemo( + () => resolveGroupedShapeIds(drawableShapes, selectedShapeIdsState), + [drawableShapes, selectedShapeIdsState], + ); + const selectedShape = selectedShapeId + ? drawableShapes.find((shape) => shape.id === selectedShapeId) ?? null + : null; + const selectedRegion = selectedRegionKey ? drawRegions.find((region) => region.key === selectedRegionKey) ?? null : null; + const selectedDrawableShapes = drawableShapes.filter((shape) => selectedShapeIds.has(shape.id)); + const hasMultiSelection = selectedShapeIds.size > 1; + const canUndo = historyState.past.length > 0; + const canRedo = historyState.future.length > 0; + const componentLibraryGroups = useMemo(() => { + const presetGroups = COMPONENT_LIBRARY_SECTIONS.map((section) => ({ + ...section, + items: section.presetIds + .map((presetId) => COMPONENT_LIBRARY_PRESETS.find((preset) => preset.presetId === presetId) ?? null) + .filter((item): item is ComponentLibraryItem => item !== null), + })); + + const savedGroup = { + key: 'saved', + label: '저장 컴포넌트', + items: savedComponents.map( + (record) => + ({ + presetId: `saved:${record.id}`, + title: record.name, + description: `${record.category} 묶음으로 저장된 사용자 컴포넌트입니다.`, + category: record.category, + previewKind: 'content-card', + blueprints: record.shapes, + source: 'saved', + savedRecordId: record.id, + }) satisfies ComponentLibraryItem, + ), + }; + + return savedComponents.length > 0 ? [savedGroup, ...presetGroups] : presetGroups; + }, [savedComponents]); + const activeComponentLibraryGroup = + componentLibraryGroups.find((section) => section.key === componentLibrarySection) ?? componentLibraryGroups[0]; + + const assignFloatingBoxRef = (key: FloatingBoxKey) => (element: HTMLElement | null) => { + floatingBoxRefs.current[key] = element; + }; + + const updateFloatingBoxPosition = (key: FloatingBoxKey, nextPosition: FloatingBoxPosition) => { + setFloatingBoxPositions((previous) => ({ + ...previous, + [key]: clampFloatingBoxPosition(nextPosition, floatingBoxRefs.current[key], pageRef.current), + })); + }; + + const bringFloatingBoxToFront = (key: FloatingBoxKey) => { + setFloatingBoxLayerOrder((previous) => { + if (previous[previous.length - 1] === key) { + return previous; + } + + return previous.filter((entry) => entry !== key).concat(key); + }); + }; + + const resolveFloatingBoxZIndex = (key: FloatingBoxKey) => { + const orderIndex = floatingBoxLayerOrder.indexOf(key); + return FLOATING_BOX_BASE_Z_INDEX + (orderIndex >= 0 ? orderIndex : 0); + }; + + const markToolbarInteraction = (key: AttachableToolbarKey) => { + bringFloatingBoxToFront(key); + attachedToolbarLastUsedRef.current[key] = Date.now(); + setCollapsedAttachedToolbars((previous) => (previous[key] ? { ...previous, [key]: false } : previous)); + }; + + const snapToolbarToAttachedPosition = (key: AttachableToolbarKey) => { + const boundary = pageRef.current; + const element = floatingBoxRefs.current[key]; + const fallbackSize = isMobileViewport ? { width: 38, height: 38 } : { width: 44, height: 44 }; + setFloatingBoxPositions((previous) => { + const size = resolveFloatingBoxSize(element, fallbackSize); + const currentPosition = previous[key]; + const nextPosition = + currentPosition.x === 0 && currentPosition.y === 0 + ? resolveFloatingBoxDefaultPosition( + key, + boundary?.clientWidth ?? (typeof window === 'undefined' ? 0 : window.innerWidth), + boundary?.clientHeight ?? (typeof window === 'undefined' ? 0 : window.innerHeight), + ) + : currentPosition; + const snappedPosition = snapFloatingBoxToClosestEdge(nextPosition, size, boundary); + + if (snappedPosition.x === currentPosition.x && snappedPosition.y === currentPosition.y) { + return previous; + } + + return { + ...previous, + [key]: snappedPosition, + }; + }); + }; + + const toggleToolbarAttached = (key: AttachableToolbarKey) => { + setAttachedToolbars((previous) => { + const nextAttached = !previous[key]; + if (nextAttached) { + attachedToolbarLastUsedRef.current[key] = Date.now(); + setCollapsedAttachedToolbars((collapsedPrevious) => ({ ...collapsedPrevious, [key]: false })); + window.requestAnimationFrame(() => { + snapToolbarToAttachedPosition(key); + }); + } + return { + ...previous, + [key]: nextAttached, + }; + }); + }; + + const expandAttachedToolbar = (key: AttachableToolbarKey) => { + bringFloatingBoxToFront(key); + markToolbarInteraction(key); + setCollapsedAttachedToolbars((previous) => ({ ...previous, [key]: false })); + if (attachedToolbars[key]) { + window.requestAnimationFrame(() => { + snapToolbarToAttachedPosition(key); + }); + } + }; + + const commitDocument = (nextBackgroundMode: BackgroundMode, nextShapes: DrawShape[]) => { + setHistoryState((previous) => + commitLayoutDrawHistory(previous, { + backgroundMode: nextBackgroundMode, + shapes: nextShapes, + }), + ); + }; + + const setSelectedShapes = (shapeIds: Iterable, anchorId?: string | null) => { + const nextIds = [...resolveGroupedShapeIds(drawableShapes, shapeIds)]; + setSelectedShapeIdsState(nextIds); + if (nextIds.length === 0) { + setSelectedTarget(null); + return; + } + + const nextAnchorId = anchorId && nextIds.includes(anchorId) ? anchorId : nextIds[0]; + setSelectedTarget(nextAnchorId ? { kind: 'shape', id: nextAnchorId } : null); + }; + + const clearShapeSelection = () => { + setSelectedShapeIdsState([]); + if (selectedTarget?.kind === 'shape') { + setSelectedTarget(null); + } + }; + + const syncSelection = (nextShapes: DrawShape[]) => { + const nextDrawableShapes = nextShapes.filter(isDrawableShape); + const nextSelectedIds = [...selectedShapeIds].filter((id) => nextDrawableShapes.some((shape) => shape.id === id)); + if (nextSelectedIds.length !== selectedShapeIds.size) { + setSelectedShapeIdsState(nextSelectedIds); + } + if (selectedTarget?.kind === 'shape' && !nextSelectedIds.includes(selectedTarget.id)) { + setSelectedTarget(null); + } + }; + + const removeSelectedTarget = () => { + if (selectedShapeIds.size > 0) { + commitDocument( + backgroundMode, + shapesRef.current.filter((shape) => !(isDrawableShape(shape) && selectedShapeIds.has(shape.id))), + ); + clearShapeSelection(); + return; + } + + if (!selectedTarget) { + return; + } + + commitDocument( + backgroundMode, + shapesRef.current.filter((shape) => !(isRegionShape(shape) && shape.regionKey === selectedTarget.regionKey)), + ); + setSelectedTarget(null); + }; + + const openSavePanel = () => { + setStorageError(null); + setSaveName(createDefaultSaveName()); + setStoragePanel('save'); + }; + + const loadSavedDraws = async () => { + setIsStorageLoading(true); + setStorageError(null); + + try { + const items = await listSavedLayoutDraws(); + setSavedDraws(items); + } catch (error) { + const nextMessage = error instanceof Error ? error.message : '저장 목록을 불러오지 못했습니다.'; + setStorageError(nextMessage); + } finally { + setIsStorageLoading(false); + } + }; + + const openLoadPanel = () => { + setStorageError(null); + setStoragePanel('load'); + void loadSavedDraws(); + }; + + const openComponentLibrary = () => { + setComponentLibraryOpen(true); + void loadSavedComponents(); + }; + + const loadSavedComponents = async () => { + setIsComponentStorageLoading(true); + setStorageError(null); + + try { + const items = await listSavedLayoutDrawComponents(); + setSavedComponents(items); + } catch (error) { + setStorageError(error instanceof Error ? error.message : '저장 컴포넌트를 불러오지 못했습니다.'); + } finally { + setIsComponentStorageLoading(false); + } + }; + + const handleInsertComponentPreset = (preset: ComponentLibraryItem) => { + const bounds = resolveBlueprintBounds(preset.blueprints); + const centerX = Math.max(24, canvasSize.width / 2); + const centerY = Math.max(24, canvasSize.height / 2); + const offsetX = Math.max(12, centerX - bounds.width / 2); + const offsetY = Math.max(12, centerY - bounds.height / 2); + const groupId = createShapeId('group'); + const nextShapes = preset.blueprints.map((blueprint) => + blueprint.type === 'line' + ? ({ + id: createShapeId('line'), + type: 'line', + groupId, + x1: Math.round(offsetX + blueprint.x1), + y1: Math.round(offsetY + blueprint.y1), + x2: Math.round(offsetX + blueprint.x2), + y2: Math.round(offsetY + blueprint.y2), + orientation: blueprint.orientation, + label: blueprint.label, + } satisfies DrawLine) + : ({ + id: createShapeId('rect'), + type: 'rect', + groupId, + x: Math.round(offsetX + blueprint.x), + y: Math.round(offsetY + blueprint.y), + width: blueprint.width, + height: blueprint.height, + label: blueprint.label, + fillColor: blueprint.fillColor ?? null, + } satisfies DrawRect), + ); + + commitDocument(backgroundMode, [...shapesRef.current, ...nextShapes]); + setSelectedShapes(nextShapes.map((shape) => shape.id), nextShapes[0]?.id ?? null); + setShapeLabelDraft(nextShapes[0].label); + setTool('select'); + setStorageError(null); + messageApi.success(`"${preset.title}" 컴포넌트를 도면에 추가했습니다.`); + }; + + const handleGroupSelectedShapes = () => { + if (selectedDrawableShapes.length < 2) { + setStorageError('둘 이상 선택한 뒤 그룹으로 묶어 주세요.'); + return; + } + + const groupId = createShapeId('group'); + const nextIds = new Set(selectedDrawableShapes.map((shape) => shape.id)); + commitDocument( + backgroundMode, + shapesRef.current.map((shape) => + isDrawableShape(shape) && nextIds.has(shape.id) + ? { + ...shape, + groupId, + } + : shape, + ), + ); + setStorageError(null); + messageApi.success('선택한 오브젝트를 그룹으로 묶었습니다.'); + }; + + const handleUngroupSelectedShapes = () => { + if (selectedDrawableShapes.length === 0 || !selectedDrawableShapes.some((shape) => shape.groupId)) { + setStorageError('해제할 그룹 선택이 없습니다.'); + return; + } + + commitDocument( + backgroundMode, + shapesRef.current.map((shape) => + isDrawableShape(shape) && selectedShapeIds.has(shape.id) + ? { + ...shape, + groupId: null, + } + : shape, + ), + ); + setStorageError(null); + messageApi.success('선택한 그룹을 해제했습니다.'); + }; + + const openComponentSaveModal = () => { + if (selectedDrawableShapes.length === 0) { + setStorageError('컴포넌트로 저장할 오브젝트를 먼저 선택해 주세요.'); + return; + } + + setComponentSaveName(selectedShape?.label || `컴포넌트 ${savedComponents.length + 1}`); + setComponentSaveCategory('내 컴포넌트'); + setComponentSaveOpen(true); + setStorageError(null); + }; + + const handleCopySelectedShape = () => { + if (selectedDrawableShapes.length === 0) { + setStorageError('복사할 객체를 먼저 선택해 주세요.'); + return; + } + + if (selectedDrawableShapes.length > 1 || selectedDrawableShapes[0]?.groupId) { + const groupedShapes = selectedDrawableShapes; + const selectedIndex = selectedShape ? groupedShapes.findIndex((shape) => shape.id === selectedShape.id) : 0; + const nextGroupId = createShapeId('group'); + const duplicatedShapes = groupedShapes.map((shape) => + duplicateShapeWithLabel(shape, createShapeId(shape.type), shape.label, nextGroupId), + ); + const nextSelectedShape = duplicatedShapes[selectedIndex] ?? duplicatedShapes[0]; + commitDocument(backgroundMode, [...shapesRef.current, ...duplicatedShapes]); + setSelectedShapes( + duplicatedShapes.map((shape) => shape.id), + nextSelectedShape?.id ?? null, + ); + setStorageError(null); + setShapeLabelDraft(nextSelectedShape?.label ?? ''); + messageApi.success(`"${selectedShape?.label || '선택 컴포넌트'}" 묶음을 복사했습니다.`); + return; + } + + const baseShape = selectedDrawableShapes[0]; + const nextShape = duplicateShapeWithLabel(baseShape, createShapeId(baseShape.type)); + commitDocument(backgroundMode, [...shapesRef.current, nextShape]); + setSelectedShapes([nextShape.id], nextShape.id); + setStorageError(null); + setShapeLabelDraft(nextShape.label); + messageApi.success(`"${nextShape.label || '선택 객체'}"를 복사했습니다.`); + }; + + const handleSelectedShapeLabelChange = () => { + if (selectedShapeIds.size > 1) { + return; + } + + if (!selectedShape && !selectedRegion) { + return; + } + + const nextLabel = shapeLabelDraft.trim(); + if (selectedShape) { + if (nextLabel === selectedShape.label) { + return; + } + + const nextShapes = shapesRef.current.map((shape) => + isDrawableShape(shape) && shape.id === selectedShape.id + ? { + ...shape, + label: nextLabel, + } + : shape, + ); + commitDocument(backgroundMode, nextShapes); + setStorageError(null); + return; + } + + if (!selectedRegion) { + return; + } + + const existingRegionShape = regionAssignments.find((shape) => shape.regionKey === selectedRegion.key) ?? null; + if (existingRegionShape && nextLabel === existingRegionShape.label) { + return; + } + + const nextShapes = existingRegionShape + ? !nextLabel && !existingRegionShape.fillColor + ? shapesRef.current.filter((shape) => !(isRegionShape(shape) && shape.id === existingRegionShape.id)) + : shapesRef.current.map((shape) => + isRegionShape(shape) && shape.id === existingRegionShape.id + ? { + ...shape, + label: nextLabel, + } + : shape, + ) + : nextLabel + ? [ + ...shapesRef.current, + { + id: createShapeId('region'), + type: 'region', + regionKey: selectedRegion.key, + label: nextLabel, + fillColor: null, + } satisfies DrawRegion, + ] + : shapesRef.current; + + commitDocument(backgroundMode, nextShapes); + setStorageError(null); + }; + + const handleUndo = () => { + setDraft(null); + setDragState(null); + setSelectionBox(null); + setResizeState(null); + setLiveShapes(null); + dragStartShapesRef.current = null; + setHistoryState((previous) => undoLayoutDrawHistory(previous)); + }; + + const handleRedo = () => { + setDraft(null); + setDragState(null); + setSelectionBox(null); + setResizeState(null); + setLiveShapes(null); + dragStartShapesRef.current = null; + setHistoryState((previous) => redoLayoutDrawHistory(previous)); + }; + + const handleSave = async () => { + const name = saveName.trim(); + if (!name) { + setStorageError('저장 이름을 입력해 주세요.'); + return; + } + + setIsStorageSaving(true); + setStorageError(null); + + const now = new Date().toISOString(); + + try { + await saveLayoutDraw({ + id: createSavedDrawId(), + name, + createdAt: now, + updatedAt: now, + backgroundMode, + shapes, + }); + setStoragePanel(null); + setSaveName(createDefaultSaveName()); + messageApi.success('현재 도면을 저장했습니다.'); + } catch (error) { + setStorageError(error instanceof Error ? error.message : '도면 저장에 실패했습니다.'); + } finally { + setIsStorageSaving(false); + } + }; + + const handleLoadSavedDraw = (record: SavedLayoutDrawRecord) => { + setDraft(null); + setDragState(null); + setSelectionBox(null); + setResizeState(null); + setLiveShapes(null); + dragStartShapesRef.current = null; + setSelectedTarget(null); + setSelectedShapeIdsState([]); + setTool('select'); + commitDocument(record.backgroundMode, record.shapes); + setStoragePanel(null); + messageApi.success(`"${record.name}" 도면을 불러왔습니다.`); + }; + + const handleDeleteSavedDraw = async (record: SavedLayoutDrawRecord) => { + try { + await deleteLayoutDraw(record.id); + setSavedDraws((previous) => previous.filter((item) => item.id !== record.id)); + messageApi.success(`"${record.name}" 저장본을 삭제했습니다.`); + } catch (error) { + setStorageError(error instanceof Error ? error.message : '저장본 삭제에 실패했습니다.'); + } + }; + + const [initialBackgroundMode] = useState(() => { + if (typeof window === 'undefined') { + return INITIAL_BACKGROUND_MODE; + } + + return window.localStorage.getItem(DRAW_BACKGROUND_MODE_STORAGE_KEY) === 'plain' ? 'plain' : 'grid'; + }); + + useEffect(() => { + shapesRef.current = shapes.map(cloneShape); + }, [shapes]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(DRAW_BACKGROUND_MODE_STORAGE_KEY, backgroundMode); + }, [backgroundMode]); + + useEffect(() => { + if (historyState.present.backgroundMode === initialBackgroundMode && historyState.present.shapes.length === 0) { + return; + } + + syncSelection(historyState.present.shapes); + }, [historyState.present, initialBackgroundMode, selectedShapeIdsState, selectedTarget]); + + useEffect(() => { + if (selectedTarget?.kind === 'region' && !selectedRegion) { + setSelectedTarget(null); + } + }, [selectedRegion, selectedTarget]); + + useEffect(() => { + if (selectedTarget?.kind === 'shape' && selectedShapeIds.size === 0) { + setSelectedTarget(null); + } + }, [selectedShapeIds, selectedTarget]); + + useEffect(() => { + const element = svgRef.current; + if (!element) { + return undefined; + } + + const updateCanvasSize = () => { + setCanvasSize(resolveCanvasSize(element)); + }; + + updateCanvasSize(); + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const observer = new ResizeObserver(() => { + updateCanvasSize(); + }); + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [attachedToolbars, isMobileViewport]); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + let previousIsMobileViewport = window.innerWidth <= MOBILE_LAYOUT_BREAKPOINT_PX; + const handleResize = () => { + const nextIsMobileViewport = window.innerWidth <= MOBILE_LAYOUT_BREAKPOINT_PX; + const boundary = pageRef.current; + const boundaryWidth = boundary?.clientWidth ?? window.innerWidth; + const boundaryHeight = boundary?.clientHeight ?? window.innerHeight; + + setIsMobileViewport(nextIsMobileViewport); + setFloatingBoxPositions((previous) => { + if (nextIsMobileViewport !== previousIsMobileViewport) { + previousIsMobileViewport = nextIsMobileViewport; + return { + tools: resolveFloatingBoxDefaultPosition('tools', boundaryWidth, boundaryHeight), + actions: resolveFloatingBoxDefaultPosition('actions', boundaryWidth, boundaryHeight), + guide: resolveFloatingBoxDefaultPosition('guide', boundaryWidth, boundaryHeight), + }; + } + + return { + tools: attachedToolbars.tools + ? snapFloatingBoxToClosestEdge( + previous.tools, + resolveFloatingBoxSize( + floatingBoxRefs.current.tools, + nextIsMobileViewport ? { width: 38, height: 38 } : { width: 44, height: 44 }, + ), + boundary, + ) + : clampFloatingBoxPosition(previous.tools, floatingBoxRefs.current.tools, boundary), + actions: attachedToolbars.actions + ? snapFloatingBoxToClosestEdge( + previous.actions, + resolveFloatingBoxSize( + floatingBoxRefs.current.actions, + nextIsMobileViewport ? { width: 38, height: 38 } : { width: 44, height: 44 }, + ), + boundary, + ) + : clampFloatingBoxPosition(previous.actions, floatingBoxRefs.current.actions, boundary), + guide: clampFloatingBoxPosition(previous.guide, floatingBoxRefs.current.guide, boundary), + }; + }); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [attachedToolbars.actions, attachedToolbars.tools]); + + useEffect(() => { + const boundary = pageRef.current; + if (!boundary || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const observer = new ResizeObserver(() => { + setFloatingBoxPositions((previous) => ({ + tools: attachedToolbars.tools + ? snapFloatingBoxToClosestEdge( + previous.tools, + resolveFloatingBoxSize( + floatingBoxRefs.current.tools, + isMobileViewport ? { width: 38, height: 38 } : { width: 44, height: 44 }, + ), + boundary, + ) + : clampFloatingBoxPosition(previous.tools, floatingBoxRefs.current.tools, boundary), + actions: attachedToolbars.actions + ? snapFloatingBoxToClosestEdge( + previous.actions, + resolveFloatingBoxSize( + floatingBoxRefs.current.actions, + isMobileViewport ? { width: 38, height: 38 } : { width: 44, height: 44 }, + ), + boundary, + ) + : clampFloatingBoxPosition(previous.actions, floatingBoxRefs.current.actions, boundary), + guide: clampFloatingBoxPosition(previous.guide, floatingBoxRefs.current.guide, boundary), + })); + }); + + observer.observe(boundary); + return () => { + observer.disconnect(); + }; + }, [attachedToolbars.actions, attachedToolbars.tools, isMobileViewport]); + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const observers: ResizeObserver[] = []; + + (['tools', 'actions'] as const).forEach((key) => { + if (!attachedToolbars[key]) { + return; + } + + const element = floatingBoxRefs.current[key]; + if (!element) { + return; + } + + const observer = new ResizeObserver(() => { + setFloatingBoxPositions((previous) => { + const nextPosition = snapFloatingBoxToClosestEdge( + previous[key], + resolveFloatingBoxSize(element, { + width: element.offsetWidth, + height: element.offsetHeight, + }), + pageRef.current, + ); + + if (nextPosition.x === previous[key].x && nextPosition.y === previous[key].y) { + return previous; + } + + return { + ...previous, + [key]: nextPosition, + }; + }); + }); + + observer.observe(element); + observers.push(observer); + }); + + return () => { + observers.forEach((observer) => { + observer.disconnect(); + }); + }; + }, [attachedToolbars.actions, attachedToolbars.tools]); + + useEffect(() => { + (['tools', 'actions'] as const).forEach((key) => { + if (!attachedToolbars[key]) { + return; + } + + window.requestAnimationFrame(() => { + snapToolbarToAttachedPosition(key); + }); + }); + }, [ + attachedToolbars.actions, + attachedToolbars.tools, + collapsedAttachedToolbars.actions, + collapsedAttachedToolbars.tools, + isMobileViewport, + ]); + + useEffect(() => { + if (!draggingBox || typeof window === 'undefined') { + return undefined; + } + + const handlePointerMove = (event: PointerEvent) => { + updateFloatingBoxPosition(draggingBox.key, { + x: event.clientX - draggingBox.pointerOffsetX, + y: event.clientY - draggingBox.pointerOffsetY, + }); + }; + + const stopDragging = () => { + setDraggingBox(null); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopDragging); + window.addEventListener('pointercancel', stopDragging); + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopDragging); + window.removeEventListener('pointercancel', stopDragging); + }; + }, [draggingBox]); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const interval = window.setInterval(() => { + const now = Date.now(); + setCollapsedAttachedToolbars((previous) => { + let changed = false; + const nextState = { ...previous }; + + (Object.keys(attachedToolbars) as AttachableToolbarKey[]).forEach((key) => { + const shouldCollapse = + attachedToolbars[key] && + !previous[key] && + now - attachedToolbarLastUsedRef.current[key] >= ATTACHED_TOOLBAR_IDLE_HIDE_MS; + + if (shouldCollapse) { + nextState[key] = true; + changed = true; + } + }); + + return changed ? nextState : previous; + }); + }, 1000); + + return () => { + window.clearInterval(interval); + }; + }, [attachedToolbars]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (componentLibraryOpen && event.key === 'Escape') { + setComponentLibraryOpen(false); + return; + } + + if (event.key === 'Escape') { + setDraft(null); + setDragState(null); + setResizeState(null); + setLiveShapes(null); + setTool('select'); + } + + const isModifierPressed = event.ctrlKey || event.metaKey; + if (isModifierPressed && event.key.toLowerCase() === 'z') { + event.preventDefault(); + + if (event.shiftKey) { + handleRedo(); + return; + } + + handleUndo(); + return; + } + + if (isModifierPressed && event.key.toLowerCase() === 'y') { + event.preventDefault(); + handleRedo(); + return; + } + + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedTarget) { + const activeTag = document.activeElement instanceof HTMLElement ? document.activeElement.tagName : ''; + if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') { + return; + } + + removeSelectedTarget(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [componentLibraryOpen, removeSelectedTarget, selectedTarget]); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const currentState = + window.history.state && typeof window.history.state === 'object' ? window.history.state : {}; + + if (!currentState[DRAW_HISTORY_GUARD_KEY]) { + window.history.pushState( + { + ...currentState, + [DRAW_HISTORY_GUARD_KEY]: true, + }, + '', + currentUrl, + ); + } + + const handlePopState = (event: PopStateEvent) => { + const nextState = event.state && typeof event.state === 'object' ? event.state : {}; + if (nextState[DRAW_HISTORY_GUARD_KEY]) { + return; + } + + window.history.pushState( + { + ...nextState, + [DRAW_HISTORY_GUARD_KEY]: true, + }, + '', + currentUrl, + ); + }; + + window.addEventListener('popstate', handlePopState); + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, []); + + useEffect(() => { + const element = svgRef.current; + if (!element || typeof window === 'undefined' || typeof document === 'undefined') { + return undefined; + } + + const root = document.documentElement; + const body = document.body; + const previousRootOverscrollBehavior = root.style.overscrollBehavior; + const previousRootOverscrollBehaviorX = root.style.overscrollBehaviorX; + const previousBodyOverscrollBehavior = body.style.overscrollBehavior; + const previousBodyOverscrollBehaviorX = body.style.overscrollBehaviorX; + const previousBodyTouchAction = body.style.touchAction; + + root.style.overscrollBehavior = 'none'; + root.style.overscrollBehaviorX = 'none'; + body.style.overscrollBehavior = 'none'; + body.style.overscrollBehaviorX = 'none'; + body.style.touchAction = 'none'; + + let edgeGestureTracking: + | null + | { + startX: number; + startY: number; + direction: 'back' | 'forward'; + } = null; + + const isEventInsideDrawPage = (target: EventTarget | null) => + target instanceof Node && element.contains(target); + + const resetEdgeGestureTracking = () => { + edgeGestureTracking = null; + }; + + const handleTouchStart = (event: TouchEvent) => { + const touch = event.touches[0]; + if (!touch || event.touches.length !== 1 || !isEventInsideDrawPage(event.target)) { + edgeGestureTracking = null; + return; + } + + if (touch.clientX <= DRAW_EDGE_GESTURE_HOTZONE_PX) { + edgeGestureTracking = { + startX: touch.clientX, + startY: touch.clientY, + direction: 'back', + }; + return; + } + + if (touch.clientX >= window.innerWidth - DRAW_EDGE_GESTURE_HOTZONE_PX) { + edgeGestureTracking = { + startX: touch.clientX, + startY: touch.clientY, + direction: 'forward', + }; + return; + } + + edgeGestureTracking = null; + }; + + const handleTouchMove = (event: TouchEvent) => { + const touch = event.touches[0]; + if (!touch || !edgeGestureTracking || !isEventInsideDrawPage(event.target)) { + return; + } + + const deltaX = touch.clientX - edgeGestureTracking.startX; + const deltaY = touch.clientY - edgeGestureTracking.startY; + const horizontalSwipe = + edgeGestureTracking.direction === 'back' ? deltaX >= DRAW_EDGE_GESTURE_MIN_SWIPE_PX : deltaX <= -DRAW_EDGE_GESTURE_MIN_SWIPE_PX; + const verticalDrift = Math.abs(deltaY); + + if (horizontalSwipe && verticalDrift <= DRAW_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX) { + event.preventDefault(); + } + }; + + const handleGestureEvent = (event: Event) => { + if (!isEventInsideDrawPage(event.target)) { + return; + } + + event.preventDefault(); + }; + + document.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true }); + document.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true }); + document.addEventListener('touchend', resetEdgeGestureTracking, { passive: true, capture: true }); + document.addEventListener('touchcancel', resetEdgeGestureTracking, { passive: true, capture: true }); + document.addEventListener('gesturestart', handleGestureEvent, { passive: false, capture: true }); + document.addEventListener('gesturechange', handleGestureEvent, { passive: false, capture: true }); + + return () => { + root.style.overscrollBehavior = previousRootOverscrollBehavior; + root.style.overscrollBehaviorX = previousRootOverscrollBehaviorX; + body.style.overscrollBehavior = previousBodyOverscrollBehavior; + body.style.overscrollBehaviorX = previousBodyOverscrollBehaviorX; + body.style.touchAction = previousBodyTouchAction; + document.removeEventListener('touchstart', handleTouchStart, true); + document.removeEventListener('touchmove', handleTouchMove, true); + document.removeEventListener('touchend', resetEdgeGestureTracking, true); + document.removeEventListener('touchcancel', resetEdgeGestureTracking, true); + document.removeEventListener('gesturestart', handleGestureEvent, true); + document.removeEventListener('gesturechange', handleGestureEvent, true); + }; + }, []); + + const beginLineDraft = (x: number, y: number) => { + setDraft({ + type: 'line', + startX: x, + startY: y, + x1: x, + y1: y, + orientation: null, + x2: x, + y2: y, + }); + }; + + const updateLineDraft = (x: number, y: number) => { + setDraft((previous) => { + if (!previous || previous.type !== 'line') { + return previous; + } + + const deltaX = x - previous.startX; + const deltaY = y - previous.startY; + const draftDistance = Math.hypot(deltaX, deltaY); + + if (!previous.orientation && draftDistance < DRAW_LINE_ORIENTATION_LOCK_DISTANCE) { + return { + ...previous, + x1: previous.startX, + y1: previous.startY, + x2: previous.startX, + y2: previous.startY, + }; + } + + const orientation: LineOrientation = + previous.orientation ?? (Math.abs(deltaX) >= Math.abs(deltaY) ? 'horizontal' : 'vertical'); + + const nextLine = resolveLineDraft( + previous.startX, + previous.startY, + x, + y, + shapesRef.current.filter(isDrawableShape), + canvasSize.width, + canvasSize.height, + orientation, + ); + + return { + ...previous, + orientation, + x1: nextLine.startX, + y1: nextLine.startY, + x2: nextLine.endX, + y2: nextLine.endY, + }; + }); + }; + + const commitLineDraft = () => { + setDraft((previous) => { + if (!previous || previous.type !== 'line') { + return previous; + } + + const samePoint = previous.x1 === previous.x2 && previous.y1 === previous.y2; + if (samePoint) { + return null; + } + + const orientation = previous.y1 === previous.y2 ? 'horizontal' : 'vertical'; + const nextShape: DrawLine = { + id: createShapeId('line'), + type: 'line', + x1: previous.x1, + y1: previous.y1, + x2: previous.x2, + y2: previous.y2, + orientation, + label: '', + }; + commitDocument(backgroundMode, [...shapesRef.current, nextShape]); + setSelectedShapes([nextShape.id], nextShape.id); + return null; + }); + }; + + const beginRectDraft = (x: number, y: number) => { + setDraft({ + type: 'rect', + startX: x, + startY: y, + endX: x, + endY: y, + }); + }; + + const updateRectDraft = (x: number, y: number) => { + setDraft((previous) => { + if (!previous || previous.type !== 'rect') { + return previous; + } + + return { + ...previous, + endX: x, + endY: y, + }; + }); + }; + + const commitRectDraft = () => { + setDraft((previous) => { + if (!previous || previous.type !== 'rect') { + return previous; + } + + const nextRect = clampRect(previous.startX, previous.startY, previous.endX, previous.endY); + if (nextRect.width === 0 || nextRect.height === 0) { + return null; + } + + const nextShape: DrawRect = { + id: createShapeId('rect'), + type: 'rect', + ...nextRect, + label: '', + }; + commitDocument(backgroundMode, [...shapesRef.current, nextShape]); + setSelectedShapes([nextShape.id], nextShape.id); + return null; + }); + }; + + const clearCanvas = () => { + setDraft(null); + setDragState(null); + setSelectionBox(null); + setResizeState(null); + setLiveShapes(null); + clearShapeSelection(); + dragStartShapesRef.current = null; + commitDocument(backgroundMode, []); + setTool('select'); + }; + + const handlePaintRect = (shape: DrawRect) => { + const nextFillColor = paintColor === 'transparent' ? null : paintColor; + if ((shape.fillColor ?? null) === nextFillColor) { + setSelectedShapes([shape.id], shape.id); + return; + } + + const nextShapes = shapesRef.current.map((item) => + item.id === shape.id && item.type === 'rect' + ? { + ...item, + fillColor: nextFillColor, + } + : item, + ); + commitDocument(backgroundMode, nextShapes); + setSelectedShapes([shape.id], shape.id); + }; + + const handlePaintRegion = (regionKey: string) => { + const nextFillColor = paintColor === 'transparent' ? null : paintColor; + const existingRegionShape = regionAssignments.find((shape) => shape.regionKey === regionKey) ?? null; + + if ((existingRegionShape?.fillColor ?? null) === nextFillColor) { + setSelectedTarget({ kind: 'region', regionKey }); + return; + } + + const nextShapes = existingRegionShape + ? !nextFillColor && !existingRegionShape.label + ? shapesRef.current.filter((shape) => !(isRegionShape(shape) && shape.id === existingRegionShape.id)) + : shapesRef.current.map((shape) => + isRegionShape(shape) && shape.id === existingRegionShape.id + ? { + ...shape, + fillColor: nextFillColor, + } + : shape, + ) + : nextFillColor + ? [ + ...shapesRef.current, + { + id: createShapeId('region'), + type: 'region', + regionKey, + label: '', + fillColor: nextFillColor, + } satisfies DrawRegion, + ] + : shapesRef.current; + + commitDocument(backgroundMode, nextShapes); + setSelectedTarget({ kind: 'region', regionKey }); + }; + + const handleSaveComponent = async () => { + const name = componentSaveName.trim(); + const category = componentSaveCategory.trim() || '내 컴포넌트'; + if (!name) { + setStorageError('컴포넌트 이름을 입력해 주세요.'); + return; + } + if (selectedDrawableShapes.length === 0) { + setStorageError('저장할 컴포넌트를 먼저 선택해 주세요.'); + return; + } + + setIsComponentSaving(true); + setStorageError(null); + const now = new Date().toISOString(); + + try { + await saveLayoutDrawComponent({ + id: createShapeId('component'), + name, + category, + createdAt: now, + updatedAt: now, + shapes: rebaseShapesToComponentBlueprint(selectedDrawableShapes).map((shape) => ({ + ...shape, + groupId: selectedDrawableShapes.length > 1 ? 'component-group' : shape.groupId ?? null, + })), + }); + setComponentSaveOpen(false); + await loadSavedComponents(); + messageApi.success(`"${name}" 컴포넌트를 저장했습니다.`); + } catch (error) { + setStorageError(error instanceof Error ? error.message : '컴포넌트 저장에 실패했습니다.'); + } finally { + setIsComponentSaving(false); + } + }; + + const handleCanvasPointerDown = (event: React.PointerEvent) => { + const element = svgRef.current; + if (!element) { + return; + } + + const point = resolveSvgPoint(event, element); + const target = event.target as SVGElement | null; + const resizeHandle = (target?.dataset.resizeHandle as ResizeHandle | undefined) ?? null; + const shapeId = target?.dataset.shapeId ?? null; + const regionKey = target?.dataset.regionKey ?? null; + const currentDrawableShapes = splitDrawShapes(shapesRef.current).drawableShapes; + const currentDrawRegions = resolveDrawRegions(shapesRef.current, canvasSize.width, canvasSize.height); + const isAdditiveSelection = event.shiftKey || event.metaKey || event.ctrlKey; + const resizeShapeTarget = + resizeHandle && selectedShape ? currentDrawableShapes.find((shape) => shape.id === selectedShape.id) ?? null : null; + + if (resizeHandle && resizeShapeTarget && tool === 'select') { + const groupedIds = resolveGroupedShapeIds(currentDrawableShapes, [resizeShapeTarget.id]); + const baseShapes = currentDrawableShapes + .filter((shape) => groupedIds.has(shape.id)) + .map((shape) => cloneShape(shape) as DrawableShape); + const bounds = resolveDrawableShapesBounds(baseShapes); + if (!bounds) { + return; + } + + dragStartShapesRef.current = shapesRef.current.map(cloneShape); + setResizeState({ + shapeId: resizeShapeTarget.id, + handle: resizeHandle, + originX: point.x, + originY: point.y, + baseShape: cloneShape(resizeShapeTarget) as DrawableShape, + groupedIds, + baseShapes, + bounds, + }); + setLiveShapes(shapesRef.current.map(cloneShape)); + element.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + return; + } + + const hitShape = + (shapeId ? currentDrawableShapes.find((shape) => shape.id === shapeId) ?? null : null) ?? + findShapeAtPoint(currentDrawableShapes, point.x, point.y); + const hitRegion = + hitShape || !currentDrawableShapes.some((shape) => shape.type === 'line') + ? null + : (regionKey ? currentDrawRegions.find((region) => region.key === regionKey) ?? null : null) ?? + findRegionAtPoint(currentDrawRegions, point.x, point.y); + + if (hitShape && tool === 'paint') { + if (hitShape.type === 'rect') { + handlePaintRect(hitShape); + } else { + setSelectedShapes([hitShape.id], hitShape.id); + } + return; + } + + if (hitRegion && tool === 'paint') { + handlePaintRegion(hitRegion.key); + return; + } + + if (hitShape && tool === 'select') { + const groupedIds = resolveGroupedShapeIds(currentDrawableShapes, [hitShape.id]); + const nextIds = [...groupedIds]; + if (isAdditiveSelection) { + const previousIds = new Set(selectedShapeIdsState); + const shouldRemove = nextIds.every((id) => previousIds.has(id)); + const mergedIds = shouldRemove + ? selectedShapeIdsState.filter((id) => !groupedIds.has(id)) + : [...new Set([...selectedShapeIdsState, ...nextIds])]; + setSelectedShapes(mergedIds, shouldRemove ? null : hitShape.id); + return; + } + + setSelectedShapes(nextIds, hitShape.id); + dragStartShapesRef.current = shapesRef.current.map(cloneShape); + + setDragState({ + shapeIds: nextIds, + pointerX: point.x, + pointerY: point.y, + }); + if (groupedIds.size > 1) { + setShapeLabelDraft(hitShape.label); + } + element.setPointerCapture(event.pointerId); + + return; + } + + if (hitRegion && tool === 'select') { + setSelectedShapeIdsState([]); + setSelectedTarget({ kind: 'region', regionKey: hitRegion.key }); + return; + } + + if (tool === 'select' || tool === 'paint') { + if (tool === 'select') { + setSelectionBox({ + startX: point.x, + startY: point.y, + endX: point.x, + endY: point.y, + additive: isAdditiveSelection, + }); + element.setPointerCapture(event.pointerId); + return; + } + + setSelectedTarget(null); + setSelectedShapeIdsState([]); + return; + } + + setSelectedTarget(null); + setSelectedShapeIdsState([]); + element.setPointerCapture(event.pointerId); + + if (tool === 'line') { + beginLineDraft(point.x, point.y); + return; + } + + beginRectDraft(point.x, point.y); + }; + + const handleCanvasPointerMove = (event: React.PointerEvent) => { + const element = svgRef.current; + if (!element) { + return; + } + + const point = resolveSvgPoint(event, element); + + if (resizeState) { + const currentShapes = liveShapes ?? shapesRef.current; + const resizedShapes = + resizeState.groupedIds.size > 1 + ? resizeGroupedShapes(resizeState.baseShapes, resizeState.bounds, resizeState.handle, point.x, point.y) + : [resizeShape(resizeState.baseShape, resizeState.handle, point.x, point.y)]; + const resizedShapeMap = new Map(resizedShapes.map((shape) => [shape.id, shape])); + setLiveShapes( + currentShapes.map((shape) => + isDrawableShape(shape) && resizeState.groupedIds.has(shape.id) ? (resizedShapeMap.get(shape.id) ?? shape) : shape, + ), + ); + return; + } + + if (dragState) { + const deltaX = point.x - dragState.pointerX; + const deltaY = point.y - dragState.pointerY; + if (deltaX === 0 && deltaY === 0) { + return; + } + + const currentShapes = liveShapes ?? shapesRef.current; + setLiveShapes( + currentShapes.map((shape) => + isDrawableShape(shape) && dragState.shapeIds.includes(shape.id) ? moveShape(shape, deltaX, deltaY) : shape, + ), + ); + setDragState({ + shapeIds: dragState.shapeIds, + pointerX: point.x, + pointerY: point.y, + }); + return; + } + + if (selectionBox) { + setSelectionBox((previous) => (previous ? { ...previous, endX: point.x, endY: point.y } : previous)); + return; + } + + if (draft?.type === 'line') { + updateLineDraft(point.x, point.y); + return; + } + + if (draft?.type === 'rect') { + updateRectDraft(point.x, point.y); + } + }; + + const handleCanvasPointerUp = (event: React.PointerEvent) => { + const element = svgRef.current; + if (element?.hasPointerCapture(event.pointerId)) { + element.releasePointerCapture(event.pointerId); + } + + if (resizeState) { + const nextShapes = liveShapes ?? shapesRef.current; + setLiveShapes(null); + setResizeState(null); + if (dragStartShapesRef.current) { + commitDocument(backgroundMode, nextShapes); + } + dragStartShapesRef.current = null; + return; + } + + if (dragState) { + const nextShapes = liveShapes ?? shapesRef.current; + setLiveShapes(null); + setDragState(null); + if (dragStartShapesRef.current) { + commitDocument(backgroundMode, nextShapes); + } + dragStartShapesRef.current = null; + return; + } + + if (selectionBox) { + const nextSelection = clampSelectionRect( + selectionBox.startX, + selectionBox.startY, + selectionBox.endX, + selectionBox.endY, + ); + const hitShapes = findShapesInSelection(splitDrawShapes(shapesRef.current).drawableShapes, nextSelection); + const nextIds = hitShapes.map((shape) => shape.id); + if (selectionBox.additive) { + setSelectedShapes([...new Set([...selectedShapeIdsState, ...nextIds])], nextIds[0] ?? selectedShapeId); + } else { + setSelectedShapes(nextIds, nextIds[0] ?? null); + } + setSelectionBox(null); + return; + } + + if (draft?.type === 'line') { + commitLineDraft(); + return; + } + + if (draft?.type === 'rect') { + commitRectDraft(); + } + }; + + const beginFloatingBoxDrag = (key: FloatingBoxKey, event: React.PointerEvent) => { + if ((key === 'tools' || key === 'actions') && attachedToolbars[key]) { + return; + } + + const box = floatingBoxRefs.current[key]; + if (!box) { + return; + } + + const rect = box.getBoundingClientRect(); + event.preventDefault(); + event.stopPropagation(); + bringFloatingBoxToFront(key); + setDraggingBox({ + key, + pointerOffsetX: event.clientX - rect.left, + pointerOffsetY: event.clientY - rect.top, + }); + }; + + useEffect(() => { + setShapeLabelDraft(selectedShapeIds.size > 1 ? '' : selectedShape?.label ?? selectedRegion?.label ?? ''); + }, [selectedRegion?.label, selectedShape, selectedShapeIds.size]); + + const selectedShapeResizeHandles = + tool === 'select' && + selectedShape && + (selectedShapeIds.size === 1 || + (selectedShape.groupId ? selectedDrawableShapes.every((shape) => shape.groupId === selectedShape.groupId) : false)) + ? (() => { + if (selectedShape.type === 'line') { + return resolveLineResizeHandles(selectedShape); + } + + const groupedShapes = + selectedShape.groupId + ? drawableShapes.filter((shape) => shape.groupId === selectedShape.groupId) + : [selectedShape]; + const groupedBounds = resolveDrawableShapesBounds(groupedShapes); + if (!groupedBounds) { + return resolveRectResizeHandles(selectedShape); + } + + return resolveRectResizeHandles({ + id: 'group-resize-frame', + type: 'rect', + x: groupedBounds.x, + y: groupedBounds.y, + width: groupedBounds.width, + height: groupedBounds.height, + label: selectedShape.label, + }); + })() + : []; + + const toolsAttachButton = ( +
+
+ ); + + const editButtons = ( + <> + + ) : ( + + )} + {collapsedAttachedToolbars.actions ? ( + + ) : ( + + )} + setComponentLibraryOpen(false)} + > +
+ Draw에서 바로 쓰는 업무형 화면 블록과 직접 저장한 컴포넌트를 같은 라이브러리에서 불러옵니다. + 단순 박스 골격보다 화면 썸네일처럼 보이는 샘플을 앞에 두고, 선택하면 비슷한 밀도의 구조가 도면 중앙에 바로 들어가도록 정리했습니다. +
+ {isComponentStorageLoading ? ( +
+ +
+ ) : !activeComponentLibraryGroup || activeComponentLibraryGroup.items.length === 0 ? ( + + ) : ( + setComponentLibrarySection(activeKey as ComponentLibrarySectionKey)} + items={componentLibraryGroups.map((section) => ({ + key: section.key, + label: section.label, + children: ( +
+ {section.items.map((item) => ( + { + if (!item.savedRecordId) { + return; + } + void deleteLayoutDrawComponent(item.savedRecordId) + .then(async () => { + await loadSavedComponents(); + messageApi.success(`"${item.title}" 컴포넌트를 삭제했습니다.`); + }) + .catch((error) => { + setStorageError(error instanceof Error ? error.message : '컴포넌트 삭제에 실패했습니다.'); + }); + }} + > + 삭제 + + ) : null + } + > +
+ {renderComponentPreview(item.previewKind)} +
+
+ {item.description} +
+ {item.source === 'saved' ? item.category : item.title} + +
+
+
+ ))} +
+ ), + }))} + /> + )} +
+ void handleSaveComponent()} + onCancel={() => setComponentSaveOpen(false)} + > +
+ 컴포넌트 이름 + setComponentSaveName(event.target.value)} + onPressEnter={() => void handleSaveComponent()} + /> + 분류 + setComponentSaveCategory(event.target.value)} + onPressEnter={() => void handleSaveComponent()} + /> + + 선택 {selectedDrawableShapes.length}개 오브젝트를 한 컴포넌트로 저장합니다. + + {storageError ?

{storageError}

: null} +
+
+ {guideVisible ? ( +
bringFloatingBoxToFront('guide')} + onFocusCapture={() => bringFloatingBoxToFront('guide')} + > +
+ + +
+ {tool === 'select' ? '선택' : tool === 'line' ? '구분선' : tool === 'rect' ? '사각형' : '색 채우기'} + + {tool === 'select' + ? '기존 오브젝트 선택과 이동' + : tool === 'line' + ? '기존 선 선택 없이 새 구분선 바로 그리기' + : tool === 'rect' + ? '기존 도형 선택 없이 새 사각형 바로 그리기' + : '사각형과 선으로 나뉜 영역을 눌러 선택한 색으로 채우기'} + +
+ ) : null} + {storagePanel ? ( +
+
+ + {storagePanel === 'save' ? '현재 도면 저장' : '저장된 도면 불러오기'} + + +
+ {storagePanel === 'save' ? ( +
+ 저장 이름 + setSaveName(event.target.value)} + onPressEnter={() => void handleSave()} + /> + + 도형 {shapes.length}개, 배경 {backgroundMode === 'grid' ? '모눈' : '단색'} + + {storageError ?

{storageError}

: null} + +
+ ) : ( +
+
+ +
+ {storageError ?

{storageError}

: null} + {isStorageLoading ? ( +
+ +
+ ) : savedDraws.length === 0 ? ( + + ) : ( +
+ {savedDraws.map((record) => ( +
+
+ {record.name} + {formatSavedAt(record.updatedAt)} + + 도형 {record.shapes.length}개, 배경 {record.backgroundMode === 'grid' ? '모눈' : '단색'} + +
+
+ + +
+
+ ))} +
+ )} +
+ )} +
+ ) : null} + + + + + + + + + + + + {backgroundMode === 'grid' ? : null} + {hasRegionLayout ? drawRegions.map((region) => { + const isSelected = selectedRegionKey === region.key; + const regionFill = region.fillColor ?? (isSelected ? 'rgba(59, 130, 246, 0.12)' : null); + + return ( + + {region.cells.map((cell) => ( + + ))} + {region.label ? ( + + {region.label} + + ) : null} + + ); + }) : null} + {drawableShapes.map((shape) => { + if (shape.type === 'line') { + const labelPosition = resolveLineLabelPosition(shape); + return ( + + + {shape.label ? ( + + {shape.label} + + ) : null} + + ); + } + + const labelPosition = resolveRectLabelPosition(shape); + return ( + + + {shape.label ? ( + + {shape.label} + + ) : null} + + ); + })} + {selectedShape + ? selectedShapeResizeHandles.map((handleItem) => ( + + )) + : null} + {selectionBox ? ( + + ) : null} + {draft?.type === 'line' ? ( + + ) : null} + {draft?.type === 'rect' + ? (() => { + return ( + + ); + })() + : null} + +
+ ); +} diff --git a/src/features/layout/draw/index.ts b/src/features/layout/draw/index.ts new file mode 100644 index 0000000..efe5d61 --- /dev/null +++ b/src/features/layout/draw/index.ts @@ -0,0 +1 @@ +export { LayoutDrawPage } from './LayoutDrawPage'; diff --git a/src/features/layout/draw/layoutDrawComponentStorage.ts b/src/features/layout/draw/layoutDrawComponentStorage.ts new file mode 100644 index 0000000..a8c1aeb --- /dev/null +++ b/src/features/layout/draw/layoutDrawComponentStorage.ts @@ -0,0 +1,246 @@ +import { appendClientIdHeader } from '../../../app/main/clientIdentity'; +import { getRegisteredAccessToken } from '../../../app/main/tokenAccess'; +import type { SavedLayoutDrawComponentRecord } from './layoutDrawTypes'; +import { normalizeSavedLayoutDrawShapes, serializeSavedLayoutDrawShapes } from './layoutDrawStorageShapes.ts'; + +type SavedLayoutDrawComponentRow = { + id: string; + name: string; + category: string; + created_at: string; + updated_at: string; + shapes: SavedLayoutDrawComponentRecord['shapes'] | string; +}; + +const WORK_SERVER_TIMEOUT_MS = 8000; +const LAYOUT_DRAW_COMPONENT_TABLE = 'layout_draw_components'; + +let setupPromise: Promise | null = null; + +function normalizeTimestamp(value: unknown, fallback: string) { + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return new Date(parsed).toISOString(); + } + } + + return fallback; +} + +function resolveWorkServerBaseUrl() { + if (import.meta.env.VITE_WORK_SERVER_URL) { + return import.meta.env.VITE_WORK_SERVER_URL; + } + + return '/api'; +} + +function resolveWorkServerFallbackBaseUrl() { + if (typeof window === 'undefined') { + return null; + } + + const hostname = window.location.hostname; + const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0'; + if (!isLocalHost) { + return null; + } + + const fallbackUrl = new URL(window.location.origin); + fallbackUrl.port = '3100'; + fallbackUrl.pathname = '/api'; + fallbackUrl.search = ''; + fallbackUrl.hash = ''; + return fallbackUrl.toString().replace(/\/+$/, ''); +} + +const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl(); +const WORK_SERVER_FALLBACK_BASE_URL = + !import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api' + ? resolveWorkServerFallbackBaseUrl() + : null; + +class LayoutDrawComponentStorageError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'LayoutDrawComponentStorageError'; + this.status = status; + } +} + +async function requestOnce(baseUrl: string, path: string, init?: RequestInit): Promise { + const headers = appendClientIdHeader(init?.headers); + const hasBody = init?.body !== undefined && init.body !== null; + const method = init?.method?.toUpperCase() ?? 'GET'; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS); + + if (hasBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const token = getRegisteredAccessToken(); + if (token && !headers.has('X-Access-Token')) { + headers.set('X-Access-Token', token); + } + + let response: Response; + + try { + response = await fetch(`${baseUrl}${path}`, { + ...init, + headers, + signal: controller.signal, + cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined), + }); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof DOMException && error.name === 'AbortError') { + throw new LayoutDrawComponentStorageError('컴포넌트 저장소 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408); + } + + throw error; + } + + clearTimeout(timeoutId); + + if (!response.ok) { + const text = await response.text(); + let message = text || '컴포넌트 저장소 요청에 실패했습니다.'; + + try { + const payload = JSON.parse(text) as { message?: string }; + message = payload.message || message; + } catch { + // Keep raw text. + } + + throw new LayoutDrawComponentStorageError(message, response.status); + } + + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + throw new LayoutDrawComponentStorageError('컴포넌트 저장소 응답이 JSON이 아닙니다.', 502); + } + + return response.json() as Promise; +} + +async function request(path: string, init?: RequestInit): Promise { + try { + return await requestOnce(WORK_SERVER_BASE_URL, path, init); + } catch (error) { + const shouldRetry = + WORK_SERVER_FALLBACK_BASE_URL && + WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL && + (error instanceof LayoutDrawComponentStorageError + ? error.status === 404 || error.status === 408 || error.status === 502 + : error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message))); + + if (!shouldRetry) { + throw error; + } + + return requestOnce(WORK_SERVER_FALLBACK_BASE_URL, path, init); + } +} + +function toRecord(row: SavedLayoutDrawComponentRow): SavedLayoutDrawComponentRecord { + const now = new Date().toISOString(); + + return { + id: row.id, + name: row.name, + category: row.category, + createdAt: normalizeTimestamp(row.created_at, now), + updatedAt: normalizeTimestamp(row.updated_at, now), + shapes: normalizeSavedLayoutDrawShapes(row.shapes).filter( + (shape) => shape.type === 'line' || shape.type === 'rect', + ) as SavedLayoutDrawComponentRecord['shapes'], + }; +} + +async function ensureLayoutDrawComponentTable() { + if (!setupPromise) { + setupPromise = (async () => { + const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables'); + const tableExists = schemaResponse.items.some((item) => item.table_name === LAYOUT_DRAW_COMPONENT_TABLE); + if (tableExists) { + return; + } + + try { + await request<{ ok: boolean; tableName: string }>('/ddl/create-table', { + method: 'POST', + body: JSON.stringify({ + tableName: LAYOUT_DRAW_COMPONENT_TABLE, + columns: [ + { name: 'id', type: 'text', nullable: false, primary: true }, + { name: 'name', type: 'text', nullable: false }, + { name: 'category', type: 'text', nullable: false }, + { name: 'created_at', type: 'timestamp with time zone', nullable: false }, + { name: 'updated_at', type: 'timestamp with time zone', nullable: false }, + { name: 'shapes', type: 'jsonb', nullable: false }, + ], + }), + }); + } catch (error) { + if (!(error instanceof LayoutDrawComponentStorageError) || !/already exists/i.test(error.message)) { + throw error; + } + } + })().catch((error) => { + setupPromise = null; + throw error; + }); + } + + return setupPromise; +} + +export async function listSavedLayoutDrawComponents() { + await ensureLayoutDrawComponentTable(); + + const response = await request<{ rows: SavedLayoutDrawComponentRow[] }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/select`, { + method: 'POST', + body: JSON.stringify({ + orderBy: [{ field: 'updated_at', direction: 'desc' }], + limit: 200, + }), + }); + + return response.rows.map(toRecord); +} + +export async function saveLayoutDrawComponent(record: SavedLayoutDrawComponentRecord) { + await ensureLayoutDrawComponentTable(); + + await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/insert`, { + method: 'POST', + body: JSON.stringify({ + data: { + id: record.id, + name: record.name, + category: record.category, + created_at: record.createdAt, + updated_at: record.updatedAt, + shapes: serializeSavedLayoutDrawShapes(record.shapes), + }, + }), + }); +} + +export async function deleteLayoutDrawComponent(id: string) { + await ensureLayoutDrawComponentTable(); + + await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/delete`, { + method: 'DELETE', + body: JSON.stringify({ + where: [{ field: 'id', operator: 'eq', value: id }], + }), + }); +} diff --git a/src/features/layout/draw/layoutDrawHistory.ts b/src/features/layout/draw/layoutDrawHistory.ts new file mode 100644 index 0000000..b610b98 --- /dev/null +++ b/src/features/layout/draw/layoutDrawHistory.ts @@ -0,0 +1,68 @@ +import type { LayoutDrawDocument } from './layoutDrawTypes'; + +export type LayoutDrawHistoryState = { + past: LayoutDrawDocument[]; + present: LayoutDrawDocument; + future: LayoutDrawDocument[]; +}; + +function cloneDocument(document: LayoutDrawDocument): LayoutDrawDocument { + return { + backgroundMode: document.backgroundMode, + shapes: document.shapes.map((shape) => ({ ...shape })), + }; +} + +function isDocumentEqual(left: LayoutDrawDocument, right: LayoutDrawDocument) { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function createLayoutDrawHistoryState(initialDocument: LayoutDrawDocument): LayoutDrawHistoryState { + return { + past: [], + present: cloneDocument(initialDocument), + future: [], + }; +} + +export function commitLayoutDrawHistory( + history: LayoutDrawHistoryState, + nextDocument: LayoutDrawDocument, +): LayoutDrawHistoryState { + const nextSnapshot = cloneDocument(nextDocument); + if (isDocumentEqual(history.present, nextSnapshot)) { + return history; + } + + return { + past: [...history.past, cloneDocument(history.present)], + present: nextSnapshot, + future: [], + }; +} + +export function undoLayoutDrawHistory(history: LayoutDrawHistoryState): LayoutDrawHistoryState { + if (history.past.length === 0) { + return history; + } + + const previous = history.past[history.past.length - 1]; + return { + past: history.past.slice(0, -1), + present: cloneDocument(previous), + future: [cloneDocument(history.present), ...history.future], + }; +} + +export function redoLayoutDrawHistory(history: LayoutDrawHistoryState): LayoutDrawHistoryState { + if (history.future.length === 0) { + return history; + } + + const [next, ...restFuture] = history.future; + return { + past: [...history.past, cloneDocument(history.present)], + present: cloneDocument(next), + future: restFuture, + }; +} diff --git a/src/features/layout/draw/layoutDrawRegions.ts b/src/features/layout/draw/layoutDrawRegions.ts new file mode 100644 index 0000000..d366c2a --- /dev/null +++ b/src/features/layout/draw/layoutDrawRegions.ts @@ -0,0 +1,237 @@ +import type { DrawLine, DrawRect, DrawRegion, DrawableShape } from './layoutDrawTypes'; + +type RegionCell = { + x: number; + y: number; + width: number; + height: number; +}; + +export type ResolvedDrawRegion = { + key: string; + cells: RegionCell[]; + labelPosition: { + x: number; + y: number; + }; + label: string; + fillColor: string | null; + assignmentId: string | null; +}; + +function normalizeLine(line: DrawLine) { + return { + x1: Math.min(line.x1, line.x2), + y1: Math.min(line.y1, line.y2), + x2: Math.max(line.x1, line.x2), + y2: Math.max(line.y1, line.y2), + }; +} + +function uniqueSorted(values: number[]) { + return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((left, right) => left - right); +} + +function isVerticalBoundaryBlocked(verticalLines: DrawLine[], rects: DrawRect[], x: number, startY: number, endY: number) { + if ( + verticalLines.some((line) => { + const normalized = normalizeLine(line); + return normalized.x1 === x && normalized.y1 <= startY && normalized.y2 >= endY; + }) + ) { + return true; + } + + return rects.some( + (rect) => + (rect.x === x || rect.x + rect.width === x) && + rect.y <= startY && + rect.y + rect.height >= endY, + ); +} + +function isHorizontalBoundaryBlocked(horizontalLines: DrawLine[], rects: DrawRect[], y: number, startX: number, endX: number) { + if ( + horizontalLines.some((line) => { + const normalized = normalizeLine(line); + return normalized.y1 === y && normalized.x1 <= startX && normalized.x2 >= endX; + }) + ) { + return true; + } + + return rects.some( + (rect) => + (rect.y === y || rect.y + rect.height === y) && + rect.x <= startX && + rect.x + rect.width >= endX, + ); +} + +export function splitDrawShapes(shapes: DrawableShape[] | (DrawableShape | DrawRegion)[]) { + const drawableShapes: DrawableShape[] = []; + const regionAssignments: DrawRegion[] = []; + + shapes.forEach((shape) => { + if (shape.type === 'region') { + regionAssignments.push(shape); + return; + } + + drawableShapes.push(shape); + }); + + return { drawableShapes, regionAssignments }; +} + +export function resolveDrawRegions( + shapes: DrawableShape[] | (DrawableShape | DrawRegion)[], + canvasWidth: number, + canvasHeight: number, +): ResolvedDrawRegion[] { + if (canvasWidth <= 0 || canvasHeight <= 0) { + return []; + } + + const { drawableShapes, regionAssignments } = splitDrawShapes(shapes); + const lines = drawableShapes.filter((shape): shape is DrawLine => shape.type === 'line'); + const rects = drawableShapes.filter((shape): shape is DrawRect => shape.type === 'rect'); + const verticalLines = lines.filter((line) => line.orientation === 'vertical'); + const horizontalLines = lines.filter((line) => line.orientation === 'horizontal'); + + const xs = uniqueSorted([ + 0, + canvasWidth, + ...verticalLines.map((line) => line.x1), + ...rects.flatMap((rect) => [rect.x, rect.x + rect.width]), + ]); + const ys = uniqueSorted([ + 0, + canvasHeight, + ...horizontalLines.map((line) => line.y1), + ...rects.flatMap((rect) => [rect.y, rect.y + rect.height]), + ]); + + if (xs.length < 2 || ys.length < 2) { + return []; + } + + const visited = new Set(); + const assignmentMap = new Map(regionAssignments.map((shape) => [shape.regionKey, shape])); + + const regions: ResolvedDrawRegion[] = []; + + for (let xIndex = 0; xIndex < xs.length - 1; xIndex += 1) { + for (let yIndex = 0; yIndex < ys.length - 1; yIndex += 1) { + const startKey = `${xIndex}:${yIndex}`; + if (visited.has(startKey)) { + continue; + } + + const stack: Array<[number, number]> = [[xIndex, yIndex]]; + const cells: RegionCell[] = []; + const cellKeys: string[] = []; + let totalArea = 0; + let weightedCenterX = 0; + let weightedCenterY = 0; + + while (stack.length > 0) { + const [currentXIndex, currentYIndex] = stack.pop() as [number, number]; + const currentKey = `${currentXIndex}:${currentYIndex}`; + if (visited.has(currentKey)) { + continue; + } + + visited.add(currentKey); + cellKeys.push(currentKey); + + const cell = { + x: xs[currentXIndex], + y: ys[currentYIndex], + width: xs[currentXIndex + 1] - xs[currentXIndex], + height: ys[currentYIndex + 1] - ys[currentYIndex], + }; + cells.push(cell); + + const area = cell.width * cell.height; + totalArea += area; + weightedCenterX += (cell.x + cell.width / 2) * area; + weightedCenterY += (cell.y + cell.height / 2) * area; + + if ( + currentXIndex > 0 && + !isVerticalBoundaryBlocked(verticalLines, rects, xs[currentXIndex], cell.y, cell.y + cell.height) + ) { + stack.push([currentXIndex - 1, currentYIndex]); + } + + if ( + currentXIndex < xs.length - 2 && + !isVerticalBoundaryBlocked(verticalLines, rects, xs[currentXIndex + 1], cell.y, cell.y + cell.height) + ) { + stack.push([currentXIndex + 1, currentYIndex]); + } + + if ( + currentYIndex > 0 && + !isHorizontalBoundaryBlocked(horizontalLines, rects, ys[currentYIndex], cell.x, cell.x + cell.width) + ) { + stack.push([currentXIndex, currentYIndex - 1]); + } + + if ( + currentYIndex < ys.length - 2 && + !isHorizontalBoundaryBlocked(horizontalLines, rects, ys[currentYIndex + 1], cell.x, cell.x + cell.width) + ) { + stack.push([currentXIndex, currentYIndex + 1]); + } + } + + if (cells.length === 0) { + continue; + } + + const centerX = totalArea > 0 ? weightedCenterX / totalArea : cells[0].x + cells[0].width / 2; + const centerY = totalArea > 0 ? weightedCenterY / totalArea : cells[0].y + cells[0].height / 2; + const anchorCell = cells.reduce((closest, cell) => { + const closestDistance = Math.hypot( + closest.x + closest.width / 2 - centerX, + closest.y + closest.height / 2 - centerY, + ); + const currentDistance = Math.hypot(cell.x + cell.width / 2 - centerX, cell.y + cell.height / 2 - centerY); + return currentDistance < closestDistance ? cell : closest; + }, cells[0]); + const key = cellKeys.sort().join('|'); + const assignment = assignmentMap.get(key) ?? null; + + regions.push({ + key, + cells, + labelPosition: { + x: anchorCell.x + anchorCell.width / 2, + y: anchorCell.y + anchorCell.height / 2, + }, + label: assignment?.label ?? '', + fillColor: assignment?.fillColor ?? null, + assignmentId: assignment?.id ?? null, + }); + } + } + + return regions; +} + +export function findRegionAtPoint(regions: ResolvedDrawRegion[], x: number, y: number) { + for (let index = regions.length - 1; index >= 0; index -= 1) { + const region = regions[index]; + if ( + region.cells.some( + (cell) => x >= cell.x && x <= cell.x + cell.width && y >= cell.y && y <= cell.y + cell.height, + ) + ) { + return region; + } + } + + return null; +} diff --git a/src/features/layout/draw/layoutDrawSelectionUtils.ts b/src/features/layout/draw/layoutDrawSelectionUtils.ts new file mode 100644 index 0000000..c400368 --- /dev/null +++ b/src/features/layout/draw/layoutDrawSelectionUtils.ts @@ -0,0 +1,99 @@ +import type { DrawRect, DrawableShape } from './layoutDrawTypes'; + +export type SelectionRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export function clampSelectionRect(startX: number, startY: number, endX: number, endY: number): SelectionRect { + const x = Math.min(startX, endX); + const y = Math.min(startY, endY); + return { + x, + y, + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), + }; +} + +export function resolveGroupedShapeIds(shapes: DrawableShape[], shapeIds: Iterable) { + const seedIds = new Set(shapeIds); + if (seedIds.size === 0) { + return new Set(); + } + + const matchedShapeIds = new Set(shapes.filter((shape) => seedIds.has(shape.id)).map((shape) => shape.id)); + if (matchedShapeIds.size === 0) { + return seedIds; + } + + const groupIds = new Set( + shapes.filter((shape) => seedIds.has(shape.id) && shape.groupId).map((shape) => shape.groupId as string), + ); + + return new Set( + shapes + .filter((shape) => seedIds.has(shape.id) || (shape.groupId ? groupIds.has(shape.groupId) : false)) + .map((shape) => shape.id), + ); +} + +function isRectIntersectingSelection(shape: DrawRect, selection: SelectionRect) { + return !( + shape.x + shape.width < selection.x || + shape.x > selection.x + selection.width || + shape.y + shape.height < selection.y || + shape.y > selection.y + selection.height + ); +} + +function isLineIntersectingSelection(shape: DrawableShape, selection: SelectionRect) { + if (shape.type !== 'line') { + return false; + } + + const minX = Math.min(shape.x1, shape.x2); + const maxX = Math.max(shape.x1, shape.x2); + const minY = Math.min(shape.y1, shape.y2); + const maxY = Math.max(shape.y1, shape.y2); + + return !( + maxX < selection.x || + minX > selection.x + selection.width || + maxY < selection.y || + minY > selection.y + selection.height + ); +} + +export function findShapesInSelection(shapes: DrawableShape[], selection: SelectionRect) { + return shapes.filter((shape) => + shape.type === 'rect' ? isRectIntersectingSelection(shape, selection) : isLineIntersectingSelection(shape, selection), + ); +} + +export function rebaseShapesToComponentBlueprint(shapes: DrawableShape[]) { + if (shapes.length === 0) { + return []; + } + + const minX = Math.min(...shapes.map((shape) => (shape.type === 'line' ? Math.min(shape.x1, shape.x2) : shape.x))); + const minY = Math.min(...shapes.map((shape) => (shape.type === 'line' ? Math.min(shape.y1, shape.y2) : shape.y))); + + return shapes.map((shape) => + shape.type === 'line' + ? { + ...shape, + x1: shape.x1 - minX, + y1: shape.y1 - minY, + x2: shape.x2 - minX, + y2: shape.y2 - minY, + } + : { + ...shape, + x: shape.x - minX, + y: shape.y - minY, + }, + ); +} diff --git a/src/features/layout/draw/layoutDrawShapeUtils.ts b/src/features/layout/draw/layoutDrawShapeUtils.ts new file mode 100644 index 0000000..4c7d0f0 --- /dev/null +++ b/src/features/layout/draw/layoutDrawShapeUtils.ts @@ -0,0 +1,32 @@ +import type { DrawableShape } from './layoutDrawTypes'; + +const DUPLICATE_OFFSET_PX = 24; + +export function duplicateShapeWithLabel( + shape: DrawableShape, + nextId: string, + label = shape.label, + groupId = shape.groupId ?? null, +): DrawableShape { + if (shape.type === 'line') { + return { + ...shape, + id: nextId, + ...(groupId ? { groupId } : {}), + label, + x1: shape.x1 + DUPLICATE_OFFSET_PX, + y1: shape.y1 + DUPLICATE_OFFSET_PX, + x2: shape.x2 + DUPLICATE_OFFSET_PX, + y2: shape.y2 + DUPLICATE_OFFSET_PX, + }; + } + + return { + ...shape, + id: nextId, + ...(groupId ? { groupId } : {}), + label, + x: shape.x + DUPLICATE_OFFSET_PX, + y: shape.y + DUPLICATE_OFFSET_PX, + }; +} diff --git a/src/features/layout/draw/layoutDrawStorage.ts b/src/features/layout/draw/layoutDrawStorage.ts new file mode 100644 index 0000000..a7421c1 --- /dev/null +++ b/src/features/layout/draw/layoutDrawStorage.ts @@ -0,0 +1,244 @@ +import { appendClientIdHeader } from '../../../app/main/clientIdentity'; +import { getRegisteredAccessToken } from '../../../app/main/tokenAccess'; +import type { SavedLayoutDrawRecord } from './layoutDrawTypes'; +import { normalizeSavedLayoutDrawShapes, serializeSavedLayoutDrawShapes } from './layoutDrawStorageShapes.ts'; + +type SavedLayoutDrawRow = { + id: string; + name: string; + created_at: string; + updated_at: string; + background_mode: SavedLayoutDrawRecord['backgroundMode']; + shapes: SavedLayoutDrawRecord['shapes'] | string; +}; + +const WORK_SERVER_TIMEOUT_MS = 8000; +const LAYOUT_DRAW_TABLE = 'layout_draw_snapshots'; + +let setupPromise: Promise | null = null; + +function normalizeTimestamp(value: unknown, fallback: string) { + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return new Date(parsed).toISOString(); + } + } + + return fallback; +} + +function resolveWorkServerBaseUrl() { + if (import.meta.env.VITE_WORK_SERVER_URL) { + return import.meta.env.VITE_WORK_SERVER_URL; + } + + return '/api'; +} + +function resolveWorkServerFallbackBaseUrl() { + if (typeof window === 'undefined') { + return null; + } + + const hostname = window.location.hostname; + const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0'; + if (!isLocalHost) { + return null; + } + + const fallbackUrl = new URL(window.location.origin); + fallbackUrl.port = '3100'; + fallbackUrl.pathname = '/api'; + fallbackUrl.search = ''; + fallbackUrl.hash = ''; + return fallbackUrl.toString().replace(/\/+$/, ''); +} + +const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl(); +const WORK_SERVER_FALLBACK_BASE_URL = + !import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api' + ? resolveWorkServerFallbackBaseUrl() + : null; + +class LayoutDrawStorageError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'LayoutDrawStorageError'; + this.status = status; + } +} + +async function requestOnce(baseUrl: string, path: string, init?: RequestInit): Promise { + const headers = appendClientIdHeader(init?.headers); + const hasBody = init?.body !== undefined && init.body !== null; + const method = init?.method?.toUpperCase() ?? 'GET'; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS); + + if (hasBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const token = getRegisteredAccessToken(); + if (token && !headers.has('X-Access-Token')) { + headers.set('X-Access-Token', token); + } + + let response: Response; + + try { + response = await fetch(`${baseUrl}${path}`, { + ...init, + headers, + signal: controller.signal, + cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined), + }); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof DOMException && error.name === 'AbortError') { + throw new LayoutDrawStorageError('저장소 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408); + } + + throw error; + } + + clearTimeout(timeoutId); + + if (!response.ok) { + const text = await response.text(); + let message = text || '도면 저장소 요청에 실패했습니다.'; + + try { + const payload = JSON.parse(text) as { message?: string }; + message = payload.message || message; + } catch { + // Keep raw text. + } + + throw new LayoutDrawStorageError(message, response.status); + } + + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + throw new LayoutDrawStorageError('도면 저장소 응답이 JSON이 아닙니다.', 502); + } + + return response.json() as Promise; +} + +async function request(path: string, init?: RequestInit): Promise { + try { + return await requestOnce(WORK_SERVER_BASE_URL, path, init); + } catch (error) { + const shouldRetry = + WORK_SERVER_FALLBACK_BASE_URL && + WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL && + (error instanceof LayoutDrawStorageError + ? error.status === 404 || error.status === 408 || error.status === 502 + : error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message))); + + if (!shouldRetry) { + throw error; + } + + return requestOnce(WORK_SERVER_FALLBACK_BASE_URL, path, init); + } +} + +function toRecord(row: SavedLayoutDrawRow): SavedLayoutDrawRecord { + const now = new Date().toISOString(); + + return { + id: row.id, + name: row.name, + createdAt: normalizeTimestamp(row.created_at, now), + updatedAt: normalizeTimestamp(row.updated_at, now), + backgroundMode: row.background_mode === 'plain' ? 'plain' : 'grid', + shapes: normalizeSavedLayoutDrawShapes(row.shapes), + }; +} + +async function ensureLayoutDrawTable() { + if (!setupPromise) { + setupPromise = (async () => { + const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables'); + const tableExists = schemaResponse.items.some((item) => item.table_name === LAYOUT_DRAW_TABLE); + if (tableExists) { + return; + } + + try { + await request<{ ok: boolean; tableName: string }>('/ddl/create-table', { + method: 'POST', + body: JSON.stringify({ + tableName: LAYOUT_DRAW_TABLE, + columns: [ + { name: 'id', type: 'text', nullable: false, primary: true }, + { name: 'name', type: 'text', nullable: false }, + { name: 'created_at', type: 'timestamp with time zone', nullable: false }, + { name: 'updated_at', type: 'timestamp with time zone', nullable: false }, + { name: 'background_mode', type: 'text', nullable: false }, + { name: 'shapes', type: 'jsonb', nullable: false }, + ], + }), + }); + } catch (error) { + if (!(error instanceof LayoutDrawStorageError) || !/already exists/i.test(error.message)) { + throw error; + } + } + })().catch((error) => { + setupPromise = null; + throw error; + }); + } + + return setupPromise; +} + +export async function listSavedLayoutDraws() { + await ensureLayoutDrawTable(); + + const response = await request<{ rows: SavedLayoutDrawRow[] }>(`/crud/${LAYOUT_DRAW_TABLE}/select`, { + method: 'POST', + body: JSON.stringify({ + orderBy: [{ field: 'updated_at', direction: 'desc' }], + limit: 200, + }), + }); + + return response.rows.map(toRecord); +} + +export async function saveLayoutDraw(record: SavedLayoutDrawRecord) { + await ensureLayoutDrawTable(); + + await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_TABLE}/insert`, { + method: 'POST', + body: JSON.stringify({ + data: { + id: record.id, + name: record.name, + created_at: record.createdAt, + updated_at: record.updatedAt, + background_mode: record.backgroundMode, + shapes: serializeSavedLayoutDrawShapes(record.shapes), + }, + }), + }); +} + +export async function deleteLayoutDraw(id: string) { + await ensureLayoutDrawTable(); + + await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_TABLE}/delete`, { + method: 'DELETE', + body: JSON.stringify({ + where: [{ field: 'id', operator: 'eq', value: id }], + }), + }); +} diff --git a/src/features/layout/draw/layoutDrawStorageShapes.ts b/src/features/layout/draw/layoutDrawStorageShapes.ts new file mode 100644 index 0000000..d6c49b6 --- /dev/null +++ b/src/features/layout/draw/layoutDrawStorageShapes.ts @@ -0,0 +1,24 @@ +import type { SavedLayoutDrawRecord } from './layoutDrawTypes.ts'; + +export function normalizeSavedLayoutDrawShapes( + value: SavedLayoutDrawRecord['shapes'] | string | null | undefined, +): SavedLayoutDrawRecord['shapes'] { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? (parsed as SavedLayoutDrawRecord['shapes']) : []; + } catch { + return []; + } + } + + return []; +} + +export function serializeSavedLayoutDrawShapes(shapes: SavedLayoutDrawRecord['shapes']) { + return JSON.stringify(Array.isArray(shapes) ? shapes : []); +} diff --git a/src/features/layout/draw/layoutDrawTypes.ts b/src/features/layout/draw/layoutDrawTypes.ts new file mode 100644 index 0000000..f37f41d --- /dev/null +++ b/src/features/layout/draw/layoutDrawTypes.ts @@ -0,0 +1,62 @@ +export type DrawTool = 'select' | 'line' | 'rect' | 'paint'; + +export type BackgroundMode = 'grid' | 'plain'; + +export type DrawLine = { + id: string; + type: 'line'; + groupId?: string | null; + x1: number; + y1: number; + x2: number; + y2: number; + orientation: 'horizontal' | 'vertical'; + label: string; +}; + +export type DrawRect = { + id: string; + type: 'rect'; + groupId?: string | null; + x: number; + y: number; + width: number; + height: number; + label: string; + fillColor?: string | null; +}; + +export type DrawRegion = { + id: string; + type: 'region'; + regionKey: string; + label: string; + fillColor?: string | null; +}; + +export type DrawableShape = DrawLine | DrawRect; + +export type DrawShape = DrawableShape | DrawRegion; + +export type LayoutDrawDocument = { + backgroundMode: BackgroundMode; + shapes: DrawShape[]; +}; + +export type SavedLayoutDrawRecord = { + id: string; + name: string; + createdAt: string; + updatedAt: string; + backgroundMode: BackgroundMode; + shapes: DrawShape[]; +}; + +export type SavedLayoutDrawComponentRecord = { + id: string; + name: string; + category: string; + createdAt: string; + updatedAt: string; + shapes: DrawableShape[]; +}; diff --git a/src/features/layout/draw/lineDraft.ts b/src/features/layout/draw/lineDraft.ts new file mode 100644 index 0000000..78a7cc7 --- /dev/null +++ b/src/features/layout/draw/lineDraft.ts @@ -0,0 +1,107 @@ +export type LineOrientation = 'horizontal' | 'vertical'; + +export type DrawLineLike = { + type: 'line'; + x1: number; + y1: number; + x2: number; + y2: number; + orientation: LineOrientation; +}; + +export type DrawShapeLike = + | DrawLineLike + | { + type: 'rect'; + x: number; + y: number; + width: number; + height: number; + }; + +function isBetween(value: number, start: number, end: number) { + const min = Math.min(start, end); + const max = Math.max(start, end); + return value >= min && value <= max; +} + +function resolveNearestCoordinate( + start: number, + candidates: number[], + direction: -1 | 1, +) { + return candidates.reduce((closest, candidate) => { + if ((candidate - start) * direction <= 0) { + return closest; + } + + if (closest === null) { + return candidate; + } + + return Math.abs(candidate - start) < Math.abs(closest - start) ? candidate : closest; + }, null); +} + +function resolveLineOrientation( + startX: number, + startY: number, + pointerX: number, + pointerY: number, +): LineOrientation { + const deltaX = pointerX - startX; + const deltaY = pointerY - startY; + return Math.abs(deltaX) >= Math.abs(deltaY) ? 'vertical' : 'horizontal'; +} + +export function resolveLineDraft( + startX: number, + startY: number, + pointerX: number, + pointerY: number, + shapes: DrawShapeLike[], + canvasWidth: number, + canvasHeight: number, + preferredOrientation?: LineOrientation, +) { + const orientation = + preferredOrientation ?? resolveLineOrientation(startX, startY, pointerX, pointerY); + + if (orientation === 'horizontal') { + const crossingXs = shapes.flatMap((shape) => { + if (shape.type !== 'line' || shape.orientation !== 'vertical') { + return []; + } + + return isBetween(startY, shape.y1, shape.y2) ? [shape.x1] : []; + }); + const previousStop = resolveNearestCoordinate(startX, crossingXs, -1); + const nextStop = resolveNearestCoordinate(startX, crossingXs, 1); + + return { + startX: previousStop ?? 0, + startY, + endX: nextStop ?? canvasWidth, + endY: startY, + orientation, + }; + } + + const crossingYs = shapes.flatMap((shape) => { + if (shape.type !== 'line' || shape.orientation !== 'horizontal') { + return []; + } + + return isBetween(startX, shape.x1, shape.x2) ? [shape.y1] : []; + }); + const previousStop = resolveNearestCoordinate(startY, crossingYs, -1); + const nextStop = resolveNearestCoordinate(startY, crossingYs, 1); + + return { + startX, + startY: previousStop ?? 0, + endX: startX, + endY: nextStop ?? canvasHeight, + orientation, + }; +} diff --git a/src/features/layout/feature-menu/FeatureMenuLayoutPage.css b/src/features/layout/feature-menu/FeatureMenuLayoutPage.css index b74caca..db6f9e4 100644 --- a/src/features/layout/feature-menu/FeatureMenuLayoutPage.css +++ b/src/features/layout/feature-menu/FeatureMenuLayoutPage.css @@ -135,7 +135,7 @@ min-height: 0; height: 100%; flex-direction: column; - overflow: auto; + overflow: hidden; } .feature-menu-layout-page__tabs .ant-tabs-nav { @@ -251,7 +251,7 @@ .feature-menu-layout-page__editor-shell { grid-template-rows: auto minmax(0, 1fr); align-self: stretch; - height: calc(100% - 24px); + height: 100%; } .feature-menu-layout-page__field:first-of-type { @@ -290,14 +290,14 @@ .feature-menu-layout-page__textarea.ant-input { align-self: stretch; - height: calc(100% - 4px) !important; + height: 100% !important; min-height: 0 !important; max-height: none; padding: 8px 10px; } .feature-menu-layout-page__notes { - height: calc(100% - 4px); + height: 100%; max-height: none; padding: 7px 12px 7px; padding-bottom: 7px; diff --git a/src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx b/src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx index 89c43c7..4424541 100644 --- a/src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx +++ b/src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx @@ -3,6 +3,7 @@ import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess'; +import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard'; import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel'; import { buildChatPath } from '../../../app/main/routes'; import { useTokenAccess } from '../../../app/main/tokenAccess'; @@ -251,6 +252,7 @@ export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsCh okText: '삭제', cancelText: '취소', okButtonProps: { danger: true }, + modalRender: renderModalWithEnterConfirm, async onOk() { const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id); const nextTree = diff --git a/src/features/planBoard/PlanBoardPage.tsx b/src/features/planBoard/PlanBoardPage.tsx index f5cd284..d62c25e 100644 --- a/src/features/planBoard/PlanBoardPage.tsx +++ b/src/features/planBoard/PlanBoardPage.tsx @@ -32,6 +32,7 @@ import { import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAppConfig, type AppConfig } from '../../app/main/appConfig'; +import { confirmWithKeyboard } from '../../app/main/modalKeyboard'; import { buildAutomationTypeOptions, resolveAutomationTypeLabel, @@ -646,6 +647,7 @@ export function PlanBoardPage({ const appConfig = useAppConfig(); const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000; const [messageApi, contextHolder] = message.useMessage(); + const [modalApi, modalContextHolder] = Modal.useModal(); const screens = Grid.useBreakpoint(); const [items, setItems] = useState([]); const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState>({}); @@ -1415,7 +1417,14 @@ export function PlanBoardPage({ return; } - if (!window.confirm('선택한 작업 메모를 삭제할까요?')) { + const confirmed = await confirmWithKeyboard(modalApi, { + title: '선택한 작업 메모를 삭제할까요?', + okText: '삭제', + cancelText: '취소', + okButtonProps: { danger: true }, + }); + + if (!confirmed) { return; } @@ -1748,6 +1757,7 @@ export function PlanBoardPage({ return (
{contextHolder} + {modalContextHolder} {isMobileAutomationLayout ? null : ( diff --git a/src/features/planBoard/PlanSchedulePage.tsx b/src/features/planBoard/PlanSchedulePage.tsx index 0597914..20e54f0 100644 --- a/src/features/planBoard/PlanSchedulePage.tsx +++ b/src/features/planBoard/PlanSchedulePage.tsx @@ -15,6 +15,7 @@ import { Tabs, Tag, Typography, + Modal, message, } from 'antd'; import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; @@ -30,6 +31,7 @@ import { } from '../../app/main/automationContextAccess'; import { buildPlansPath } from '../../app/main/routes'; import { useTokenAccess } from '../../app/main/tokenAccess'; +import { confirmWithKeyboard } from '../../app/main/modalKeyboard'; import './planBoard.css'; import './planSchedule.css'; import { maskNotePreviewByWord } from './noteMasking'; @@ -596,6 +598,7 @@ export function PlanSchedulePage() { const { automationTypes } = useAutomationTypeRegistry(); const { automationContexts } = useAutomationContextRegistry(); const [messageApi, contextHolder] = message.useMessage(); + const [modalApi, modalContextHolder] = Modal.useModal(); const [items, setItems] = useState([]); const [draft, setDraft] = useState(() => createEmptyScheduleDraft()); const [editorOpen, setEditorOpen] = useState(false); @@ -701,7 +704,14 @@ export function PlanSchedulePage() { return; } - if (!window.confirm('선택한 스케줄을 삭제할까요?')) { + const confirmed = await confirmWithKeyboard(modalApi, { + title: '선택한 스케줄을 삭제할까요?', + okText: '삭제', + cancelText: '취소', + okButtonProps: { danger: true }, + }); + + if (!confirmed) { return; } @@ -752,6 +762,7 @@ export function PlanSchedulePage() { return (
{contextHolder} + {modalContextHolder}
diff --git a/src/views/play/apps/apps/AppsLibraryView.css b/src/views/play/apps/apps/AppsLibraryView.css new file mode 100644 index 0000000..da319b3 --- /dev/null +++ b/src/views/play/apps/apps/AppsLibraryView.css @@ -0,0 +1,217 @@ +.apps-library { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + height: 100%; + min-height: 0; + padding: clamp(12px, 1.4vw, 18px); + border-radius: 20px; + color: #f7f9fc; + background: + linear-gradient(135deg, rgba(255, 163, 92, 0.2), transparent 32%), + radial-gradient(circle at bottom right, rgba(93, 166, 255, 0.22), transparent 28%), + linear-gradient(180deg, #10192b 0%, #0a1020 100%); + overflow: hidden; +} + +.apps-library__topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.apps-library__title { + display: flex; + align-items: baseline; + gap: 8px; +} + +.apps-library__title strong { + font-size: clamp(18px, 2vw, 24px); +} + +.apps-library__title span { + font-size: 12px; + color: rgba(247, 249, 252, 0.68); +} + +.apps-library__shelf { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + min-height: 0; + align-content: start; +} + +.apps-library__card { + display: grid; + justify-items: start; + align-content: end; + gap: 8px; + min-height: 120px; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + overflow: hidden; + text-align: left; + color: inherit; + background-color: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + cursor: pointer; +} + +.apps-library__card--puzzle { + background: + linear-gradient(180deg, rgba(255, 167, 86, 0.24), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.06); +} + +.apps-library__card--photoprism { + background: + linear-gradient(180deg, rgba(228, 177, 95, 0.24), rgba(94, 165, 255, 0.12)), + rgba(255, 255, 255, 0.06); +} + +.apps-library__card--reader { + background: + linear-gradient(180deg, rgba(96, 219, 255, 0.26), rgba(59, 118, 255, 0.16)), + rgba(255, 255, 255, 0.06); +} + +.apps-library__card--beat { + background: + linear-gradient(180deg, rgba(127, 114, 255, 0.18), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.05); +} + +.apps-library__card--tetris { + background: + linear-gradient(180deg, rgba(255, 212, 84, 0.24), rgba(255, 119, 82, 0.12)), + rgba(255, 255, 255, 0.06); +} + +.apps-library__card--the-quest { + background: + linear-gradient(180deg, rgba(255, 201, 112, 0.24), rgba(104, 198, 255, 0.14)), + rgba(255, 255, 255, 0.06); +} + +.apps-library__card--sticker { + background: + linear-gradient(180deg, rgba(255, 95, 149, 0.18), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.05); +} + +.apps-library__card--launch { + background: + linear-gradient(180deg, rgba(94, 183, 255, 0.18), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.05); +} + +.apps-library__card--arcade { + background: + linear-gradient(180deg, rgba(255, 115, 92, 0.18), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.05); +} + +.apps-library__card--vault { + background: + linear-gradient(180deg, rgba(96, 255, 194, 0.16), rgba(255, 255, 255, 0.04)), + rgba(255, 255, 255, 0.05); +} + +.apps-library__icon { + display: inline-flex; + width: 42px; + height: 42px; + align-items: center; + justify-content: center; + border-radius: 12px; + font-size: 20px; + background: rgba(255, 255, 255, 0.12); +} + +.apps-library__card strong { + font-size: 16px; + line-height: 1.1; +} + +.apps-library__meta { + font-size: 12px; + color: rgba(247, 249, 252, 0.72); +} + +.apps-library__card:disabled { + opacity: 0.72; + cursor: default; +} + +@media (max-width: 960px) { + .apps-library__shelf { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .apps-library { + gap: 10px; + padding: 10px; + border-radius: 16px; + } + + .apps-library__topbar .ant-tag { + font-size: 11px; + padding-inline: 8px; + } + + .apps-library__shelf, + .apps-library__shelf--compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + + .apps-library__card { + min-height: 78px; + gap: 6px; + padding: 10px 8px; + border-radius: 12px; + } + + .apps-library__title strong { + font-size: 18px; + } + + .apps-library__icon { + width: 26px; + height: 26px; + border-radius: 8px; + font-size: 13px; + } + + .apps-library__card strong { + font-size: 12px; + } + + .apps-library__meta { + font-size: 10px; + } +} + +@media (max-width: 480px) { + .apps-library__shelf, + .apps-library__shelf--compact { + gap: 6px; + } + + .apps-library__card { + min-height: 72px; + padding: 8px 7px; + } + + .apps-library__icon { + width: 22px; + height: 22px; + font-size: 11px; + } +} diff --git a/src/views/play/apps/apps/AppsLibraryView.tsx b/src/views/play/apps/apps/AppsLibraryView.tsx new file mode 100644 index 0000000..f4fab61 --- /dev/null +++ b/src/views/play/apps/apps/AppsLibraryView.tsx @@ -0,0 +1,133 @@ +import { Tag } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import './AppsLibraryView.css'; +import { EReaderAppView } from '../e-reader/EReaderAppView'; +import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView'; +import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView'; +import { TheQuestAppView } from '../the-quest/TheQuestAppView'; +import { TetrisAppView } from '../tetris/TetrisAppView'; +import { APP_LIBRARY_ENTRIES, findReadyPlayAppEntryById } from './appsRegistry'; +import { buildPlayAppPath } from '../../../../app/main/routes'; + +function normalizeReturnToPath(returnTo: string | null) { + if (!returnTo || !returnTo.startsWith('/') || returnTo.startsWith('//')) { + return null; + } + + return returnTo; +} + +export function AppsLibraryView() { + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isCompactViewport, setIsCompactViewport] = useState(() => + typeof window === 'undefined' ? false : window.matchMedia('(max-width: 768px)').matches, + ); + const activeAppId = searchParams.get('app'); + const launchContext = searchParams.get('launchContext') === 'embedded' ? 'embedded' : 'direct'; + const returnTo = normalizeReturnToPath(searchParams.get('returnTo')); + const activeAppEntry = findReadyPlayAppEntryById(activeAppId); + + const readyCount = useMemo(() => APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady).length, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const mediaQuery = window.matchMedia('(max-width: 768px)'); + const handleChange = () => { + setIsCompactViewport(mediaQuery.matches); + }; + + handleChange(); + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + const openApp = (appId: string) => { + const currentPath = `${location.pathname}${location.search}${location.hash}`; + setSearchParams(new URLSearchParams(buildPlayAppPath(appId, 'embedded', currentPath).split('?')[1] ?? '')); + }; + + const closeApp = () => { + if (returnTo) { + navigate(returnTo); + return; + } + + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.delete('app'); + nextSearchParams.delete('launchContext'); + nextSearchParams.delete('returnTo'); + setSearchParams(nextSearchParams); + }; + + if (activeAppEntry?.id === 'photoprism') { + return ; + } + + if (activeAppEntry?.id === 'e-reader') { + return ; + } + + if (activeAppEntry?.id === 'photo-puzzle') { + return ; + } + + if (activeAppEntry?.id === 'tetris') { + return ; + } + + if (activeAppEntry?.id === 'the-quest') { + return ; + } + + return ( +
+
+
+ 앱 보관함 + {APP_LIBRARY_ENTRIES.length}개 +
+ + 실행 가능 {readyCount} + +
+ +
+ {APP_LIBRARY_ENTRIES.map((entry) => ( + + ))} +
+
+ ); +} diff --git a/src/views/play/apps/apps/appsRegistry.tsx b/src/views/play/apps/apps/appsRegistry.tsx new file mode 100644 index 0000000..d30fbdc --- /dev/null +++ b/src/views/play/apps/apps/appsRegistry.tsx @@ -0,0 +1,114 @@ +import { + AppstoreOutlined, + BookOutlined, + FileImageOutlined, + FireOutlined, + FundProjectionScreenOutlined, + PictureOutlined, + RocketOutlined, + SoundOutlined, + StarOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; +import type { ReactNode } from 'react'; + +export type PlayAppEnvironment = 'preview' | 'test' | 'prod'; + +export type PlayAppEntry = { + id: string; + name: string; + accentClassName: string; + statusLabel: string; + isReady: boolean; + icon: ReactNode; + supportedEnvironments?: PlayAppEnvironment[]; + searchKeywords?: string[]; + searchDescription?: string; +}; + +export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [ + { + id: 'e-reader', + name: 'E-Reader', + accentClassName: 'apps-library__card--reader', + statusLabel: '읽기', + isReady: true, + icon: , + supportedEnvironments: ['preview', 'test'], + searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'], + searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.', + }, + { + id: 'photoprism', + name: 'PhotoPrism', + accentClassName: 'apps-library__card--photoprism', + statusLabel: '연결', + isReady: true, + icon: , + supportedEnvironments: ['preview', 'test'], + searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'], + searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.', + }, + { + id: 'photo-puzzle', + name: '사진 퍼즐', + accentClassName: 'apps-library__card--puzzle', + statusLabel: '실행', + isReady: true, + icon: , + supportedEnvironments: ['preview', 'test'], + searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'], + searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.', + }, + { + id: 'the-quest', + name: 'The Quest', + accentClassName: 'apps-library__card--the-quest', + statusLabel: '신규', + isReady: true, + icon: , + supportedEnvironments: ['preview', 'test'], + searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'], + searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.', + }, + { + id: 'tetris', + name: 'Tetris', + accentClassName: 'apps-library__card--tetris', + statusLabel: '실행', + isReady: true, + icon: , + supportedEnvironments: ['preview', 'test'], + searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'], + searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.', + }, + { id: 'beat-lab', name: 'Beat Lab', accentClassName: 'apps-library__card--beat', statusLabel: '준비', isReady: false, icon: }, + { id: 'sticker-booth', name: 'Sticker Booth', accentClassName: 'apps-library__card--sticker', statusLabel: '준비', isReady: false, icon: }, + { id: 'launch-note', name: 'Launch Note', accentClassName: 'apps-library__card--launch', statusLabel: '예정', isReady: false, icon: }, + { id: 'arcade-pack', name: 'Arcade Pack', accentClassName: 'apps-library__card--arcade', statusLabel: '예정', isReady: false, icon: }, + { id: 'app-vault', name: 'App Vault', accentClassName: 'apps-library__card--vault', statusLabel: '테마', isReady: false, icon: }, +]; + +export function getReadyPlayAppEntries() { + return APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady); +} + +export function getSupportedPlayAppEnvironments(entry: PlayAppEntry): PlayAppEnvironment[] { + if (entry.supportedEnvironments && entry.supportedEnvironments.length > 0) { + return entry.supportedEnvironments; + } + + return ['preview']; +} + +export function isPlayAppSupportedInEnvironment(entry: PlayAppEntry, environment: PlayAppEnvironment) { + return getSupportedPlayAppEnvironments(entry).includes(environment); +} + +export function findReadyPlayAppEntryById(appId: string | null | undefined) { + if (!appId) { + return null; + } + + return getReadyPlayAppEntries().find((entry) => entry.id === appId) ?? null; +} diff --git a/src/views/play/apps/e-reader/EReaderAppView.css b/src/views/play/apps/e-reader/EReaderAppView.css new file mode 100644 index 0000000..f2c063b --- /dev/null +++ b/src/views/play/apps/e-reader/EReaderAppView.css @@ -0,0 +1,1228 @@ +.e-reader { + --reader-bg: linear-gradient(180deg, #f5fbff 0%, #dfeef9 100%); + --reader-surface: rgba(34, 95, 140, 0.08); + --reader-text: #0f2334; + --reader-muted: rgba(15, 35, 52, 0.64); + --reader-card: rgba(248, 252, 255, 0.88); + --reader-card-border: rgba(67, 119, 154, 0.18); + --reader-drawer-surface: rgba(248, 252, 255, 0.98); + --reader-drawer-strong: rgba(255, 255, 255, 0.94); + --reader-drawer-shadow: 0 28px 72px rgba(18, 58, 88, 0.24); + --reader-drawer-mask: rgba(8, 24, 39, 0.34); + --reader-page: linear-gradient(180deg, #fffdfa 0%, #edf4f8 100%); + --reader-page-shadow: 0 24px 64px rgba(32, 80, 114, 0.14); + --reader-accent: #2175ad; + --reader-frame-bg: + radial-gradient(circle at top, rgba(255, 250, 244, 0.5), transparent 38%), + linear-gradient(180deg, rgba(83, 112, 131, 0.08), rgba(255, 255, 255, 0.05)); + --reader-frame-edge-left: rgba(30, 94, 136, 0.1); + --reader-frame-edge-right: rgba(30, 94, 136, 0.08); + --reader-page-border: rgba(103, 129, 146, 0.16); + --reader-veil-bg: + radial-gradient(circle at top, rgba(255, 249, 240, 0.26), transparent 36%), + linear-gradient(180deg, rgba(251, 247, 241, 0.5), rgba(228, 238, 244, 0.54)); + --reader-veil-border: rgba(76, 117, 146, 0.1); + position: relative; + height: 100%; + min-height: 100%; + padding: 0; + color: var(--reader-text); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.78), transparent 28%), + radial-gradient(circle at bottom right, rgba(84, 166, 222, 0.16), transparent 34%), + var(--reader-bg); + overflow: hidden; +} + +.e-reader--ocean { + --reader-bg: linear-gradient(180deg, #daf3ff 0%, #c4ddf8 100%); + --reader-surface: rgba(13, 117, 183, 0.1); + --reader-text: #0e2234; + --reader-muted: rgba(14, 34, 52, 0.7); + --reader-card: rgba(239, 249, 255, 0.88); + --reader-card-border: rgba(28, 113, 173, 0.2); + --reader-drawer-surface: rgba(244, 251, 255, 0.98); + --reader-drawer-strong: rgba(255, 255, 255, 0.95); + --reader-drawer-shadow: 0 28px 72px rgba(12, 74, 121, 0.24); + --reader-drawer-mask: rgba(8, 30, 48, 0.38); + --reader-page: linear-gradient(180deg, #fbfeff 0%, #e8f5fd 100%); + --reader-page-shadow: 0 26px 64px rgba(31, 98, 152, 0.16); + --reader-accent: #0d87c9; + --reader-frame-bg: + radial-gradient(circle at top, rgba(255, 255, 255, 0.4), transparent 36%), + linear-gradient(180deg, rgba(29, 95, 144, 0.08), rgba(223, 242, 252, 0.08)); + --reader-frame-edge-left: rgba(13, 117, 183, 0.12); + --reader-frame-edge-right: rgba(13, 117, 183, 0.1); + --reader-page-border: rgba(69, 131, 176, 0.15); + --reader-veil-bg: + radial-gradient(circle at top, rgba(255, 255, 255, 0.2), transparent 36%), + linear-gradient(180deg, rgba(239, 249, 255, 0.42), rgba(214, 233, 247, 0.5)); + --reader-veil-border: rgba(53, 115, 160, 0.1); +} + +.e-reader--night { + --reader-bg: linear-gradient(180deg, #09121b 0%, #050a11 100%); + --reader-surface: rgba(125, 195, 255, 0.08); + --reader-text: #eef4fb; + --reader-muted: rgba(238, 244, 251, 0.68); + --reader-card: rgba(12, 23, 35, 0.84); + --reader-card-border: rgba(125, 195, 255, 0.16); + --reader-drawer-surface: rgba(8, 17, 27, 0.96); + --reader-drawer-strong: rgba(14, 27, 41, 0.94); + --reader-drawer-shadow: 0 32px 84px rgba(0, 0, 0, 0.46); + --reader-drawer-mask: rgba(0, 0, 0, 0.48); + --reader-page: linear-gradient(180deg, #102132 0%, #0b1521 100%); + --reader-page-shadow: 0 30px 70px rgba(0, 0, 0, 0.42); + --reader-accent: #8ed8ff; + --reader-frame-bg: + radial-gradient(circle at top, rgba(142, 216, 255, 0.08), transparent 34%), + linear-gradient(180deg, rgba(5, 12, 19, 0.78), rgba(8, 17, 27, 0.94)); + --reader-frame-edge-left: rgba(142, 216, 255, 0.1); + --reader-frame-edge-right: rgba(142, 216, 255, 0.08); + --reader-page-border: rgba(142, 216, 255, 0.12); + --reader-veil-bg: + radial-gradient(circle at top, rgba(142, 216, 255, 0.12), transparent 36%), + linear-gradient(180deg, rgba(10, 20, 31, 0.82), rgba(7, 14, 23, 0.92)); + --reader-veil-border: rgba(142, 216, 255, 0.08); +} + +.e-reader--sepia { + --reader-bg: linear-gradient(180deg, #f4e8d5 0%, #e6d3b8 100%); + --reader-surface: rgba(118, 84, 44, 0.08); + --reader-text: #3c2716; + --reader-muted: rgba(60, 39, 22, 0.66); + --reader-card: rgba(252, 246, 238, 0.9); + --reader-card-border: rgba(141, 106, 63, 0.18); + --reader-drawer-surface: rgba(250, 242, 232, 0.98); + --reader-drawer-strong: rgba(255, 249, 241, 0.95); + --reader-drawer-shadow: 0 28px 72px rgba(90, 60, 28, 0.22); + --reader-drawer-mask: rgba(47, 28, 10, 0.28); + --reader-page: linear-gradient(180deg, #f7efe2 0%, #eadbc4 100%); + --reader-page-shadow: 0 24px 64px rgba(98, 67, 31, 0.16); + --reader-accent: #9b6d35; + --reader-frame-bg: + radial-gradient(circle at top, rgba(255, 245, 226, 0.42), transparent 36%), + linear-gradient(180deg, rgba(121, 89, 51, 0.08), rgba(255, 250, 244, 0.06)); + --reader-frame-edge-left: rgba(155, 109, 53, 0.1); + --reader-frame-edge-right: rgba(155, 109, 53, 0.08); + --reader-page-border: rgba(140, 104, 67, 0.16); + --reader-veil-bg: + radial-gradient(circle at top, rgba(255, 247, 232, 0.18), transparent 36%), + linear-gradient(180deg, rgba(245, 233, 214, 0.34), rgba(227, 210, 185, 0.44)); + --reader-veil-border: rgba(132, 97, 59, 0.1); +} + +.e-reader--forest { + --reader-bg: linear-gradient(180deg, #e0efe8 0%, #c5ddd1 100%); + --reader-surface: rgba(40, 105, 73, 0.08); + --reader-text: #163022; + --reader-muted: rgba(22, 48, 34, 0.66); + --reader-card: rgba(244, 250, 245, 0.9); + --reader-card-border: rgba(62, 121, 90, 0.18); + --reader-drawer-surface: rgba(242, 249, 245, 0.98); + --reader-drawer-strong: rgba(250, 254, 251, 0.95); + --reader-drawer-shadow: 0 28px 72px rgba(24, 71, 48, 0.2); + --reader-drawer-mask: rgba(8, 30, 18, 0.28); + --reader-page: linear-gradient(180deg, #f5f9f1 0%, #dce9db 100%); + --reader-page-shadow: 0 24px 64px rgba(31, 78, 54, 0.16); + --reader-accent: #2f8f5c; + --reader-frame-bg: + radial-gradient(circle at top, rgba(250, 255, 252, 0.34), transparent 36%), + linear-gradient(180deg, rgba(47, 111, 82, 0.08), rgba(232, 243, 236, 0.08)); + --reader-frame-edge-left: rgba(47, 143, 92, 0.1); + --reader-frame-edge-right: rgba(47, 143, 92, 0.08); + --reader-page-border: rgba(71, 124, 95, 0.16); + --reader-veil-bg: + radial-gradient(circle at top, rgba(248, 255, 252, 0.16), transparent 36%), + linear-gradient(180deg, rgba(234, 243, 233, 0.32), rgba(206, 225, 214, 0.42)); + --reader-veil-border: rgba(62, 121, 90, 0.1); +} + +.e-reader--graphite { + --reader-bg: linear-gradient(180deg, #23272f 0%, #171b22 100%); + --reader-surface: rgba(194, 202, 214, 0.08); + --reader-text: #edf2f8; + --reader-muted: rgba(237, 242, 248, 0.66); + --reader-card: rgba(31, 36, 44, 0.9); + --reader-card-border: rgba(150, 162, 181, 0.18); + --reader-drawer-surface: rgba(23, 28, 36, 0.98); + --reader-drawer-strong: rgba(31, 36, 44, 0.96); + --reader-drawer-shadow: 0 30px 78px rgba(0, 0, 0, 0.42); + --reader-drawer-mask: rgba(0, 0, 0, 0.46); + --reader-page: linear-gradient(180deg, #2f3640 0%, #212731 100%); + --reader-page-shadow: 0 30px 72px rgba(0, 0, 0, 0.4); + --reader-accent: #a7b4c8; + --reader-frame-bg: + radial-gradient(circle at top, rgba(255, 255, 255, 0.05), transparent 34%), + linear-gradient(180deg, rgba(10, 13, 18, 0.78), rgba(25, 29, 37, 0.94)); + --reader-frame-edge-left: rgba(167, 180, 200, 0.08); + --reader-frame-edge-right: rgba(167, 180, 200, 0.06); + --reader-page-border: rgba(167, 180, 200, 0.12); + --reader-veil-bg: + radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 36%), + linear-gradient(180deg, rgba(30, 36, 45, 0.72), rgba(18, 22, 29, 0.86)); + --reader-veil-border: rgba(167, 180, 200, 0.08); +} + +.e-reader--rose { + --reader-bg: linear-gradient(180deg, #fbeef1 0%, #f4dce4 100%); + --reader-surface: rgba(155, 77, 101, 0.08); + --reader-text: #3c2430; + --reader-muted: rgba(60, 36, 48, 0.64); + --reader-card: rgba(255, 247, 249, 0.9); + --reader-card-border: rgba(173, 101, 125, 0.18); + --reader-drawer-surface: rgba(255, 244, 247, 0.98); + --reader-drawer-strong: rgba(255, 250, 251, 0.96); + --reader-drawer-shadow: 0 28px 72px rgba(113, 59, 80, 0.2); + --reader-drawer-mask: rgba(47, 16, 28, 0.28); + --reader-page: linear-gradient(180deg, #fff7f6 0%, #f7e6e9 100%); + --reader-page-shadow: 0 24px 64px rgba(113, 59, 80, 0.14); + --reader-accent: #cf6f8e; + --reader-frame-bg: + radial-gradient(circle at top, rgba(255, 255, 255, 0.32), transparent 36%), + linear-gradient(180deg, rgba(178, 108, 130, 0.08), rgba(255, 244, 246, 0.08)); + --reader-frame-edge-left: rgba(207, 111, 142, 0.1); + --reader-frame-edge-right: rgba(207, 111, 142, 0.08); + --reader-page-border: rgba(176, 108, 130, 0.14); + --reader-veil-bg: + radial-gradient(circle at top, rgba(255, 255, 255, 0.14), transparent 36%), + linear-gradient(180deg, rgba(251, 237, 241, 0.34), rgba(238, 214, 223, 0.44)); + --reader-veil-border: rgba(176, 108, 130, 0.1); +} + +.e-reader--dawn { + --reader-bg: linear-gradient(180deg, #f6f1e6 0%, #dfd5c2 100%); + --reader-surface: rgba(122, 94, 52, 0.08); + --reader-text: #332515; + --reader-muted: rgba(51, 37, 21, 0.64); + --reader-card: rgba(252, 247, 239, 0.9); + --reader-card-border: rgba(154, 122, 77, 0.18); + --reader-drawer-surface: rgba(250, 245, 236, 0.98); + --reader-drawer-strong: rgba(255, 249, 240, 0.96); + --reader-drawer-shadow: 0 28px 72px rgba(98, 74, 39, 0.2); + --reader-drawer-mask: rgba(47, 29, 8, 0.28); + --reader-page: linear-gradient(180deg, #faf1df 0%, #eadfc7 100%); + --reader-page-shadow: 0 24px 64px rgba(94, 71, 40, 0.15); + --reader-accent: #bd8a3d; + --reader-frame-bg: + radial-gradient(circle at top, rgba(255, 247, 228, 0.38), transparent 36%), + linear-gradient(180deg, rgba(153, 120, 70, 0.08), rgba(248, 240, 225, 0.08)); + --reader-frame-edge-left: rgba(189, 138, 61, 0.1); + --reader-frame-edge-right: rgba(189, 138, 61, 0.08); + --reader-page-border: rgba(154, 122, 77, 0.16); + --reader-veil-bg: + radial-gradient(circle at top, rgba(255, 250, 236, 0.18), transparent 36%), + linear-gradient(180deg, rgba(247, 238, 218, 0.34), rgba(226, 209, 177, 0.44)); + --reader-veil-border: rgba(154, 122, 77, 0.1); +} + +.e-reader__floating-bar, +.e-reader__floating-actions, +.e-reader__floating-status, +.e-reader__panel-header, +.e-reader__gesture-hint, +.e-reader__reader-footer, +.e-reader__page-topline, +.e-reader__page-bottomline, +.e-reader__import-url-row, +.e-reader__index-grid { + display: flex; + align-items: center; +} + +.e-reader__floating-bar, +.e-reader__panel-header, +.e-reader__reader-footer, +.e-reader__import-url-row { + justify-content: space-between; +} + +.e-reader__floating-bar { + position: absolute; + top: 14px; + left: 14px; + right: 14px; + z-index: 5; + flex-wrap: wrap; + row-gap: 8px; +} + +.e-reader__floating-actions { + gap: 8px; +} + +.e-reader__panel-header-actions, +.e-reader__floating-status { + gap: 10px; +} + +.e-reader__floating-status { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +.e-reader__card-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.e-reader__panel-header-actions { + display: inline-flex; + align-items: center; +} + +.e-reader__icon-button { + width: 38px; + min-width: 38px; + height: 38px; + padding: 0; + border: 1px solid var(--reader-card-border); + border-radius: 999px; + color: inherit; + background: rgba(255, 255, 255, 0.32); + backdrop-filter: blur(14px); +} + +.e-reader__exit-button { + height: 38px; + padding: 0 14px; + border: 1px solid var(--reader-card-border); + border-radius: 999px; + color: inherit; + background: rgba(255, 255, 255, 0.32); + backdrop-filter: blur(14px); +} + +.e-reader__reader-stage, +.e-reader__reader-panel, +.e-reader__menu-stage, +.e-reader__book-list, +.e-reader__import-fields { + height: 100%; + min-height: 0; +} + +.e-reader__reader-panel, +.e-reader__panel { + border: 1px solid var(--reader-card-border); + border-radius: 0; + background: var(--reader-card); + backdrop-filter: blur(12px); +} + +.e-reader__reader-panel { + display: grid; + grid-template-rows: minmax(0, 1fr); + gap: 0; + padding: 56px 12px 12px; +} + +.e-reader__menu-stage { + display: grid; + padding: 56px 12px 12px; +} + +.e-reader__source-link, +.e-reader__book-card span, +.e-reader__book-card p, +.e-reader__import-fields span, +.e-reader__font-control span, +.e-reader__library-status, +.e-reader__import-status, +.e-reader__display-meta { + color: var(--reader-muted); +} + +.e-reader__source-link { + display: inline-flex; + align-self: start; + font-size: 13px; + text-decoration: none; +} + +.e-reader__page-frame { + position: relative; + min-height: 0; + height: 100%; + padding: clamp(8px, 1.2vw, 14px); + border-radius: 30px; + background: var(--reader-frame-bg); + overflow: hidden; + touch-action: none; + user-select: none; +} + +.e-reader__page-frame::before, +.e-reader__page-frame::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 16%; + pointer-events: none; +} + +.e-reader__page-frame::before { + left: 0; + background: linear-gradient(90deg, var(--reader-frame-edge-left), transparent); +} + +.e-reader__page-frame::after { + right: 0; + background: linear-gradient(270deg, var(--reader-frame-edge-right), transparent); +} + +.e-reader__page-stack { + position: relative; + height: 100%; + min-height: 0; +} + +.e-reader__page-stack--hidden > .e-reader__page-layer--visible .e-reader__page { + visibility: hidden; +} + +.e-reader__page-layer { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + visibility: hidden; +} + +.e-reader__page-layer--visible { + z-index: 2; + pointer-events: auto; + visibility: visible; +} + +.e-reader__page-layer--preloading { + z-index: -1; + visibility: hidden; +} + +.e-reader__page-preload { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + visibility: hidden; +} + +.e-reader__page-veil { + position: absolute; + inset: 0; + z-index: 3; + border-radius: 24px; + background: var(--reader-veil-bg); + border: 1px solid var(--reader-veil-border); + box-shadow: var(--reader-page-shadow); +} + +.e-reader__page { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + height: 100%; + min-height: 0; + padding: clamp(20px, 3vw, 36px); + border: 1px solid var(--reader-page-border); + border-radius: 24px; + background: var(--reader-page); + box-shadow: var(--reader-page-shadow); + will-change: transform; + backface-visibility: hidden; + contain: paint; + transform: translateZ(0); +} + +.e-reader__page-topline, +.e-reader__page-bottomline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--reader-muted); +} + +.e-reader__page-topline-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.e-reader__page-topline-detail { + font-size: 11px; + letter-spacing: 0.02em; + text-transform: none; +} + +.e-reader__timeline-meta, +.e-reader__book-card-dates { + display: grid; + gap: 2px; + min-width: 0; +} + +.e-reader__page-body { + min-height: 0; + width: min(100%, 78ch); + margin: 0 auto; + line-height: 1.92; + white-space: pre-wrap; + overflow: hidden; + padding-right: 0; +} + +.e-reader__page-body p { + margin: 0 0 1.15em; +} + +.e-reader__page-html { + display: grid; + gap: 1.2em; +} + +.e-reader__page-title { + margin: 0 0 0.4em; + font-size: clamp(1.6em, 2.6vw, 2.3em); + line-height: 1.16; + letter-spacing: -0.04em; + text-wrap: balance; +} + +.e-reader__page-html > * { + margin: 0; +} + +.e-reader__page-html figure { + display: grid; + gap: 10px; + margin: 0; +} + +.e-reader__page-html img { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + height: auto; + max-height: min(32vh, 260px); + border-radius: 18px; + object-fit: cover; + background: rgba(132, 164, 184, 0.14); +} + +.e-reader__page-html figcaption, +.e-reader__page-html blockquote { + color: var(--reader-muted); +} + +.e-reader__page-measure { + position: absolute; + inset: 0; + z-index: -2; + pointer-events: none; + visibility: hidden; +} + +.e-reader__page-body--measure { + contain: strict; +} + +.e-reader__panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; + min-height: 0; + height: 100%; + padding: 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24); + overflow: hidden; +} + +.e-reader__panel--import { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.e-reader__panel--library { + position: relative; +} + +.e-reader__panel-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 6px; +} + +.e-reader__panel-body--locked { + overflow: hidden; + padding-right: 0; +} + +.e-reader__index-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.e-reader__nav-card { + display: grid; + align-content: start; + gap: 8px; + min-height: 132px; + padding: 14px; + text-align: left; + color: inherit; + border: 1px solid var(--reader-card-border); + border-radius: 20px; + background: + linear-gradient(140deg, rgba(255, 255, 255, 0.48), transparent 54%), + rgba(255, 255, 255, 0.28); + cursor: pointer; +} + +.e-reader__nav-card strong { + font-size: 15px; +} + +.e-reader__nav-card p { + margin: 0; + color: var(--reader-muted); + font-size: 12px; + line-height: 1.45; +} + +.e-reader__book-list, +.e-reader__import-fields, +.e-reader__import-grid, +.e-reader__news-filter-grid, +.e-reader__news-list { + display: grid; + gap: 12px; +} + +.e-reader__book-list, +.e-reader__import-fields { + align-content: start; +} + +.e-reader__library-toolbar { + display: grid; + gap: 10px; + margin-bottom: 12px; +} + +.e-reader__library-sort-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.e-reader__library-sort-count { + font-size: 15px; + font-weight: 700; + color: var(--reader-ink); +} + +.e-reader__library-sort-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: transparent; + color: var(--reader-ink); + font: inherit; + font-size: 15px; + font-weight: 700; + cursor: pointer; +} + +.e-reader__library-sort-trigger .anticon { + font-size: 12px; +} + +.e-reader__bottom-sheet { + position: absolute; + inset: 0; + z-index: 40; + pointer-events: none; +} + +.e-reader__bottom-sheet-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(16, 18, 22, 0.18); + pointer-events: auto; +} + +.e-reader__bottom-sheet-panel { + position: absolute; + right: 0; + bottom: 0; + left: 0; + display: grid; + gap: 12px; + padding: 12px 0 max(18px, calc(env(safe-area-inset-bottom, 0px) + 10px)); + border-radius: 28px 28px 0 0; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 -18px 48px rgba(15, 23, 42, 0.18); + pointer-events: auto; +} + +.e-reader__bottom-sheet-handle { + width: 84px; + height: 6px; + margin: 0 auto; + border-radius: 999px; + background: rgba(15, 23, 42, 0.14); +} + +.e-reader__bottom-sheet-options { + display: grid; +} + +.e-reader__bottom-sheet-option { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 72px; + padding: 0 20px; + border: 0; + border-top: 1px solid rgba(15, 23, 42, 0.08); + background: transparent; + color: var(--reader-ink); + font: inherit; + font-size: 18px; + font-weight: 700; + cursor: pointer; +} + +.e-reader__bottom-sheet-option--selected { + color: #0f9f61; +} + +.e-reader__news-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.e-reader__news-filter-grid label { + display: grid; + gap: 6px; +} + +.e-reader__news-filter-shell { + display: grid; + gap: 12px; + min-width: 0; +} + +.e-reader__news-filter-topbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: end; + min-width: 0; +} + +.e-reader__news-filter-topbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: flex-end; + min-width: 0; +} + +.e-reader__news-filter-date, +.e-reader__news-filter-limit { + display: grid; + gap: 6px; + min-width: 0; +} + +.e-reader__news-filter-date .ant-input { + min-height: 42px; + border-radius: 16px; + width: 100%; +} + +.e-reader__news-filter-grid .ant-input, +.e-reader__news-filter-grid .ant-select-selector { + min-height: 42px; + border-radius: 16px; +} + +.e-reader__news-stage { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + gap: 12px; + min-height: 0; + height: 100%; + min-width: 0; +} + +.e-reader__news-actions, +.e-reader__news-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} + +.e-reader__news-actions { + margin-bottom: 0; +} + +.e-reader__news-status-stack { + display: grid; + gap: 6px; +} + +.e-reader__news-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.e-reader__news-list { + align-content: start; + min-width: 0; +} + +.e-reader__news-list--paged { + grid-template-columns: repeat(var(--news-columns, 2), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + min-height: 0; + height: 100%; + overflow: hidden; +} + +.e-reader__news-card { + display: grid; + gap: 8px; + min-height: 0; + min-width: 0; + padding: 14px; + text-align: left; + color: inherit; + border: 1px solid var(--reader-card-border); + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 52%), + rgba(255, 255, 255, 0.26); + cursor: pointer; +} + +.e-reader__news-signal-list { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + min-width: 0; +} + +.e-reader__news-card--read, +.e-reader__book-card--read { + border-color: color-mix(in srgb, var(--reader-accent) 26%, var(--reader-card-border)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--reader-accent) 10%, white) 0%, transparent 52%), + rgba(255, 255, 255, 0.3); +} + +.e-reader__news-card strong { + font-size: 15px; + line-height: 1.45; +} + +.e-reader__news-card p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--reader-muted); + font-size: 12px; + line-height: 1.5; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; +} + +.e-reader__news-meta { + justify-content: space-between; + align-items: start; + font-size: 11px; + color: var(--reader-muted); +} + +.e-reader__news-meta > span, +.e-reader__timeline-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.e-reader__search-autocomplete { + width: 100%; +} + +.e-reader__search-autocomplete .ant-input-affix-wrapper, +.e-reader__search-autocomplete .ant-select-selector, +.e-reader__news-filter-limit .ant-select, +.e-reader__news-filter-limit .ant-select-selector { + width: 100%; + border-radius: 16px; +} + +.e-reader__install-summary, +.e-reader__install-copy { + display: grid; + gap: 6px; +} + +.e-reader__install-summary { + margin-top: 4px; + padding: 12px 14px; + border: 1px solid var(--reader-card-border); + border-radius: 18px; + background: color-mix(in srgb, var(--reader-accent) 8%, white); +} + +.e-reader__install-summary span, +.e-reader__install-copy span, +.e-reader__install-status { + font-size: 12px; + color: var(--reader-muted); +} + +.e-reader__install-summary code, +.e-reader__install-copy code { + padding: 8px 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 12px; + color: inherit; + background: rgba(255, 255, 255, 0.42); +} + +.e-reader__install-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 14px; + border: 1px solid var(--reader-card-border); + border-radius: 20px; + background: linear-gradient(135deg, color-mix(in srgb, var(--reader-accent) 10%, white) 0%, rgba(255, 255, 255, 0.34) 100%); +} + +.e-reader__install-copy strong { + font-size: 14px; +} + +.e-reader__install-status { + margin: 0; +} + +.e-reader__book-card { + display: grid; + align-content: start; + gap: 5px; + min-height: 172px; + min-width: 0; + padding: 12px; + text-align: left; + color: inherit; + border: 1px solid rgba(127, 89, 50, 0.14); + border-radius: 16px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 48%), + rgba(255, 255, 255, 0.34); + cursor: pointer; +} + +.e-reader--night .e-reader__book-card { + border-color: rgba(142, 216, 255, 0.14); + background: + linear-gradient(135deg, rgba(142, 216, 255, 0.08), transparent 48%), + rgba(255, 255, 255, 0.02); +} + +.e-reader__book-card--active { + border-color: var(--reader-accent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--reader-accent) 36%, transparent); +} + +.e-reader__book-icon { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + border-radius: 10px; + color: #fff8ef; + background: linear-gradient(135deg, var(--reader-accent) 0%, #69b7e5 100%); +} + +.e-reader__book-card strong { + display: -webkit-box; + overflow: hidden; + font-size: 14px; + line-height: 1.42; + text-overflow: ellipsis; + word-break: break-word; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.e-reader__book-card p, +.e-reader__import-fields span, +.e-reader__font-control span, +.e-reader__library-status, +.e-reader__import-status { + font-size: 12px; +} + +.e-reader__book-card-date { + display: inline-flex; + font-size: 11px; + letter-spacing: 0.02em; + color: var(--reader-muted); +} + +.e-reader__library-status, +.e-reader__import-status { + margin: 0; +} + +.e-reader__book-card p { + display: -webkit-box; + margin: 2px 0 0; + overflow: hidden; + line-height: 1.45; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.e-reader__book-list { + grid-template-columns: repeat(auto-fit, minmax(208px, 1fr)); + padding-bottom: max(16px, env(safe-area-inset-bottom, 0px)); +} + +.e-reader__book-list--paged { + grid-template-columns: repeat(var(--library-columns, 2), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + height: 100%; +} + +.e-reader__book-list--paged .e-reader__book-card { + min-height: 0; + height: 100%; +} + +.e-reader__book-empty { + display: grid; + gap: 6px; + padding: 20px 16px; + text-align: center; + border: 1px dashed var(--reader-card-border); + background: rgba(255, 255, 255, 0.2); +} + +.e-reader__book-empty p { + margin: 0; + color: var(--reader-muted); + font-size: 12px; +} + +.e-reader__import-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.e-reader__import-fields label, +.e-reader__font-control { + display: grid; + gap: 6px; +} + +.e-reader__import-body-field, +.e-reader__panel--settings .e-reader__panel-body { + align-content: start; +} + +.e-reader__import-textarea.ant-input-textarea textarea { + min-height: 240px !important; + resize: vertical; +} + +.e-reader__import-actions, +.e-reader__theme-switcher, +.e-reader__gesture-switcher { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.e-reader__library-pager { + display: grid; + min-height: 0; + height: 100%; + padding-bottom: max(16px, env(safe-area-inset-bottom, 0px)); +} + +.e-reader__book-swipe-frame { + min-height: 0; + height: 100%; + overflow: hidden; + touch-action: none; +} + +.e-reader__theme-chip { + min-width: 72px; + padding: 10px 12px; + border: 1px solid var(--reader-card-border); + border-radius: 999px; + color: inherit; + background: rgba(255, 255, 255, 0.3); + cursor: pointer; +} + +.e-reader__theme-chip--active { + border-color: var(--reader-accent); + background: color-mix(in srgb, var(--reader-accent) 14%, transparent); +} + +.e-reader__gesture-hint { + display: grid; + gap: 8px; + padding: 10px 14px; + border-radius: 18px; + background: var(--reader-surface); + font-size: 13px; +} + +.e-reader__progress { + display: grid; + gap: 8px; +} + +.e-reader__reader-footer { + gap: 14px; +} + +.e-reader__progress { + flex: 1; +} + +.e-reader__progress strong { + font-size: 12px; + color: var(--reader-muted); +} + +.e-reader__progress-bar { + position: relative; + height: 8px; + border-radius: 999px; + background: rgba(39, 90, 124, 0.12); + overflow: hidden; +} + +.e-reader__progress-bar span { + position: absolute; + inset: 0 auto 0 0; + display: block; + border-radius: inherit; + background: linear-gradient(90deg, var(--reader-accent) 0%, #69b7e5 100%); +} + +@media (max-width: 720px) { + .e-reader { + padding: 0; + } + + .e-reader__floating-bar { + top: 10px; + left: 10px; + right: 10px; + gap: 8px; + align-items: flex-start; + } + + .e-reader__reader-panel { + padding: 56px 10px 10px; + } + + .e-reader__menu-stage { + padding: 56px 10px 10px; + } + + .e-reader__panel { + padding: 14px; + gap: 12px; + } + + .e-reader__panel--library .e-reader__panel-body { + padding-bottom: max(20px, env(safe-area-inset-bottom, 0px)); + } + + .e-reader__panel--library .e-reader__book-list, + .e-reader__panel--library .e-reader__library-pager { + padding-bottom: max(28px, calc(env(safe-area-inset-bottom, 0px) + 8px)); + } + + .e-reader__import-grid, + .e-reader__import-url-row, + .e-reader__news-filter-grid { + grid-template-columns: minmax(0, 1fr); + } + + .e-reader__news-filter-topbar { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + } + + .e-reader__news-filter-topbar-actions { + justify-content: flex-start; + } + + .e-reader__news-pager { + flex-wrap: wrap; + justify-content: flex-start; + } + + .e-reader__import-url-row { + display: grid; + } + + .e-reader__install-panel { + grid-template-columns: 1fr; + } + + .e-reader__page { + padding: 18px 16px; + } + + .e-reader__page-body { + width: 100%; + line-height: 1.82; + } + + .e-reader__book-icon { + width: 30px; + height: 30px; + } +} diff --git a/src/views/play/apps/e-reader/EReaderAppView.tsx b/src/views/play/apps/e-reader/EReaderAppView.tsx new file mode 100644 index 0000000..c743200 --- /dev/null +++ b/src/views/play/apps/e-reader/EReaderAppView.tsx @@ -0,0 +1,3201 @@ +import { + ArrowLeftOutlined, + ArrowRightOutlined, + BookOutlined, + CheckOutlined, + DownOutlined, + DownloadOutlined, + FontSizeOutlined, + HomeOutlined, + LinkOutlined, + ReadOutlined, + ReloadOutlined, + SearchOutlined, + SettingOutlined, + SwapOutlined, +} from '@ant-design/icons'; +import { AutoComplete, Button, Input, Select, Slider, Tag } from 'antd'; +import { startTransition, useEffect, useMemo, useRef, useState } from 'react'; +import { isPreviewRuntime } from '../../../../app/main/previewRuntime'; +import { buildPlayAppPath } from '../../../../app/main/routes'; +import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess'; +import { + extractReaderArticle, + listReaderLibraryArticles, + searchReaderNews, + saveReaderLibraryArticle, + type EReaderLibraryArticle, + type EReaderNewsArticle, + type EReaderNewsSearchParams, +} from './eReaderApi'; +import './EReaderAppView.css'; + +type EReaderAppViewProps = { + onBack: () => void; + launchContext?: 'direct' | 'embedded'; +}; + +type ReaderTheme = 'mist' | 'ocean' | 'night' | 'sepia' | 'forest' | 'graphite' | 'rose' | 'dawn'; +type ReaderView = 'home' | 'news' | 'library' | 'import' | 'settings' | 'reader'; +type ReaderNewsTopic = NonNullable[number]>; + +type ReaderArticleSeed = { + id: string; + title: string; + sourceLabel: string; + url: string; + lead: string; + body: string; + htmlBody?: string; + tags?: string[]; + signals?: string[]; + listedDate?: string; + publishedAt?: string; + createdAt?: string; + updatedAt?: string; +}; + +type ReaderArticle = ReaderArticleSeed & { + pageTokens: string[]; +}; + +type ReaderPageBlock = { + tag: 'blockquote' | 'figure' | 'h2' | 'h3' | 'p' | 'ul'; + markup: string; + text: string; + splittable: boolean; +}; + +type SwipeTrackingState = { + pointerId: number; + startX: number; + startY: number; + deltaX: number; + deltaY: number; +}; + +type PendingPageTurn = { + articleTitle: string; + nextPage: string; + nextPageIndex: number; + nextPageLabel: string; + nextProgressPercent: number; + sourceLabel: string; +}; + +type ReaderPageCardData = { + articleTitle: string; + page: string; + pageLabel: string; + progressPercent: number; + listedDate?: string; + publishedAt?: string; + sourceLabel: string; +}; + +type ReaderPageSlot = 'primary' | 'secondary'; +type ReaderInstallState = 'standalone' | 'installable' | 'browser-help'; +type ReaderGestureMode = 'tap-swipe' | 'touch-scroll'; +type ReaderLibraryNavigationMode = 'scroll' | 'touch-swipe'; +type ReaderNewsFilters = { + keyword: string; + topics: ReaderNewsTopic[]; + sources: string[]; + dateFrom: string; + dateTo: string; + limit: number; +}; + +type ReaderLibrarySort = 'updated-desc' | 'created-desc'; + +type DeferredInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; +}; + +const { TextArea } = Input; + +const READER_STORAGE_KEY = 'play-app.e-reader.saved-articles'; +const READER_SETTINGS_STORAGE_KEY = 'play-app.e-reader.settings'; +const READER_READ_HISTORY_STORAGE_KEY = 'play-app.e-reader.read-history'; +const E_READER_IMMERSIVE_BODY_CLASS = 'play-app-e-reader-immersive'; +const E_READER_MANIFEST_PATH = '/e-reader.webmanifest'; +const PAGE_TURN_DISTANCE_PX = 52; +const PAGE_TURN_VERTICAL_TOLERANCE_PX = 74; +const PAGE_SCROLL_TURN_DISTANCE_PX = 72; +const PAGE_SCROLL_HORIZONTAL_TOLERANCE_PX = 82; +const BACK_SWIPE_EDGE_PX = 44; +const BACK_SWIPE_DISTANCE_PX = 112; +const BACK_SWIPE_VERTICAL_TOLERANCE_PX = 72; +const DEFAULT_SOURCE_URL = 'https://example.com/article'; +const READER_NEWS_LIMIT_OPTIONS = [12, 24, 36].map((count) => ({ + label: `${count}건`, + value: count, +})); +const READER_LIBRARY_SORT_OPTIONS: ReadonlyArray<{ value: ReaderLibrarySort; label: string }> = [ + { value: 'updated-desc', label: '업데이트순' }, + { value: 'created-desc', label: '최근등록순' }, +]; +const FALLBACK_BODY = [ + '인터넷 기사나 웹 콘텐츠를 전자책처럼 보려면 읽기 흐름을 먼저 정리해야 한다.', + '복잡한 광고나 사이드바를 없애고 제목, 출처, 본문만 남기면 긴 글도 훨씬 집중해서 읽을 수 있다.', + '이 리더는 URL 기준 자동 원문 가져오기와 페이지 분할을 함께 제공해 긴 글을 더 차분하게 읽게 돕는다.', +].join('\n\n'); + +const READER_THEME_OPTIONS: ReadonlyArray<{ value: ReaderTheme; label: string }> = [ + { value: 'mist', label: 'Mist' }, + { value: 'ocean', label: 'Ocean' }, + { value: 'night', label: 'Night' }, + { value: 'sepia', label: 'Sepia' }, + { value: 'forest', label: 'Forest' }, + { value: 'graphite', label: 'Graphite' }, + { value: 'rose', label: 'Rose' }, + { value: 'dawn', label: 'Dawn' }, +]; + +const READER_NEWS_TOPIC_OPTIONS: ReadonlyArray<{ value: ReaderNewsTopic; label: string }> = [ + { value: 'politics', label: '정치' }, + { value: 'economy', label: '경제' }, + { value: 'society', label: '사회' }, + { value: 'culture', label: '생활/문화' }, + { value: 'world', label: '세계' }, + { value: 'it', label: 'IT/과학' }, +]; + +const READER_ARTICLE_SEEDS: ReaderArticleSeed[] = [ + { + id: 'sample-okky-crawler-risk', + title: 'OKKY 인기글 발췌: AI가 위험한 진짜 이유, 크롤러가 남긴 운영 비용', + sourceLabel: 'OKKY 인기글', + url: 'https://okky.kr/articles/1531363', + lead: 'OKKY에서 반응이 컸던 글을 바탕으로, AI 학습용 크롤러가 운영자에게 남기는 실제 비용과 피로를 짧게 정리했다.', + tags: ['AI', '크롤러', '운영비용'], + body: [ + '이 글은 AI 자체보다, AI 학습을 명분으로 돌아다니는 크롤러가 실제 서비스 운영자에게 주는 비용이 더 직접적인 위협일 수 있다는 문제의식에서 출발한다. 한 번 돌기 시작한 봇은 정상 사용자보다 훨씬 거칠게 링크를 타고, 로그와 트래픽을 빠르게 불려 버린다.', + '작성자는 웹서버 로그와 AWS 사용량을 확인하면서, 짧은 기간에도 실제 콘텐츠 용량을 훨씬 넘는 요청이 발생한 흔적을 봤다고 설명한다. 핵심은 이 부하가 제품 가치와 무관한 크롤링에서 왔다는 점이다.', + '흥미로운 부분은 단순히 특정 회사 봇만 막는 방식이 근본 해법이 아니었다는 대목이다. 링크 구조의 edge case와 인코딩 문제를 타는 저품질 크롤러가 섞여 있었고, 결국 서비스 설정과 링크 구조를 함께 봐야 했다는 정리로 이어진다.', + '커뮤니티 반응도 컸다. AI 논의가 흔히 모델 성능이나 생산성에 몰리는 것과 달리, 이 글은 운영 비용과 저작권, 공개 저장소의 개방성 문제를 현실적인 톤으로 끌어냈기 때문이다.', + '읽기 모드에서는 이런 글이 특히 잘 맞는다. 로그 예시와 운영 관찰, 결론이 단계적으로 이어지기 때문에 광고나 주변 UI 없이 본문만 보는 쪽이 맥락을 따라가기 쉽다.', + ].join('\n\n'), + htmlBody: [ + '

AI 시대의 리스크가 모델 성능만은 아니라는 기록

', + '

이 글은 AI 자체보다, AI 학습을 명분으로 돌아다니는 크롤러가 실제 서비스 운영자에게 주는 비용이 더 직접적인 위협일 수 있다는 문제의식에서 출발한다. 한 번 돌기 시작한 봇은 정상 사용자보다 훨씬 거칠게 링크를 타고, 로그와 트래픽을 빠르게 불려 버린다.

', + '
서버 장비와 모니터링 화면
사진 1. 커뮤니티 글의 핵심은 AI 모델보다 먼저 서버 비용과 로그 폭증이 운영자에게 체감된다는 점이다.
', + '

작성자는 웹서버 로그와 사용량 지표를 확인하면서, 실제 콘텐츠 규모를 넘는 요청이 짧은 기간에 누적된 흔적을 봤다고 설명한다. 읽는 입장에서는 기술적 원인보다도, 왜 운영자가 피로를 느끼는지가 먼저 잡히는 글이다.

', + '

흥미로운 부분은 특정 봇 차단만으로 끝나지 않는다는 점이다. 링크 구조의 edge case와 URI 인코딩 문제가 섞이면 저품질 크롤러가 증폭되고, 결국 서비스 설정과 링크 구조를 함께 다뤄야 한다는 결론으로 이어진다.

', + '
데이터센터 복도와 서버 랙
사진 2. 인프라 관점의 AI 담론은 숫자와 체감 비용이 함께 보일 때 더 강하게 전달된다.
', + '
커뮤니티 반응이 컸던 이유는 AI 논의를 생산성보다 운영 현실로 끌어내렸기 때문이다.
', + ].join(''), + }, + { + id: 'sample-okky-prism-memory', + title: "OKKY 발췌: AI가 자꾸 까먹어서, 직접 '외장 뇌'를 붙인다는 발상", + sourceLabel: 'OKKY 인기글', + url: 'https://okky.kr/articles/1549361', + lead: '대화 맥락이 휘발되는 문제를 로컬 저장과 장기 기억 개념으로 풀어 보려는 1인 개발자의 시도를 요약했다.', + tags: ['AI', '장기기억', '로컬저장'], + body: [ + '이 글의 출발점은 많은 사용자가 공감하는 장면이다. ChatGPT나 Claude와 길게 대화하다 보면, 방금 합의한 맥락이 어느 순간 흐려지고 모델이 딴소리를 하기 시작한다는 경험이다.', + '작성자는 이 문제를 단순 프롬프트 기교가 아니라 기억 장치의 부재로 본다. 그래서 대화와 자료를 로컬 디스크에 보존하고, 필요할 때 다시 불러오는 외장 기억 구조를 제품 개념으로 제시한다.', + '흥미로운 점은 기능보다 비유가 강하다는 것이다. 로컬 브리지, 비전 스트림, 슬랙 연동 같은 설명을 통해 AI를 채팅창 안 도구가 아니라 확장된 작업 파트너로 상상하게 만든다.', + '커뮤니티에서 이 글이 주목받은 이유도 비슷하다. 화려한 모델 비교보다, AI와 오래 일할수록 생기는 컨텍스트 드리프트와 기록 주권 문제를 더 직설적으로 건드렸기 때문이다.', + '읽기 앱용 발췌본으로 보면 한 번에 훑기 좋다. 문제 정의, 구조적 해결책, 철학까지 단계가 분명해서 페이지 단위 분할에도 잘 맞는다.', + ].join('\n\n'), + htmlBody: [ + '

채팅창 밖에 기억을 두겠다는 아이디어

', + '

이 글의 출발점은 많은 사용자가 공감하는 장면이다. ChatGPT나 Claude와 길게 대화하다 보면, 방금 합의한 맥락이 어느 순간 흐려지고 모델이 다른 대답을 꺼내는 경험이다.

', + '

작성자는 이 문제를 단순 프롬프트 기술이 아니라 기억 장치의 부재로 해석한다. 그래서 대화와 자료를 로컬 디스크에 보존하고, 필요할 때 다시 불러오는 외장 기억 구조를 제품 개념으로 제시한다.

', + '
개발자가 여러 화면에서 작업하는 장면
사진 1. 이 발췌본의 핵심 장면은 AI를 채팅창이 아니라 작업 환경 전체와 연결된 존재로 보는 시선이다.
', + '

커뮤니티 반응은 제품 구현보다 철학에 더 가까웠다. 기억이 휘발되지 않기를 바라는 사용자 경험, 그리고 기록을 클라우드 바깥으로 되찾고 싶다는 감각이 동시에 읽혔기 때문이다.

', + '
핵심은 더 똑똑한 답변이 아니라, 맥락이 사라지지 않는 작업 루프를 만들겠다는 선언에 있다.
', + ].join(''), + }, + { + id: 'sample-okky-ai-levels', + title: 'OKKY 정리글 발췌: AI 활용으로 도달할 수 있는 개발 수준 10단계', + sourceLabel: 'OKKY 인기글', + url: 'https://okky.kr/articles/1555082?topic=ai', + lead: '단순 코드 생성에서 끝나지 않고, SQL·아키텍처·자동화·업무 시스템화까지 확장해 보는 관점을 짧게 묶었다.', + tags: ['AI', '자동화', '개발성장'], + body: [ + '이 글은 AI를 얼마나 잘 쓰느냐를 단순 프롬프트 숙련도보다, 개발 프로세스를 어디까지 시스템화할 수 있느냐의 문제로 본다. 그래서 포스트맨 대체 도구 만들기처럼 작은 자동화부터 시작해 점차 범위를 넓혀 간다.', + '중간 단계에서는 SQL 분석, 외부 API 정합성, 코드 구조 고도화, DB 설계 같은 일상적인 개발 문제에 AI를 붙여 보는 흐름이 제시된다. 즉 결과물보다 루프를 정리하는 쪽에 더 무게가 있다.', + '후반부는 더 공격적이다. RAG, 벡터 DB, 업무 자동화, 에이전트, 하네스 엔지니어링까지 나아가며 결국 조직 지식과 일정, 규칙을 응답 시스템으로 묶는 그림을 상상한다.', + '그래서 이 글은 기술 튜토리얼이라기보다 체크리스트에 가깝다. 지금 내가 AI를 어디에 붙이고 있고, 아직 손대지 않은 병목은 무엇인지 역으로 생각하게 만든다.', + '서재 샘플로는 단계별 시야를 넓히는 글 역할을 한다. 제품 글과 뉴스형 글 사이에 두면 읽기 흐름도 자연스럽다.', + ].join('\n\n'), + htmlBody: [ + '

AI 활용을 능숙함이 아니라 시스템화로 보는 시선

', + '

이 글은 AI를 얼마나 잘 쓰느냐를 단순 프롬프트 숙련도보다, 개발 프로세스를 어디까지 시스템화할 수 있느냐의 문제로 본다. 그래서 작은 자동화부터 시작해 점차 범위를 넓혀 가자는 제안이 중심에 놓여 있다.

', + '
네트워크와 데이터 흐름을 표현한 추상 이미지
사진 1. 글이 말하는 성장 곡선은 기능 하나보다 연결된 업무 흐름 전체를 보는 시각에 가깝다.
', + '

중간 단계에서는 SQL 분석, 외부 API 정합성, 코드 구조 고도화, DB 설계 같은 실전 업무가 반복해서 등장한다. 화려한 데모보다 현업에 바로 닿는 주제를 앞세우는 점이 이 글의 강점이다.

', + '

후반부는 RAG, 에이전트, 업무 응답 봇까지 나아가며 결국 지식과 의사결정을 축적 가능한 시스템으로 묶는 방향을 가리킨다.

', + '
결국 이 발췌본은 AI를 도구가 아니라 팀의 작업 방식을 재조립하는 계기로 보자는 제안이다.
', + ].join(''), + }, + { + id: 'sample-geeknews-ai-growth', + title: 'GeekNews 인기글 발췌: AI와 함께 일하며 복리처럼 성장하는 법', + sourceLabel: 'GeekNews 인기글', + url: 'https://news.hada.io/topic?id=29606', + lead: 'AI 협업의 핵심을 한 번의 정답보다, 다음 세션까지 남는 컨텍스트와 검증 루프로 해석한 글을 읽기용으로 요약했다.', + tags: ['AI', '협업', '컨텍스트'], + body: [ + '이 글은 AI를 잘 쓰는 방법을 모델 선택 요령보다 작업 축적 방식으로 설명한다. 코드, 문서, 분석, 결정이 다음 세션의 컨텍스트로 남을 때 생산성이 단발성 향상이 아니라 복리처럼 쌓인다는 주장이다.', + '그래서 중요한 것은 한 번의 프롬프트가 아니라, 설정 파일과 가이드, 검증 루틴을 코드처럼 관리하는 습관이 된다. 사람이 반복해서 같은 실수를 교정하지 않게 만드는 운영 방식에 더 가깝다.', + '읽는 사람 입장에서는 화려한 성공담보다 재현 가능한 루프를 설명한다는 점이 좋다. 컨텍스트 제공, 취향 설정, 검증 자동화, 위임 확대, 피드백 루프처럼 개념 축이 분명해 긴 글이어도 따라가기 쉽다.', + '서재 샘플에서는 실무 톤의 긴 글 역할을 맡는다. 앞선 OKKY 글들이 현장 감각을 건드린다면, 이 글은 그 감각을 운영 원칙으로 정리해 준다.', + ].join('\n\n'), + }, + { + id: 'sample-geeknews-ai-spec', + title: 'GeekNews 읽을거리 발췌: AI 에이전트를 위한 좋은 스펙 작성법', + sourceLabel: 'GeekNews 읽을거리', + url: 'https://news.hada.io/topic?id=25949', + lead: '잘 만든 스펙이 AI 에이전트의 성능을 얼마나 좌우하는지, 명령어·테스트·프로젝트 구조 관점으로 묶은 글이다.', + tags: ['AI', '에이전트', '스펙'], + body: [ + '이 글은 에이전트가 자꾸 엇나가는 이유를 모델 성능 부족보다 스펙의 품질 문제로 본다. 문서가 메모처럼 흩어져 있으면 AI는 무엇을 실행하고 무엇을 검증해야 하는지 일관되게 파악하기 어렵다.', + '그래서 좋은 스펙은 전문적인 PRD나 SRS처럼 구조화되어야 한다는 제안이 나온다. 실행 명령, 테스트 방법, 프로젝트 구조, 제약조건, 인간이 개입해야 하는 경계가 앞쪽에 또렷하게 놓여야 한다는 정리다.', + '특히 인상적인 부분은 스펙을 금지 목록이 아니라 코치와 심판의 혼합물로 보자는 관점이다. 에이전트가 언제 바로 진행하고, 언제 멈춰 묻고, 언제 아예 중단해야 하는지 단계적으로 알려줘야 한다.', + '개발 서재에 넣기 좋은 이유도 분명하다. 추상적인 AI 담론이 아니라 지금 저장소 문서에 무엇을 써야 하는지 바로 떠올리게 만드는 글이기 때문이다.', + ].join('\n\n'), + htmlBody: [ + '

AI 에이전트는 좋은 스펙을 만나야 덜 흔들린다

', + '

이 글은 에이전트가 자꾸 엇나가는 이유를 모델 성능 부족보다 스펙의 품질 문제로 본다. 문서가 메모처럼 흩어져 있으면 AI는 무엇을 실행하고 무엇을 검증해야 하는지 일관되게 파악하기 어렵다.

', + '

그래서 좋은 스펙은 PRD나 SRS처럼 구조화되어야 한다는 제안이 나온다. 실행 명령, 테스트 방법, 프로젝트 구조, 제약조건, 인간이 개입해야 하는 경계가 앞쪽에 또렷하게 놓여야 한다.

', + '
회의 테이블 위 문서와 노트북
사진 1. 좋은 스펙은 아이디어 메모가 아니라 실행 순서와 판단 기준이 정리된 운영 문서에 가깝다.
', + '

특히 인상적인 부분은 스펙을 금지 목록이 아니라 코치와 심판의 혼합물로 보자는 관점이다. 에이전트가 언제 바로 진행하고, 언제 멈춰 묻고, 언제 아예 중단해야 하는지 단계적으로 알려줘야 한다.

', + '
화이트보드 앞에서 계획을 점검하는 팀
사진 2. 테스트와 제약조건을 문서에 미리 심어 두면 AI는 사람의 감독 없이도 덜 엇나간다.
', + '
좋은 스펙은 AI를 더 똑똑하게 만들기보다, 덜 헷갈리게 만든다.
', + ].join(''), + }, + { + id: 'sample-geeknews-llm-building', + title: 'GeekNews 긴 글 발췌: 1년 동안 LLM과 함께 구축하며 배운 점', + sourceLabel: 'GeekNews 긴 글', + url: 'https://news.hada.io/topic?id=15268', + lead: 'LLM이 데모를 넘어 제품으로 들어올 때 무엇이 어려워지는지, 데이터와 평가 중심으로 압축한 발췌본이다.', + tags: ['LLM', '데이터', '평가'], + body: [ + '이 글은 지난 1년 사이 LLM이 충분히 좋아졌지만, 실제 제품으로 붙이는 일은 여전히 별개의 문제라는 점을 차분하게 정리한다. 모델 성능만 높아졌다고 운영 품질이 자동으로 확보되지는 않는다는 뜻이다.', + '가장 자주 강조되는 축은 데이터와 평가다. 프로덕션에서 만나는 입력 분포를 제대로 보지 않으면 개발 중에 좋아 보이던 시스템이 실제 사용자 앞에서 쉽게 무너질 수 있다는 설명이 이어진다.', + '또 하나는 비용과 구조다. 모든 문제에 가장 큰 모델을 붙이는 대신, 더 작은 도구와 명확한 파이프라인으로 같은 목적을 달성할 수 있는지 계속 검토해야 한다는 태도가 반복된다.', + '그래서 이 글은 LLM 낙관론과 비관론 사이에서 드문 균형을 준다. 가능성은 인정하되, 결국 해자를 만드는 것은 데이터 품질과 평가 체계, 제품 UX라는 점을 잊지 않게 한다.', + ].join('\n\n'), + htmlBody: [ + '

LLM 제품화는 데모 이후가 더 어렵다

', + '

이 글은 지난 1년 사이 LLM이 충분히 좋아졌지만, 실제 제품으로 붙이는 일은 여전히 별개의 문제라는 점을 차분하게 정리한다. 모델 성능 향상만으로 운영 품질이 자동으로 확보되지는 않는다.

', + '
대시보드와 데이터 화면을 바라보는 작업 환경
사진 1. 긴 글이 반복해서 강조하는 축은 모델 선택보다 데이터 분포와 평가 루프다.
', + '

가장 자주 언급되는 문제는 개발-프로덕션 편향이다. 내부 테스트에서는 잘 되지만 실제 입력 분포를 만나면 무너지는 패턴을 막으려면, 사용 데이터를 지속적으로 들여다보는 습관이 필요하다.

', + '
분석 화면과 노트북이 놓인 책상
사진 2. 결국 제품 해자는 더 큰 모델보다도 데이터 품질, 평가 설계, UX 조합에서 만들어진다.
', + '
LLM 시대에도 끝까지 남는 경쟁력은 입력 데이터와 평가 체계를 얼마나 잘 운영하느냐다.
', + ].join(''), + }, +]; + +function buildArticleId() { + return `custom-${Date.now().toString(36)}`; +} + +function normalizeArticleUrl(value: string) { + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return ''; + } + + try { + const url = new URL(trimmedValue); + url.hash = ''; + url.pathname = url.pathname.replace(/\/+$/u, '') || '/'; + return url.toString(); + } catch { + return trimmedValue.replace(/#.*$/u, '').replace(/\/+$/u, ''); + } +} + +function normalizeLineBreaks(value: string) { + return value.replace(/\r\n?/gu, '\n').trim(); +} + +function normalizeTags(tags: string[] | undefined) { + if (!tags?.length) { + return []; + } + + return Array.from( + new Set( + tags + .map((tag) => tag.replace(/^#/u, '').trim()) + .filter(Boolean), + ), + ); +} + +function normalizePublishedAt(value: string | undefined) { + if (!value?.trim()) { + return undefined; + } + + const trimmedValue = value.trim(); + + if (/^\d{4}-\d{2}-\d{2}$/u.test(trimmedValue)) { + return trimmedValue; + } + + const parsedDate = new Date(trimmedValue); + + if (Number.isNaN(parsedDate.getTime())) { + return undefined; + } + + return parsedDate.toISOString(); +} + +function formatPublishedAt(value: string | undefined) { + if (!value?.trim()) { + return ''; + } + + const trimmedValue = value.trim(); + const normalizedValue = normalizePublishedAt(trimmedValue); + + if (!normalizedValue) { + return ''; + } + + const date = new Date(normalizedValue); + + if (Number.isNaN(date.getTime())) { + return normalizedValue; + } + + const hasExplicitTime = /(?:[t\s]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)|(?:오전|오후)\s*\d{1,2}:\d{2}/iu.test(trimmedValue); + + return new Intl.DateTimeFormat( + 'ko-KR', + hasExplicitTime + ? { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + } + : { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }, + ).format(date); +} + +function formatDateInputValue(date: Date) { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function normalizeListedDate(value: string | undefined) { + const trimmedValue = value?.trim() ?? ''; + return /^\d{4}-\d{2}-\d{2}$/u.test(trimmedValue) ? trimmedValue : undefined; +} + +function buildArticleTimelineDetails(article: Pick) { + const details: string[] = []; + + if (article.listedDate) { + details.push(`목록 기준 ${formatPublishedAt(article.listedDate)}`); + } + + if (article.publishedAt) { + details.push(`원본 등록 ${formatPublishedAt(article.publishedAt)}`); + } + + return details; +} + +function createDefaultNewsFilters(): ReaderNewsFilters { + const today = new Date(); + + return { + keyword: '', + topics: [], + sources: [], + dateFrom: formatDateInputValue(today), + dateTo: formatDateInputValue(today), + limit: 24, + }; +} + +function createDefaultNewsFiltersStatus() { + return '선택한 날짜 범위의 네이버 목록을 기준으로 수집하고, 카드에는 목록 기준일과 원본 등록 시각을 함께 표시합니다.'; +} + +function buildNewsArticleSeed(item: EReaderNewsArticle) { + return { + id: item.id, + title: item.title, + sourceLabel: item.sourceLabel, + url: item.url, + lead: item.lead, + body: item.body, + htmlBody: item.htmlBody, + tags: normalizeTags(item.tags), + signals: Array.isArray(item.signals) ? item.signals.filter((signal) => typeof signal === 'string' && signal.trim()) : undefined, + listedDate: normalizeListedDate(item.listedDate), + publishedAt: normalizePublishedAt(item.publishedAt), + createdAt: typeof item.createdAt === 'string' && item.createdAt.trim() ? item.createdAt : undefined, + updatedAt: typeof item.updatedAt === 'string' && item.updatedAt.trim() ? item.updatedAt : undefined, + } satisfies ReaderArticleSeed; +} + +function resolveSortableTimestamp(value: string | undefined) { + if (!value?.trim()) { + return 0; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime(); +} + +function compareReaderArticlesBySort(left: ReaderArticle, right: ReaderArticle, sort: ReaderLibrarySort) { + const sortKey = sort === 'created-desc' ? 'createdAt' : 'updatedAt'; + const primaryGap = resolveSortableTimestamp(right[sortKey]) - resolveSortableTimestamp(left[sortKey]); + + if (primaryGap !== 0) { + return primaryGap; + } + + const publishedGap = resolveSortableTimestamp(right.publishedAt) - resolveSortableTimestamp(left.publishedAt); + if (publishedGap !== 0) { + return publishedGap; + } + + return left.title.localeCompare(right.title, 'ko'); +} + +function resolveNewsSignalTone(signal: string) { + if (signal === '속보') { + return 'error'; + } + + if (signal === '핵심') { + return 'gold'; + } + + return 'processing'; +} + +function inferArticleTags(seed: Pick) { + const haystack = `${seed.sourceLabel} ${seed.title} ${seed.lead} ${seed.body}`.toLowerCase(); + const detectedTags = new Set(); + + if (haystack.includes('ai')) { + detectedTags.add('AI'); + } + if (haystack.includes('llm')) { + detectedTags.add('LLM'); + } + if (haystack.includes('okky')) { + detectedTags.add('OKKY'); + } + if (haystack.includes('geeknews')) { + detectedTags.add('GeekNews'); + } + if (haystack.includes('에이전트')) { + detectedTags.add('에이전트'); + } + if (haystack.includes('자동화')) { + detectedTags.add('자동화'); + } + if (haystack.includes('데이터')) { + detectedTags.add('데이터'); + } + if (haystack.includes('저장') || haystack.includes('clip')) { + detectedTags.add('저장글'); + } + + return Array.from(detectedTags); +} + +function splitIntoParagraphs(body: string) { + return normalizeLineBreaks(body) + .split(/\n{2,}/u) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); +} + +function escapeHtml(value: string) { + return value + .replace(/&/gu, '&') + .replace(//gu, '>') + .replace(/"/gu, '"') + .replace(/'/gu, '''); +} + +function stripHtmlForMeasure(value: string) { + return value + .replace(/]*>/giu, ' [image] ') + .replace(/<[^>]+>/gu, ' ') + .replace(/\s+/gu, ' ') + .trim(); +} + +function buildReaderPageTitleMarkup(title: string) { + const normalizedTitle = title.trim() || 'Untitled'; + return `

${escapeHtml(normalizedTitle)}

`; +} + +function splitHtmlIntoBlocks(htmlBody: string) { + const blockMatches = htmlBody.match(/|<(?:h1|h2|h3|p|blockquote|ul)\b[\s\S]*?<\/(?:h1|h2|h3|p|blockquote|ul)>/giu); + return blockMatches?.map((block) => block.trim()).filter(Boolean) ?? []; +} + +function buildTextBlock(tag: 'blockquote' | 'h2' | 'h3' | 'p', text: string): ReaderPageBlock { + const normalizedText = text.trim(); + return { + tag, + markup: `<${tag}>${escapeHtml(normalizedText)}`, + text: normalizedText, + splittable: true, + }; +} + +function buildReaderPageBlocks(htmlBody: string | undefined, body: string) { + const htmlBlocks = htmlBody?.trim() ? splitHtmlIntoBlocks(htmlBody) : []; + + if (htmlBlocks.length) { + return htmlBlocks.map((block) => { + const tagMatch = block.match(/^<([a-z0-9]+)/iu); + const tag = (tagMatch?.[1]?.toLowerCase() ?? 'p') as ReaderPageBlock['tag']; + return { + tag, + markup: block, + text: stripHtmlForMeasure(block), + splittable: tag !== 'figure' && tag !== 'ul', + } satisfies ReaderPageBlock; + }); + } + + return splitIntoParagraphs(body).map((paragraph) => buildTextBlock('p', paragraph)); +} + +function buildPageTokens(body: string, pageSize: number) { + const paragraphs = splitIntoParagraphs(body); + + if (!paragraphs.length) { + return [FALLBACK_BODY]; + } + + const pages: string[] = []; + let currentPage = ''; + + paragraphs.forEach((paragraph) => { + const nextValue = currentPage ? `${currentPage}\n\n${paragraph}` : paragraph; + + if (currentPage && nextValue.length > pageSize) { + pages.push(currentPage); + currentPage = paragraph; + return; + } + + if (paragraph.length > pageSize) { + if (currentPage) { + pages.push(currentPage); + currentPage = ''; + } + + const sentences = paragraph.split(/(?<=[.!?।…])\s+/u).filter(Boolean); + let sentenceBucket = ''; + + sentences.forEach((sentence) => { + const nextSentenceBucket = sentenceBucket ? `${sentenceBucket} ${sentence}` : sentence; + + if (sentenceBucket && nextSentenceBucket.length > pageSize) { + pages.push(sentenceBucket); + sentenceBucket = sentence; + return; + } + + sentenceBucket = nextSentenceBucket; + }); + + if (sentenceBucket) { + currentPage = sentenceBucket; + } + + return; + } + + currentPage = nextValue; + }); + + if (currentPage) { + pages.push(currentPage); + } + + return pages.length ? pages : [FALLBACK_BODY]; +} + +function buildHtmlPageTokens(htmlBody: string | undefined, body: string, pageSize: number) { + if (!htmlBody?.trim()) { + return buildPageTokens(body, pageSize); + } + + const blocks = splitHtmlIntoBlocks(htmlBody); + + if (!blocks.length) { + return buildPageTokens(body, pageSize); + } + + const pages: string[] = []; + let currentPageBlocks: string[] = []; + let currentWeight = 0; + + blocks.forEach((block) => { + const blockWeight = / pageSize) { + pages.push(currentPageBlocks.join('')); + currentPageBlocks = [block]; + currentWeight = blockWeight; + return; + } + + currentPageBlocks.push(block); + currentWeight += blockWeight; + }); + + if (currentPageBlocks.length) { + pages.push(currentPageBlocks.join('')); + } + + return pages.length ? pages : buildPageTokens(body, pageSize); +} + +function resolvePageSize(width: number, fontSize: number) { + if (width <= 420) { + return Math.max(340, Math.round(500 - fontSize * 10)); + } + + if (width <= 860) { + return Math.max(540, Math.round(820 - fontSize * 12)); + } + + return Math.max(720, Math.round(1160 - fontSize * 16)); +} + +function buildReaderArticle(seed: ReaderArticleSeed, pageSize: number): ReaderArticle { + return { + ...seed, + tags: normalizeTags(seed.tags), + pageTokens: buildHtmlPageTokens(seed.htmlBody, seed.body, pageSize), + }; +} + +function clampIndex(value: number, pageCount: number) { + if (pageCount <= 0) { + return 0; + } + + return Math.min(Math.max(value, 0), pageCount - 1); +} + +function normalizeSearchKeyword(value: string) { + return value.replace(/#/gu, '').trim().toLowerCase(); +} + +function buildReadHistoryKeys(article: Pick | Pick) { + const keys = new Set(); + + if (article.id.trim()) { + keys.add(`id:${article.id}`); + } + + const normalizedUrl = normalizeArticleUrl(article.url); + if (normalizedUrl) { + keys.add(`url:${normalizedUrl}`); + } + + return Array.from(keys); +} + +function readStoredReadHistory() { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = window.localStorage.getItem(READER_READ_HISTORY_STORAGE_KEY); + + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return Array.from( + new Set( + parsed + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + ), + ); + } catch { + return []; + } +} + +function writeStoredReadHistory(items: string[]) { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(READER_READ_HISTORY_STORAGE_KEY, JSON.stringify(Array.from(new Set(items)))); +} + +function chunkArticles(items: T[], pageSize: number) { + if (pageSize <= 0) { + return [items]; + } + + const pages: T[][] = []; + + for (let index = 0; index < items.length; index += pageSize) { + pages.push(items.slice(index, index + pageSize)); + } + + return pages.length ? pages : [[]]; +} + +function resolveLibraryColumns(width: number) { + if (width <= 720) { + return 2; + } + + if (width <= 1040) { + return 3; + } + + return 4; +} + +function resolveLibraryRows(width: number, height: number) { + if (width <= 720) { + return height <= 760 ? 2 : 3; + } + + return height <= 780 ? 2 : 3; +} + +function normalizeStoredArticleSeed(parsed: Partial | null | undefined) { + if (!parsed?.title || !parsed?.body) { + return null; + } + + return { + id: typeof parsed.id === 'string' && parsed.id ? parsed.id : buildArticleId(), + title: parsed.title, + sourceLabel: typeof parsed.sourceLabel === 'string' && parsed.sourceLabel ? parsed.sourceLabel : 'Saved Clip', + url: typeof parsed.url === 'string' && parsed.url ? parsed.url : DEFAULT_SOURCE_URL, + lead: typeof parsed.lead === 'string' && parsed.lead ? parsed.lead : '저장한 웹 클립', + body: parsed.body, + htmlBody: typeof parsed.htmlBody === 'string' && parsed.htmlBody ? parsed.htmlBody : undefined, + tags: normalizeTags(Array.isArray(parsed.tags) ? parsed.tags.filter((tag): tag is string => typeof tag === 'string') : inferArticleTags({ + sourceLabel: typeof parsed.sourceLabel === 'string' ? parsed.sourceLabel : 'Saved Clip', + title: parsed.title, + lead: typeof parsed.lead === 'string' ? parsed.lead : '저장한 웹 클립', + body: parsed.body, + })), + signals: Array.isArray(parsed.signals) + ? parsed.signals.filter((signal): signal is string => typeof signal === 'string' && signal.trim()) + : undefined, + listedDate: normalizeListedDate(typeof parsed.listedDate === 'string' ? parsed.listedDate : undefined), + publishedAt: normalizePublishedAt(typeof parsed.publishedAt === 'string' ? parsed.publishedAt : undefined), + createdAt: typeof parsed.createdAt === 'string' && parsed.createdAt.trim() ? parsed.createdAt : undefined, + updatedAt: typeof parsed.updatedAt === 'string' && parsed.updatedAt.trim() ? parsed.updatedAt : undefined, + } satisfies ReaderArticleSeed; +} + +function mergeSavedArticleSeeds(...groups: ReaderArticleSeed[][]) { + const seenIds = new Set(); + const seenUrls = new Set(); + const merged: ReaderArticleSeed[] = []; + + groups.forEach((items) => { + items.forEach((item) => { + const normalizedUrl = normalizeArticleUrl(item.url); + + if (!item.id || seenIds.has(item.id) || (normalizedUrl && seenUrls.has(normalizedUrl))) { + return; + } + + seenIds.add(item.id); + if (normalizedUrl) { + seenUrls.add(normalizedUrl); + } + merged.push(item); + }); + }); + + return merged; +} + +function upsertSavedArticleSeed(items: ReaderArticleSeed[], article: ReaderArticleSeed) { + const normalizedUrl = normalizeArticleUrl(article.url); + const duplicatedArticle = + items.find((item) => item.id === article.id) + ?? items.find((item) => normalizedUrl && normalizeArticleUrl(item.url) === normalizedUrl); + const timestamp = new Date().toISOString(); + const nextArticle = { + ...article, + createdAt: article.createdAt ?? duplicatedArticle?.createdAt ?? timestamp, + updatedAt: timestamp, + } satisfies ReaderArticleSeed; + + return [ + nextArticle, + ...items.filter((item) => { + if (item.id === nextArticle.id) { + return false; + } + + if (!normalizedUrl) { + return true; + } + + return normalizeArticleUrl(item.url) !== normalizedUrl; + }), + ]; +} + +function writeStoredArticles(items: ReaderArticleSeed[]) { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(READER_STORAGE_KEY, JSON.stringify(items)); +} + +function readStoredArticles() { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = window.localStorage.getItem(READER_STORAGE_KEY); + + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as Partial | Partial[] | null; + + if (Array.isArray(parsed)) { + return parsed + .map((item) => normalizeStoredArticleSeed(item)) + .filter((item): item is ReaderArticleSeed => Boolean(item)); + } + + const single = normalizeStoredArticleSeed(parsed); + return single ? [single] : []; + } catch { + return []; + } +} + +function readStoredSettings() { + if (typeof window === 'undefined') { + return null; + } + + try { + const raw = window.localStorage.getItem(READER_SETTINGS_STORAGE_KEY); + + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as Partial<{ + fontSize: number; + gestureMode: ReaderGestureMode; + libraryNavigationMode: ReaderLibraryNavigationMode; + theme: ReaderTheme; + }> | null; + + return { + fontSize: typeof parsed?.fontSize === 'number' ? parsed.fontSize : undefined, + gestureMode: parsed?.gestureMode === 'touch-scroll' ? 'touch-scroll' : parsed?.gestureMode === 'tap-swipe' ? 'tap-swipe' : undefined, + libraryNavigationMode: + parsed?.libraryNavigationMode === 'touch-swipe' + ? 'touch-swipe' + : parsed?.libraryNavigationMode === 'scroll' + ? 'scroll' + : undefined, + theme: READER_THEME_OPTIONS.some((option) => option.value === parsed?.theme) ? parsed?.theme : undefined, + }; + } catch { + return null; + } +} + +function renderPageParagraphs(page: string) { + return splitIntoParagraphs(page).map((paragraph, index) =>

{paragraph}

); +} + +function renderPageBody(page: string) { + if (/<(?:figure|h1|h2|h3|blockquote|ul|p)\b/iu.test(page)) { + return
; + } + + return renderPageParagraphs(page); +} + +function waitForAnimationFrame() { + return new Promise((resolve) => { + if (typeof window === 'undefined') { + resolve(); + return; + } + + window.requestAnimationFrame(() => resolve()); + }); +} + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +function getReaderLaunchPath() { + return buildPlayAppPath('e-reader'); +} + +function getReaderLaunchUrl() { + if (typeof window === 'undefined') { + return getReaderLaunchPath(); + } + + return new URL(getReaderLaunchPath(), window.location.origin).toString(); +} + +function getInstallGuideMessage() { + if (typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent)) { + return 'Safari 공유 메뉴에서 홈 화면에 추가를 선택하면 E-Reader 전용 아이콘으로 저장됩니다.'; + } + + return '브라우저 메뉴의 홈 화면에 추가 또는 앱 설치를 사용하면 E-Reader를 바로 열 수 있습니다.'; +} + +async function createEReaderManifestObjectUrl(registeredToken: string) { + if (typeof window === 'undefined') { + return ''; + } + + const response = await window.fetch(E_READER_MANIFEST_PATH, { + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`manifest fetch failed: ${response.status}`); + } + + const manifest = (await response.json()) as Record; + const startUrl = new URL( + typeof manifest.start_url === 'string' && manifest.start_url.trim() ? manifest.start_url : getReaderLaunchPath(), + window.location.origin, + ); + + startUrl.searchParams.set('registeredAccessToken', registeredToken); + manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`; + + return window.URL.createObjectURL( + new Blob([JSON.stringify(manifest, null, 2)], { + type: 'application/manifest+json', + }), + ); +} + +function swapManifestForEReader(manifestHref = E_READER_MANIFEST_PATH) { + if (typeof document === 'undefined') { + return () => undefined; + } + + const head = document.head; + let manifestLink = document.querySelector('link[rel="manifest"]'); + const createdManifestLink = !manifestLink; + + if (!manifestLink) { + manifestLink = document.createElement('link'); + manifestLink.rel = 'manifest'; + head.appendChild(manifestLink); + } + + const previousManifestHref = manifestLink.href; + manifestLink.href = manifestHref; + + let appleTitleMeta = document.querySelector('meta[name="apple-mobile-web-app-title"]'); + const createdAppleTitleMeta = !appleTitleMeta; + const previousAppleTitle = appleTitleMeta?.content ?? null; + + if (!appleTitleMeta) { + appleTitleMeta = document.createElement('meta'); + appleTitleMeta.name = 'apple-mobile-web-app-title'; + head.appendChild(appleTitleMeta); + } + + appleTitleMeta.content = 'E-Reader'; + + return () => { + if (createdManifestLink) { + manifestLink?.remove(); + } else if (manifestLink) { + manifestLink.href = previousManifestHref; + } + + if (createdAppleTitleMeta) { + appleTitleMeta?.remove(); + } else if (appleTitleMeta) { + appleTitleMeta.content = previousAppleTitle ?? ''; + } + }; +} + +async function waitForPageAssets(root: HTMLElement | null) { + await waitForAnimationFrame(); + await waitForAnimationFrame(); + + if (!root) { + return; + } + + const images = Array.from(root.querySelectorAll('img')); + if (!images.length) { + return; + } + + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + const finalize = () => { + image.removeEventListener('load', finalize); + image.removeEventListener('error', finalize); + resolve(); + }; + + if (image.complete) { + if (typeof image.decode === 'function') { + image.decode().catch(() => undefined).finally(resolve); + return; + } + + resolve(); + return; + } + + image.addEventListener('load', finalize, { once: true }); + image.addEventListener('error', finalize, { once: true }); + }), + ), + ); + + await waitForAnimationFrame(); +} + +function splitBlockForMeasurement(block: ReaderPageBlock) { + if (!block.splittable || !block.text) { + return [block]; + } + + const units = block.text.split(/(?<=[.!?।…])\s+/u).filter(Boolean); + const fallbackUnits = units.length > 1 ? units : block.text.split(/\s+/u).filter(Boolean); + + if (!fallbackUnits.length) { + return [block]; + } + + const chunks: ReaderPageBlock[] = []; + let currentChunk = ''; + + fallbackUnits.forEach((unit) => { + const nextChunk = currentChunk ? `${currentChunk} ${unit}` : unit; + + if (currentChunk && nextChunk.length > 260) { + chunks.push(buildTextBlock(block.tag === 'blockquote' ? 'blockquote' : 'p', currentChunk)); + currentChunk = unit; + return; + } + + currentChunk = nextChunk; + }); + + if (currentChunk) { + chunks.push(buildTextBlock(block.tag === 'blockquote' ? 'blockquote' : 'p', currentChunk)); + } + + return chunks.length ? chunks : [block]; +} + +function paginateBlocksByMeasurement(blocks: ReaderPageBlock[], pageBody: HTMLDivElement) { + const pages: string[] = []; + let currentBlocks: ReaderPageBlock[] = []; + const titleMarkup = buildReaderPageTitleMarkup(pageBody.dataset.articleTitle ?? ''); + + const buildPageMarkup = (candidateBlocks: ReaderPageBlock[], isFirstPage: boolean) => + `${isFirstPage ? titleMarkup : ''}${candidateBlocks.map((block) => block.markup).join('')}`; + + const applyBlocks = (candidateBlocks: ReaderPageBlock[]) => { + pageBody.innerHTML = buildPageMarkup(candidateBlocks, pages.length === 0); + return pageBody.scrollHeight <= pageBody.clientHeight + 1; + }; + + const pushCurrentPage = () => { + if (!currentBlocks.length) { + return; + } + + pages.push(buildPageMarkup(currentBlocks, pages.length === 0)); + currentBlocks = []; + }; + + const pushTitleOnlyFirstPage = () => { + if (pages.length > 0) { + return; + } + + pages.push(buildPageMarkup([], true)); + }; + + blocks.forEach((block) => { + const nextBlocks = [...currentBlocks, block]; + + if (applyBlocks(nextBlocks)) { + currentBlocks = nextBlocks; + return; + } + + if (currentBlocks.length) { + pushCurrentPage(); + } + + if (applyBlocks([block])) { + currentBlocks = [block]; + return; + } + + if (pages.length === 0) { + pushTitleOnlyFirstPage(); + + if (applyBlocks([block])) { + currentBlocks = [block]; + return; + } + } + + splitBlockForMeasurement(block).forEach((splitBlock) => { + const splitCandidate = [...currentBlocks, splitBlock]; + + if (!applyBlocks(splitCandidate) && currentBlocks.length) { + pushCurrentPage(); + } + + if (!applyBlocks([splitBlock])) { + pages.push(buildPageMarkup([splitBlock], pages.length === 0)); + currentBlocks = []; + return; + } + + currentBlocks = [splitBlock]; + }); + }); + + pushCurrentPage(); + pageBody.innerHTML = ''; + return pages.length ? pages : [FALLBACK_BODY]; +} + +type ReaderPageCardProps = { + articleTitle: string; + fontSize: number; + page: string; + pageLabel: string; + progressPercent: number; + listedDate?: string; + publishedAt?: string; + sourceLabel: string; +}; + +function ReaderPageCard({ + articleTitle, + fontSize, + page, + pageLabel, + progressPercent, + listedDate, + publishedAt, + sourceLabel, +}: ReaderPageCardProps) { + const timelineDetails = buildArticleTimelineDetails({ listedDate, publishedAt }); + + return ( +
+
+
+ {sourceLabel} + {timelineDetails.map((detail) => ( + + {detail} + + ))} +
+ {pageLabel} +
+
+ {renderPageBody(page)} +
+
+ {articleTitle} + {progressPercent}% +
+
+ ); +} + +function buildReaderPageCardData(article: ReaderArticle | undefined, nextPageIndex: number, pageCount: number) { + const safePageIndex = clampIndex(nextPageIndex, pageCount); + + return { + articleTitle: article?.title ?? 'Untitled', + page: article?.pageTokens[safePageIndex] ?? '', + pageLabel: pageCount ? `${safePageIndex + 1} / ${pageCount}` : '0 / 0', + progressPercent: pageCount > 0 ? Math.round(((safePageIndex + 1) / pageCount) * 100) : 0, + listedDate: article?.listedDate, + publishedAt: article?.publishedAt, + sourceLabel: article?.sourceLabel ?? 'Reader', + } satisfies ReaderPageCardData; +} + +function sleep(ms: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppViewProps) { + const storedSettings = readStoredSettings(); + const initialStoredArticles = readStoredArticles(); + const initialReadHistory = readStoredReadHistory(); + const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? 1280 : window.innerWidth)); + const [viewportHeight, setViewportHeight] = useState(() => (typeof window === 'undefined' ? 900 : window.innerHeight)); + const [fontSize, setFontSize] = useState(() => storedSettings?.fontSize ?? 18); + const [theme, setTheme] = useState(() => storedSettings?.theme ?? 'mist'); + const [activeArticleId, setActiveArticleId] = useState(() => initialStoredArticles[0]?.id ?? READER_ARTICLE_SEEDS[0].id); + const [currentView, setCurrentView] = useState('home'); + const [viewHistory, setViewHistory] = useState([]); + const [pageIndex, setPageIndex] = useState(0); + const [displayPageIndex, setDisplayPageIndex] = useState(0); + const [draftTitle, setDraftTitle] = useState('오늘 읽을 웹 클립'); + const [draftSourceLabel, setDraftSourceLabel] = useState('Saved Clip'); + const [draftUrl, setDraftUrl] = useState(DEFAULT_SOURCE_URL); + const [draftBody, setDraftBody] = useState(FALLBACK_BODY); + const [draftHtmlBody, setDraftHtmlBody] = useState(undefined); + const [savedArticleSeeds, setSavedArticleSeeds] = useState(() => initialStoredArticles); + const [readHistoryKeys, setReadHistoryKeys] = useState(() => initialReadHistory); + const [librarySearchTerm, setLibrarySearchTerm] = useState(''); + const [librarySort, setLibrarySort] = useState('updated-desc'); + const [isLibrarySortSheetOpen, setIsLibrarySortSheetOpen] = useState(false); + const [newsFilters, setNewsFilters] = useState(() => createDefaultNewsFilters()); + const [newsResults, setNewsResults] = useState([]); + const [isSearchingNews, setIsSearchingNews] = useState(false); + const [newsStatus, setNewsStatus] = useState(''); + const [isNewsFiltersCollapsed, setIsNewsFiltersCollapsed] = useState(false); + const [gestureMode, setGestureMode] = useState(() => storedSettings?.gestureMode ?? 'tap-swipe'); + const [libraryNavigationMode, setLibraryNavigationMode] = useState( + () => storedSettings?.libraryNavigationMode ?? 'scroll', + ); + const [pendingPageTurn, setPendingPageTurn] = useState(null); + const [visiblePageSlot, setVisiblePageSlot] = useState('primary'); + const [primaryPageCard, setPrimaryPageCard] = useState(() => buildReaderPageCardData(undefined, 0, 0)); + const [secondaryPageCard, setSecondaryPageCard] = useState(() => buildReaderPageCardData(undefined, 0, 0)); + const [isPageContentHidden, setIsPageContentHidden] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [importStatus, setImportStatus] = useState(''); + const [librarySyncStatus, setLibrarySyncStatus] = useState(''); + const [isLibrarySyncing, setIsLibrarySyncing] = useState(false); + const [librarySyncRequestId, setLibrarySyncRequestId] = useState(0); + const [installState, setInstallState] = useState(() => + isStandaloneDisplayMode() ? 'standalone' : 'browser-help', + ); + const [installStatus, setInstallStatus] = useState(''); + const swipeTrackingRef = useRef(null); + const backSwipeTrackingRef = useRef(null); + const backSwipeTriggeredRef = useRef(false); + const deferredInstallPromptRef = useRef(null); + const librarySwipeTrackingRef = useRef(null); + const librarySwipeSuppressClickRef = useRef(false); + const newsAutoSearchAttemptedRef = useRef(false); + const primaryPageRef = useRef(null); + const secondaryPageRef = useRef(null); + const measurementBodyRef = useRef(null); + const pageTurnRequestIdRef = useRef(0); + const [measuredPagination, setMeasuredPagination] = useState<{ articleId: string; pageTokens: string[] } | null>(null); + const [libraryPageIndex, setLibraryPageIndex] = useState(0); + const [newsPageIndex, setNewsPageIndex] = useState(0); + + const pageSize = useMemo(() => resolvePageSize(viewportWidth, fontSize), [fontSize, viewportWidth]); + const libraryColumns = useMemo(() => resolveLibraryColumns(viewportWidth), [viewportWidth]); + const libraryRows = useMemo(() => resolveLibraryRows(viewportWidth, viewportHeight), [viewportHeight, viewportWidth]); + const libraryPageSize = useMemo(() => libraryColumns * libraryRows, [libraryColumns, libraryRows]); + const newsColumns = useMemo(() => (viewportWidth <= 720 ? 1 : 2), [viewportWidth]); + const newsRows = useMemo(() => { + if (viewportWidth <= 720) { + return viewportHeight < 760 ? 2 : 3; + } + + return viewportHeight < 860 ? 2 : 3; + }, [viewportHeight, viewportWidth]); + const newsPageSize = useMemo(() => newsColumns * newsRows, [newsColumns, newsRows]); + + const articles = useMemo(() => { + const seeds = mergeSavedArticleSeeds(savedArticleSeeds, READER_ARTICLE_SEEDS); + return seeds.map((seed) => buildReaderArticle(seed, pageSize)); + }, [pageSize, savedArticleSeeds]); + + const activeArticle = useMemo( + () => articles.find((article) => article.id === activeArticleId) ?? articles[0], + [activeArticleId, articles], + ); + const activeArticleIndex = useMemo( + () => articles.findIndex((article) => article.id === activeArticle?.id), + [activeArticle?.id, articles], + ); + const previousArticle = activeArticleIndex > 0 ? articles[activeArticleIndex - 1] : undefined; + const nextArticle = activeArticleIndex >= 0 ? articles[activeArticleIndex + 1] : undefined; + const libraryTags = useMemo( + () => Array.from(new Set(articles.flatMap((article) => article.tags ?? []))).sort((left, right) => left.localeCompare(right, 'ko')), + [articles], + ); + const libraryTagSuggestions = useMemo(() => { + const normalizedSearch = normalizeSearchKeyword(librarySearchTerm); + const candidateTags = normalizedSearch + ? libraryTags.filter((tag) => tag.toLowerCase().includes(normalizedSearch)) + : libraryTags.slice(0, 8); + + return candidateTags.map((tag) => ({ + label: `#${tag}`, + value: `#${tag}`, + })); + }, [librarySearchTerm, libraryTags]); + const filteredArticles = useMemo(() => { + const normalizedSearch = normalizeSearchKeyword(librarySearchTerm); + + return articles + .filter((article) => { + if (!normalizedSearch) { + return true; + } + + const searchableText = [article.title, article.sourceLabel, article.lead, ...(article.tags ?? [])].join(' ').toLowerCase(); + return searchableText.includes(normalizedSearch); + }) + .sort((left, right) => compareReaderArticlesBySort(left, right, librarySort)); + }, [articles, librarySearchTerm, librarySort]); + const libraryPages = useMemo( + () => chunkArticles(filteredArticles, libraryPageSize), + [filteredArticles, libraryPageSize], + ); + const newsPages = useMemo( + () => chunkArticles(newsResults, newsPageSize), + [newsPageSize, newsResults], + ); + const safeLibraryPageIndex = Math.min(Math.max(libraryPageIndex, 0), Math.max(libraryPages.length - 1, 0)); + const visibleLibraryArticles = libraryPages[safeLibraryPageIndex] ?? []; + const safeNewsPageIndex = Math.min(Math.max(newsPageIndex, 0), Math.max(newsPages.length - 1, 0)); + const visibleNewsResults = newsPages[safeNewsPageIndex] ?? []; + const librarySortLabel = READER_LIBRARY_SORT_OPTIONS.find((option) => option.value === librarySort)?.label ?? '업데이트순'; + const libraryCountLabel = librarySearchTerm.trim() ? `검색 ${filteredArticles.length}` : `전체 ${filteredArticles.length}`; + const effectivePageTokens = + measuredPagination?.articleId === activeArticle?.id ? measuredPagination.pageTokens : (activeArticle?.pageTokens ?? []); + const paginatedActiveArticle = useMemo( + () => (activeArticle ? { ...activeArticle, pageTokens: effectivePageTokens } : activeArticle), + [activeArticle, effectivePageTokens], + ); + const isMeasuredPaginationReady = currentView !== 'reader' || measuredPagination?.articleId === activeArticle?.id; + const pageCount = paginatedActiveArticle?.pageTokens.length ?? 0; + const safePageIndex = clampIndex(pageIndex, pageCount); + const safeDisplayPageIndex = clampIndex(displayPageIndex, pageCount); + const hiddenPageSlot = visiblePageSlot === 'primary' ? 'secondary' : 'primary'; + const isEmbeddedLaunch = launchContext === 'embedded'; + const hasViewHistory = viewHistory.length > 0; + const canExitToParentApp = isEmbeddedLaunch && installState !== 'standalone'; + const isStandaloneHome = installState === 'standalone' && currentView === 'home'; + const canShowParentBackButton = canExitToParentApp; + const canShowInternalBackButton = !isStandaloneHome && (!isEmbeddedLaunch || hasViewHistory); + const newsSourceOptions = useMemo(() => { + const sourceLabels = Array.from( + new Set( + [ + ...newsFilters.sources, + ...newsResults.map((item) => item.sourceLabel), + ...savedArticleSeeds.map((item) => item.sourceLabel), + ] + .map((value) => value.trim()) + .filter(Boolean), + ), + ).sort((left, right) => left.localeCompare(right, 'ko')); + + return sourceLabels.map((value) => ({ label: value, value })); + }, [newsFilters.sources, newsResults, savedArticleSeeds]); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const handleResize = () => { + setViewportWidth(window.innerWidth); + setViewportHeight(window.innerHeight); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem( + READER_SETTINGS_STORAGE_KEY, + JSON.stringify({ + fontSize, + gestureMode, + libraryNavigationMode, + theme, + }), + ); + }, [fontSize, gestureMode, libraryNavigationMode, theme]); + + useEffect(() => { + writeStoredArticles(savedArticleSeeds); + }, [savedArticleSeeds]); + + useEffect(() => { + writeStoredReadHistory(readHistoryKeys); + }, [readHistoryKeys]); + + useEffect(() => { + let isCancelled = false; + + void (async () => { + setIsLibrarySyncing(true); + try { + setLibrarySyncStatus('서버 서가를 확인하는 중입니다.'); + let remoteItems: EReaderLibraryArticle[] | null = null; + let syncError: unknown; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + remoteItems = await listReaderLibraryArticles(); + syncError = undefined; + break; + } catch (error) { + syncError = error; + + if (attempt === 0) { + if (isCancelled) { + return; + } + + setLibrarySyncStatus('서버 연결을 다시 확인하는 중입니다.'); + await sleep(900); + continue; + } + } + } + + if (!remoteItems) { + throw syncError instanceof Error ? syncError : new Error('서버 서가를 불러오지 못했습니다.'); + } + + if (isCancelled) { + return; + } + + const remoteSeeds = remoteItems.map((item) => ({ + id: item.id, + title: item.title, + sourceLabel: item.sourceLabel, + url: item.url, + lead: item.lead, + body: item.body, + htmlBody: item.htmlBody, + tags: normalizeTags(item.tags), + listedDate: normalizeListedDate(item.listedDate), + publishedAt: normalizePublishedAt(item.publishedAt), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }) satisfies ReaderArticleSeed); + + const localSeeds = readStoredArticles(); + const mergedSeeds = mergeSavedArticleSeeds(remoteSeeds, localSeeds); + setSavedArticleSeeds(mergedSeeds); + + const remoteIds = new Set(remoteSeeds.map((item) => item.id)); + const unsyncedLocalSeeds = localSeeds.filter((item) => !remoteIds.has(item.id)); + + if (!unsyncedLocalSeeds.length) { + setLibrarySyncStatus(remoteSeeds.length ? '서버 저장글과 동기화되었습니다.' : '서버 저장글이 아직 없습니다.'); + return; + } + + setLibrarySyncStatus('기존 로컬 저장글을 서버로 동기화하는 중입니다.'); + const uploadedItems = await Promise.all( + unsyncedLocalSeeds.map((article) => saveReaderLibraryArticle(article as EReaderLibraryArticle)), + ); + + if (isCancelled) { + return; + } + + const uploadedSeeds = uploadedItems.map((item) => ({ + id: item.id, + title: item.title, + sourceLabel: item.sourceLabel, + url: item.url, + lead: item.lead, + body: item.body, + htmlBody: item.htmlBody, + tags: normalizeTags(item.tags), + listedDate: normalizeListedDate(item.listedDate), + publishedAt: normalizePublishedAt(item.publishedAt), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }) satisfies ReaderArticleSeed); + + setSavedArticleSeeds(mergeSavedArticleSeeds(uploadedSeeds, remoteSeeds, localSeeds)); + setLibrarySyncStatus('로컬 저장글까지 서버 서가와 동기화되었습니다.'); + } catch { + if (!isCancelled) { + setLibrarySyncStatus('서버 응답이 불안정해 이 기기 저장글만 표시합니다. 위 새로고침으로 다시 확인할 수 있습니다.'); + } + } finally { + if (!isCancelled) { + setIsLibrarySyncing(false); + } + } + })(); + + return () => { + isCancelled = true; + }; + }, [librarySyncRequestId]); + + useEffect(() => { + if (typeof document === 'undefined') { + return undefined; + } + + let isDisposed = false; + let dynamicManifestHref = ''; + document.body.classList.add(E_READER_IMMERSIVE_BODY_CLASS); + const previewRuntimeToken = isPreviewRuntime() ? getRegisteredAccessToken() : ''; + const restoreManifest = swapManifestForEReader(); + + if (previewRuntimeToken) { + void (async () => { + try { + const manifestObjectUrl = await createEReaderManifestObjectUrl(previewRuntimeToken); + + if (isDisposed) { + window.URL.revokeObjectURL(manifestObjectUrl); + return; + } + + dynamicManifestHref = manifestObjectUrl; + restoreManifest(); + swapManifestForEReader(manifestObjectUrl); + } catch { + // Fall back to the static manifest when the dynamic install URL cannot be prepared. + } + })(); + } + + return () => { + isDisposed = true; + if (dynamicManifestHref) { + window.URL.revokeObjectURL(dynamicManifestHref); + } + restoreManifest(); + document.body.classList.remove(E_READER_IMMERSIVE_BODY_CLASS); + }; + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const syncInstallState = () => { + setInstallState((currentState) => { + if (isStandaloneDisplayMode()) { + return 'standalone'; + } + + return deferredInstallPromptRef.current ? 'installable' : currentState === 'standalone' ? 'browser-help' : currentState; + }); + }; + + const handleBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + deferredInstallPromptRef.current = event as DeferredInstallPromptEvent; + setInstallState(isStandaloneDisplayMode() ? 'standalone' : 'installable'); + setInstallStatus(''); + }; + + const handleAppInstalled = () => { + deferredInstallPromptRef.current = null; + setInstallState('standalone'); + setInstallStatus('홈 화면 앱으로 추가되었습니다. 다음부터는 E-Reader로 바로 열립니다.'); + }; + + syncInstallState(); + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.addEventListener('appinstalled', handleAppInstalled); + window.addEventListener('resize', syncInstallState); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.removeEventListener('appinstalled', handleAppInstalled); + window.removeEventListener('resize', syncInstallState); + }; + }, []); + + useEffect(() => { + const nextPageCard = buildReaderPageCardData(paginatedActiveArticle, safeDisplayPageIndex, pageCount); + + setPageIndex((currentPageIndex) => clampIndex(currentPageIndex, pageCount)); + setDisplayPageIndex((currentPageIndex) => clampIndex(currentPageIndex, pageCount)); + setVisiblePageSlot('primary'); + setPrimaryPageCard(nextPageCard); + setSecondaryPageCard(nextPageCard); + setPendingPageTurn(null); + setIsPageContentHidden(false); + }, [pageCount, paginatedActiveArticle, safeDisplayPageIndex]); + + useEffect(() => { + if (!activeArticle?.id) { + setMeasuredPagination(null); + return; + } + + if (currentView !== 'reader') { + return; + } + + const measurementBody = measurementBodyRef.current; + if (!measurementBody) { + return; + } + + measurementBody.dataset.articleTitle = activeArticle.title; + const pageBlocks = buildReaderPageBlocks(activeArticle.htmlBody, activeArticle.body); + const nextPageTokens = paginateBlocksByMeasurement(pageBlocks, measurementBody); + + setMeasuredPagination((current) => { + if ( + current?.articleId === activeArticle.id && + current.pageTokens.length === nextPageTokens.length && + current.pageTokens.every((token, index) => token === nextPageTokens[index]) + ) { + return current; + } + + return { + articleId: activeArticle.id, + pageTokens: nextPageTokens, + }; + }); + }, [activeArticle?.body, activeArticle?.htmlBody, activeArticle?.id, currentView, fontSize, viewportHeight, viewportWidth]); + + useEffect(() => { + if (!pendingPageTurn) { + return undefined; + } + + const requestId = ++pageTurnRequestIdRef.current; + const nextVisiblePageSlot = hiddenPageSlot; + const nextPageRef = nextVisiblePageSlot === 'primary' ? primaryPageRef : secondaryPageRef; + let isCancelled = false; + + void (async () => { + await waitForPageAssets(nextPageRef.current); + + if (isCancelled || requestId !== pageTurnRequestIdRef.current) { + return; + } + + setIsPageContentHidden(true); + await waitForAnimationFrame(); + + if (isCancelled || requestId !== pageTurnRequestIdRef.current) { + return; + } + + setVisiblePageSlot(nextVisiblePageSlot); + setDisplayPageIndex(pendingPageTurn.nextPageIndex); + setPageIndex(pendingPageTurn.nextPageIndex); + await waitForAnimationFrame(); + await waitForAnimationFrame(); + + if (isCancelled || requestId !== pageTurnRequestIdRef.current) { + return; + } + + setIsPageContentHidden(false); + setPendingPageTurn(null); + })(); + + return () => { + isCancelled = true; + }; + }, [hiddenPageSlot, pendingPageTurn]); + + useEffect(() => { + if (activeArticle?.id) { + return; + } + + setActiveArticleId(savedArticleSeeds[0]?.id ?? READER_ARTICLE_SEEDS[0].id); + }, [activeArticle?.id, savedArticleSeeds]); + + useEffect(() => { + setLibraryPageIndex((currentIndex) => Math.min(Math.max(currentIndex, 0), Math.max(libraryPages.length - 1, 0))); + }, [libraryPages.length]); + + useEffect(() => { + setNewsPageIndex((currentIndex) => Math.min(Math.max(currentIndex, 0), Math.max(newsPages.length - 1, 0))); + }, [newsPages.length]); + + useEffect(() => { + setLibraryPageIndex(0); + }, [libraryNavigationMode, librarySearchTerm, librarySort]); + + useEffect(() => { + if (currentView !== 'library') { + setIsLibrarySortSheetOpen(false); + } + }, [currentView]); + + useEffect(() => { + setNewsPageIndex(0); + }, [newsFilters.dateFrom, newsFilters.dateTo, newsFilters.keyword, newsFilters.limit, newsFilters.sources, newsFilters.topics]); + + useEffect(() => { + if (!activeArticle) { + return; + } + + setDraftTitle(activeArticle.title); + setDraftSourceLabel(activeArticle.sourceLabel); + setDraftUrl(activeArticle.url); + setDraftBody(activeArticle.body); + setDraftHtmlBody(activeArticle.htmlBody); + }, [activeArticle]); + + useEffect(() => { + if (currentView !== 'news') { + newsAutoSearchAttemptedRef.current = false; + return; + } + + if (newsAutoSearchAttemptedRef.current || isSearchingNews || newsResults.length > 0) { + return; + } + + newsAutoSearchAttemptedRef.current = true; + void handleSearchNews({ silent: true }); + }, [currentView, isSearchingNews, newsResults.length]); + + const moveToView = (nextView: ReaderView) => { + if (currentView === nextView) { + return; + } + + setViewHistory((history) => [...history, currentView]); + setCurrentView(nextView); + }; + + const handleGoBack = () => { + if (isStandaloneHome) { + return; + } + + const previousView = viewHistory[viewHistory.length - 1]; + + if (!previousView) { + onBack(); + return; + } + + setViewHistory((history) => history.slice(0, -1)); + setCurrentView(previousView); + }; + + const handleExitToParentApp = () => { + onBack(); + }; + + const handleBackGesturePointerDownCapture: React.PointerEventHandler = (event) => { + if (isStandaloneHome || event.pointerType === 'mouse' || event.clientX > BACK_SWIPE_EDGE_PX) { + backSwipeTrackingRef.current = null; + backSwipeTriggeredRef.current = false; + return; + } + + backSwipeTrackingRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + deltaX: 0, + deltaY: 0, + }; + backSwipeTriggeredRef.current = false; + }; + + const handleBackGesturePointerMoveCapture: React.PointerEventHandler = (event) => { + const tracking = backSwipeTrackingRef.current; + + if (!tracking || tracking.pointerId !== event.pointerId || backSwipeTriggeredRef.current) { + return; + } + + const deltaX = event.clientX - tracking.startX; + const deltaY = event.clientY - tracking.startY; + + backSwipeTrackingRef.current = { + ...tracking, + deltaX, + deltaY, + }; + + if (deltaX < BACK_SWIPE_DISTANCE_PX || Math.abs(deltaY) > BACK_SWIPE_VERTICAL_TOLERANCE_PX) { + return; + } + + backSwipeTriggeredRef.current = true; + backSwipeTrackingRef.current = null; + handleGoBack(); + }; + + const finishBackGesturePointerTrackingCapture: React.PointerEventHandler = (event) => { + const tracking = backSwipeTrackingRef.current; + + if (tracking?.pointerId === event.pointerId) { + backSwipeTrackingRef.current = null; + } + + backSwipeTriggeredRef.current = false; + }; + + const commitArticle = (articleSeed: ReaderArticleSeed) => { + setSavedArticleSeeds((currentItems) => upsertSavedArticleSeed(currentItems, articleSeed)); + startTransition(() => { + setActiveArticleId(articleSeed.id); + setPageIndex(0); + moveToView('reader'); + }); + }; + + const syncArticleToServer = async (articleSeed: ReaderArticleSeed) => { + const savedItem = await saveReaderLibraryArticle(articleSeed as EReaderLibraryArticle); + const normalizedSavedArticle = { + id: savedItem.id, + title: savedItem.title, + sourceLabel: savedItem.sourceLabel, + url: savedItem.url, + lead: savedItem.lead, + body: savedItem.body, + htmlBody: savedItem.htmlBody, + tags: normalizeTags(savedItem.tags), + listedDate: normalizeListedDate(savedItem.listedDate), + publishedAt: normalizePublishedAt(savedItem.publishedAt), + signals: Array.isArray(savedItem.signals) ? savedItem.signals.filter((signal) => typeof signal === 'string' && signal.trim()) : undefined, + createdAt: savedItem.createdAt, + updatedAt: savedItem.updatedAt, + } satisfies ReaderArticleSeed; + + setSavedArticleSeeds((currentItems) => upsertSavedArticleSeed(currentItems, normalizedSavedArticle)); + setLibrarySyncStatus('서버 서가와 동기화되었습니다.'); + return normalizedSavedArticle; + }; + + const buildImportedArticleSeed = ( + item: { + title: string; + sourceLabel: string; + url: string; + lead: string; + body: string; + htmlBody?: string; + publishedAt?: string; + }, + fallbackDraft?: Partial>, + ) => { + const normalizedUrl = normalizeArticleUrl(item.url.trim() || draftUrl.trim()); + const duplicatedArticle = savedArticleSeeds.find((savedItem) => normalizeArticleUrl(savedItem.url) === normalizedUrl); + const nextCustomArticle = { + id: duplicatedArticle?.id ?? buildArticleId(), + title: item.title.trim() || fallbackDraft?.title?.trim() || draftTitle.trim() || '가져온 원문', + sourceLabel: item.sourceLabel.trim() || fallbackDraft?.sourceLabel?.trim() || draftSourceLabel.trim() || 'Imported Article', + url: normalizedUrl, + lead: item.lead.trim() || item.body.split('\n')[0]?.trim() || fallbackDraft?.lead?.trim() || '가져온 원문', + body: normalizeLineBreaks(item.body), + htmlBody: item.htmlBody?.trim() || undefined, + tags: inferArticleTags({ + sourceLabel: item.sourceLabel.trim() || fallbackDraft?.sourceLabel?.trim() || draftSourceLabel.trim() || 'Imported Article', + title: item.title.trim() || fallbackDraft?.title?.trim() || draftTitle.trim() || '가져온 원문', + lead: item.lead.trim() || item.body.split('\n')[0]?.trim() || fallbackDraft?.lead?.trim() || '가져온 원문', + body: item.body, + }), + signals: duplicatedArticle?.signals, + listedDate: normalizeListedDate(item.listedDate) ?? duplicatedArticle?.listedDate, + publishedAt: normalizePublishedAt(item.publishedAt) ?? duplicatedArticle?.publishedAt, + createdAt: duplicatedArticle?.createdAt, + updatedAt: duplicatedArticle?.updatedAt, + } satisfies ReaderArticleSeed; + + return { duplicatedArticle, nextCustomArticle }; + }; + + const handleSearchNews = async (options?: { silent?: boolean }) => { + if (!newsFilters.dateFrom || !newsFilters.dateTo) { + setNewsStatus('뉴스 검색은 날짜 범위가 필수입니다.'); + return; + } + + if (newsFilters.dateFrom > newsFilters.dateTo) { + setNewsStatus('시작 날짜가 종료 날짜보다 늦을 수 없습니다.'); + return; + } + + setIsSearchingNews(true); + if (!options?.silent) { + setNewsStatus(''); + } + + try { + const items = await searchReaderNews(newsFilters); + setNewsResults(items); + if (items.length) { + const savedNewsSeeds = items.map((item) => buildNewsArticleSeed(item)); + setSavedArticleSeeds((currentItems) => mergeSavedArticleSeeds(savedNewsSeeds, currentItems)); + setActiveArticleId((currentId) => currentId || savedNewsSeeds[0]?.id || currentId); + } + setNewsStatus( + items.length + ? `${newsFilters.dateFrom}${newsFilters.dateFrom === newsFilters.dateTo ? '' : ` ~ ${newsFilters.dateTo}`} 네이버 목록 기준으로 뉴스 ${items.length}건을 서가에 수집했습니다.` + : `${newsFilters.dateFrom}${newsFilters.dateFrom === newsFilters.dateTo ? '' : ` ~ ${newsFilters.dateTo}`} 목록 기준에 맞는 네이버 뉴스가 없습니다.`, + ); + } catch (error) { + setNewsResults([]); + setNewsStatus(error instanceof Error ? error.message : '뉴스 검색에 실패했습니다.'); + } finally { + setIsSearchingNews(false); + } + }; + + const handleOpenNewsArticle = (newsItem: EReaderNewsArticle) => { + const nextArticle = buildNewsArticleSeed(newsItem); + + setDraftTitle(nextArticle.title); + setDraftSourceLabel(nextArticle.sourceLabel); + setDraftUrl(nextArticle.url); + setDraftBody(nextArticle.body); + setDraftHtmlBody(nextArticle.htmlBody); + commitArticle(nextArticle); + setNewsStatus('수집한 뉴스를 바로 펼쳤습니다.'); + }; + + const markArticleAsRead = (article: Pick | Pick) => { + const nextKeys = buildReadHistoryKeys(article); + + if (!nextKeys.length) { + return; + } + + setReadHistoryKeys((currentKeys) => { + const mergedKeys = new Set(currentKeys); + let hasChanged = false; + + nextKeys.forEach((key) => { + if (!mergedKeys.has(key)) { + mergedKeys.add(key); + hasChanged = true; + } + }); + + return hasChanged ? Array.from(mergedKeys) : currentKeys; + }); + }; + + const isReadArticle = (article: Pick | Pick) => + buildReadHistoryKeys(article).some((key) => readHistoryKeys.includes(key)); + + const turnPage = (direction: 'next' | 'prev') => { + if (!paginatedActiveArticle || pendingPageTurn || isPageContentHidden) { + return; + } + + const nextPageIndex = + direction === 'next' + ? clampIndex(safeDisplayPageIndex + 1, paginatedActiveArticle.pageTokens.length) + : clampIndex(safeDisplayPageIndex - 1, paginatedActiveArticle.pageTokens.length); + + if (nextPageIndex === safeDisplayPageIndex) { + return; + } + + const nextPageTurn = { + articleTitle: activeArticle.title, + nextPage: paginatedActiveArticle.pageTokens[nextPageIndex] ?? '', + nextPageIndex, + nextPageLabel: `${nextPageIndex + 1} / ${pageCount}`, + nextProgressPercent: pageCount > 0 ? Math.round(((nextPageIndex + 1) / pageCount) * 100) : 0, + sourceLabel: paginatedActiveArticle.sourceLabel, + } satisfies PendingPageTurn; + + const nextPageCard = { + articleTitle: nextPageTurn.articleTitle, + page: nextPageTurn.nextPage, + pageLabel: nextPageTurn.nextPageLabel, + progressPercent: nextPageTurn.nextProgressPercent, + sourceLabel: nextPageTurn.sourceLabel, + } satisfies ReaderPageCardData; + + if (hiddenPageSlot === 'primary') { + setPrimaryPageCard(nextPageCard); + } else { + setSecondaryPageCard(nextPageCard); + } + + setPendingPageTurn(nextPageTurn); + }; + + const moveToAdjacentArticle = (direction: 'next' | 'prev') => { + if (pendingPageTurn || isPageContentHidden) { + return false; + } + + const targetArticle = direction === 'next' ? nextArticle : previousArticle; + + if (!targetArticle) { + return false; + } + + startTransition(() => { + setActiveArticleId(targetArticle.id); + setPageIndex(0); + setDisplayPageIndex(0); + setPendingPageTurn(null); + setIsPageContentHidden(false); + }); + + return true; + }; + + const turnReaderContent = (direction: 'next' | 'prev') => { + if (!paginatedActiveArticle) { + return; + } + + const atBoundary = + direction === 'next' + ? safeDisplayPageIndex >= Math.max(pageCount - 1, 0) + : safeDisplayPageIndex <= 0; + + if (atBoundary) { + moveToAdjacentArticle(direction); + return; + } + + turnPage(direction); + }; + + const handleSaveClip = async () => { + const normalizedBody = normalizeLineBreaks(draftBody); + const normalizedUrl = normalizeArticleUrl(draftUrl) || DEFAULT_SOURCE_URL; + + if (!draftTitle.trim() || !normalizedBody) { + setImportStatus('제목과 본문을 먼저 채워 주세요.'); + return; + } + + const duplicatedArticle = savedArticleSeeds.find((item) => normalizeArticleUrl(item.url) === normalizedUrl); + + const nextCustomArticle = { + id: duplicatedArticle?.id ?? buildArticleId(), + title: draftTitle.trim(), + sourceLabel: draftSourceLabel.trim() || 'Saved Clip', + url: normalizedUrl, + lead: normalizedBody.split('\n')[0]?.trim() || '저장한 웹 클립', + body: normalizedBody, + htmlBody: draftHtmlBody?.trim() || undefined, + tags: inferArticleTags({ + sourceLabel: draftSourceLabel.trim() || 'Saved Clip', + title: draftTitle.trim(), + lead: normalizedBody.split('\n')[0]?.trim() || '저장한 웹 클립', + body: normalizedBody, + }), + listedDate: duplicatedArticle?.listedDate, + publishedAt: duplicatedArticle?.publishedAt, + createdAt: duplicatedArticle?.createdAt, + updatedAt: duplicatedArticle?.updatedAt, + } satisfies ReaderArticleSeed; + + setImportStatus(duplicatedArticle ? '같은 URL 저장글을 최신 내용으로 갱신했습니다.' : '서가에 저장했습니다.'); + commitArticle(nextCustomArticle); + + try { + await syncArticleToServer(nextCustomArticle); + setImportStatus( + duplicatedArticle + ? '같은 URL 저장글을 갱신했고 서버에도 반영했습니다.' + : '서가에 저장했고 서버에도 보관했습니다.', + ); + } catch (error) { + setImportStatus( + error instanceof Error + ? `이 기기에는 저장했지만 서버 저장은 실패했습니다. ${error.message}` + : '이 기기에는 저장했지만 서버 저장은 실패했습니다.', + ); + } + }; + + const handleAutoImport = async () => { + if (!draftUrl.trim()) { + setImportStatus('원문 URL을 먼저 입력해 주세요.'); + return; + } + + setIsImporting(true); + setImportStatus(''); + + try { + const item = await extractReaderArticle(draftUrl); + const { duplicatedArticle, nextCustomArticle } = buildImportedArticleSeed(item); + + setDraftTitle(nextCustomArticle.title); + setDraftSourceLabel(nextCustomArticle.sourceLabel); + setDraftUrl(nextCustomArticle.url); + setDraftBody(nextCustomArticle.body); + setDraftHtmlBody(nextCustomArticle.htmlBody); + commitArticle(nextCustomArticle); + setImportStatus( + duplicatedArticle + ? '같은 URL 원문이 이미 있어 기존 저장글을 최신 내용으로 갱신했습니다.' + : '원문을 가져와 이 기기 서가에 저장했습니다.', + ); + + try { + await syncArticleToServer(nextCustomArticle); + setImportStatus( + duplicatedArticle + ? '같은 URL 원문 저장글을 갱신했고 서버에도 반영했습니다.' + : '원문을 가져와 서가와 서버에 함께 저장했습니다.', + ); + } catch (error) { + setImportStatus( + error instanceof Error + ? `원문을 가져와 이 기기에는 저장했지만 서버 저장은 실패했습니다. ${error.message}` + : '원문을 가져와 이 기기에는 저장했지만 서버 저장은 실패했습니다.', + ); + } + } catch (error) { + setImportStatus(error instanceof Error ? error.message : '원문 자동 가져오기에 실패했습니다.'); + } finally { + setIsImporting(false); + } + }; + + const handleLoadSample = (articleId: string) => { + setActiveArticleId(articleId); + setPageIndex(0); + setImportStatus(''); + moveToView('reader'); + }; + + useEffect(() => { + if (currentView !== 'reader' || !activeArticle) { + return; + } + + markArticleAsRead(activeArticle); + }, [activeArticle, currentView]); + + const handleInstallReaderApp = async () => { + if (isStandaloneDisplayMode()) { + setInstallState('standalone'); + setInstallStatus('이미 홈 화면 앱으로 실행 중입니다.'); + return; + } + + const deferredPrompt = deferredInstallPromptRef.current; + + if (!deferredPrompt) { + setInstallState('browser-help'); + setInstallStatus(getInstallGuideMessage()); + return; + } + + deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice.catch(() => null); + deferredInstallPromptRef.current = null; + + if (choice?.outcome === 'accepted') { + setInstallStatus('설치 요청을 보냈습니다. 설치가 끝나면 E-Reader 전용 아이콘으로 실행됩니다.'); + setInstallState('browser-help'); + return; + } + + setInstallState('browser-help'); + setInstallStatus('설치를 닫았습니다. 필요하면 브라우저 메뉴에서 다시 홈 화면에 추가할 수 있습니다.'); + }; + + const handlePointerDown: React.PointerEventHandler = (event) => { + swipeTrackingRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + deltaX: 0, + deltaY: 0, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handlePointerMove: React.PointerEventHandler = (event) => { + const tracking = swipeTrackingRef.current; + + if (!tracking || tracking.pointerId !== event.pointerId) { + return; + } + + swipeTrackingRef.current = { + ...tracking, + deltaX: event.clientX - tracking.startX, + deltaY: event.clientY - tracking.startY, + }; + }; + + const finishPointerTracking = (event: React.PointerEvent) => { + const tracking = swipeTrackingRef.current; + + if (!tracking || tracking.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + swipeTrackingRef.current = null; + + if (gestureMode === 'touch-scroll') { + if (Math.abs(tracking.deltaY) < PAGE_SCROLL_TURN_DISTANCE_PX || Math.abs(tracking.deltaX) > PAGE_SCROLL_HORIZONTAL_TOLERANCE_PX) { + return; + } + + if (tracking.deltaY < 0) { + turnReaderContent('next'); + return; + } + + turnReaderContent('prev'); + return; + } + + if (Math.abs(tracking.deltaX) < PAGE_TURN_DISTANCE_PX || Math.abs(tracking.deltaY) > PAGE_TURN_VERTICAL_TOLERANCE_PX) { + return; + } + + if (tracking.deltaX < 0) { + turnReaderContent('next'); + return; + } + + turnReaderContent('prev'); + }; + + const handleReaderTap: React.MouseEventHandler = (event) => { + if (gestureMode === 'touch-scroll') { + return; + } + + const bounds = event.currentTarget.getBoundingClientRect(); + const offsetX = event.clientX - bounds.left; + const ratio = offsetX / Math.max(bounds.width, 1); + + if (ratio >= 0.7) { + turnReaderContent('next'); + return; + } + + if (ratio <= 0.3) { + turnReaderContent('prev'); + } + }; + + const turnLibraryPage = (direction: 'next' | 'prev') => { + if (libraryNavigationMode !== 'touch-swipe' || libraryPages.length <= 1) { + return; + } + + setLibraryPageIndex((currentIndex) => { + const nextIndex = + direction === 'next' + ? Math.min(currentIndex + 1, libraryPages.length - 1) + : Math.max(currentIndex - 1, 0); + return nextIndex; + }); + }; + + const handleLibraryPointerDown: React.PointerEventHandler = (event) => { + if (libraryNavigationMode !== 'touch-swipe') { + return; + } + + librarySwipeTrackingRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + deltaX: 0, + deltaY: 0, + }; + librarySwipeSuppressClickRef.current = false; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleLibraryPointerMove: React.PointerEventHandler = (event) => { + const tracking = librarySwipeTrackingRef.current; + + if (!tracking || tracking.pointerId !== event.pointerId) { + return; + } + + librarySwipeTrackingRef.current = { + ...tracking, + deltaX: event.clientX - tracking.startX, + deltaY: event.clientY - tracking.startY, + }; + }; + + const finishLibraryPointerTracking = (event: React.PointerEvent) => { + const tracking = librarySwipeTrackingRef.current; + + if (!tracking || tracking.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + librarySwipeTrackingRef.current = null; + + if ( + Math.abs(tracking.deltaX) < PAGE_TURN_DISTANCE_PX || + Math.abs(tracking.deltaY) > PAGE_TURN_VERTICAL_TOLERANCE_PX + ) { + return; + } + + librarySwipeSuppressClickRef.current = true; + + if (tracking.deltaX < 0) { + turnLibraryPage('next'); + return; + } + + turnLibraryPage('prev'); + }; + + const handleLibraryCardClick = (articleId: string) => { + if (librarySwipeSuppressClickRef.current) { + librarySwipeSuppressClickRef.current = false; + return; + } + + handleLoadSample(articleId); + }; + + const homePanel = ( +
+
+

E-Reader

+
+
+
+
+
+ + + + +
+
+
+ ); + + const newsPanel = ( +
+
+

뉴스

+
+ + {newsResults.length}건 + +
+
+
+
+
+
+ +
+ + +
+
+ {isNewsFiltersCollapsed ? null : ( +
+ + +
+ {librarySyncStatus ?

{librarySyncStatus}

: null} + {filteredArticles.length ? ( + libraryNavigationMode === 'touch-swipe' ? ( +
+
+
+ {visibleLibraryArticles.map((article) => ( + + ))} +
+
+
+ ) : ( +
+ {filteredArticles.map((article) => ( + + ))} +
+ ) + ) : ( +
+ 검색 결과가 없습니다. +

추천된 태그를 고르거나 검색어를 지워 다시 확인해 주세요.

+
+ )} +
+ {isLibrarySortSheetOpen ? ( +
+ + ); + })} +
+
+
+ ) : null} +
+ ); + + const importPanel = ( +
+
+

원문 자동 가져오기

+ +
+
+
+ + +
+ + +
+