@peaske7/readit 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/remotion-best-practices/SKILL.md +61 -0
- package/.agents/skills/remotion-best-practices/rules/3d.md +86 -0
- package/.agents/skills/remotion-best-practices/rules/animations.md +27 -0
- package/.agents/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx +178 -0
- package/.agents/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx +100 -0
- package/.agents/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx +108 -0
- package/.agents/skills/remotion-best-practices/rules/assets.md +78 -0
- package/.agents/skills/remotion-best-practices/rules/audio-visualization.md +198 -0
- package/.agents/skills/remotion-best-practices/rules/audio.md +169 -0
- package/.agents/skills/remotion-best-practices/rules/calculate-metadata.md +134 -0
- package/.agents/skills/remotion-best-practices/rules/can-decode.md +75 -0
- package/.agents/skills/remotion-best-practices/rules/charts.md +120 -0
- package/.agents/skills/remotion-best-practices/rules/compositions.md +154 -0
- package/.agents/skills/remotion-best-practices/rules/display-captions.md +184 -0
- package/.agents/skills/remotion-best-practices/rules/extract-frames.md +229 -0
- package/.agents/skills/remotion-best-practices/rules/ffmpeg.md +38 -0
- package/.agents/skills/remotion-best-practices/rules/fonts.md +152 -0
- package/.agents/skills/remotion-best-practices/rules/get-audio-duration.md +58 -0
- package/.agents/skills/remotion-best-practices/rules/get-video-dimensions.md +68 -0
- package/.agents/skills/remotion-best-practices/rules/get-video-duration.md +60 -0
- package/.agents/skills/remotion-best-practices/rules/gifs.md +141 -0
- package/.agents/skills/remotion-best-practices/rules/images.md +134 -0
- package/.agents/skills/remotion-best-practices/rules/import-srt-captions.md +69 -0
- package/.agents/skills/remotion-best-practices/rules/light-leaks.md +73 -0
- package/.agents/skills/remotion-best-practices/rules/lottie.md +70 -0
- package/.agents/skills/remotion-best-practices/rules/maps.md +412 -0
- package/.agents/skills/remotion-best-practices/rules/measuring-dom-nodes.md +34 -0
- package/.agents/skills/remotion-best-practices/rules/measuring-text.md +140 -0
- package/.agents/skills/remotion-best-practices/rules/parameters.md +109 -0
- package/.agents/skills/remotion-best-practices/rules/sequencing.md +118 -0
- package/.agents/skills/remotion-best-practices/rules/sfx.md +26 -0
- package/.agents/skills/remotion-best-practices/rules/subtitles.md +36 -0
- package/.agents/skills/remotion-best-practices/rules/tailwind.md +11 -0
- package/.agents/skills/remotion-best-practices/rules/text-animations.md +20 -0
- package/.agents/skills/remotion-best-practices/rules/timing.md +179 -0
- package/.agents/skills/remotion-best-practices/rules/transcribe-captions.md +70 -0
- package/.agents/skills/remotion-best-practices/rules/transitions.md +197 -0
- package/.agents/skills/remotion-best-practices/rules/transparent-videos.md +106 -0
- package/.agents/skills/remotion-best-practices/rules/trimming.md +51 -0
- package/.agents/skills/remotion-best-practices/rules/videos.md +171 -0
- package/.agents/skills/remotion-best-practices/rules/voiceover.md +99 -0
- package/.agents/skills/simple/SKILL.md +52 -0
- package/.agents/skills/vercel-react-best-practices/AGENTS.md +3254 -0
- package/.agents/skills/vercel-react-best-practices/README.md +123 -0
- package/.agents/skills/vercel-react-best-practices/SKILL.md +141 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
- package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
- package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-flatmap-filter.md +60 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-resource-hints.md +85 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md +68 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md +82 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
- package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md +142 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
- package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
- package/.claude/CLAUDE.md +142 -0
- package/.claude/commands/review.md +120 -0
- package/.claude/commands/sync-docs.md +71 -0
- package/.claude/roadmap.md +98 -0
- package/.claude/rules/style-guide.md +830 -0
- package/.claude/settings.json +18 -0
- package/.claude/user-stories.md +248 -0
- package/AGENTS.md +64 -0
- package/README.md +7 -7
- package/biome.json +69 -0
- package/bun.lock +1124 -0
- package/docs/design.md +563 -0
- package/docs/plans/2026-03-13-client-mode-design.md +86 -0
- package/docs/plans/2026-03-13-client-mode-plan.md +605 -0
- package/docs/plans/2026-03-13-keyboard-shortcuts-design.md +129 -0
- package/docs/plans/2026-03-13-keyboard-shortcuts-plan.md +1471 -0
- package/docs/plans/2026-03-13-multi-document-design.md +183 -0
- package/docs/plans/2026-03-13-performance-benchmarks-design.md +121 -0
- package/e2e/comments.spec.ts +125 -0
- package/e2e/document-load.spec.ts +54 -0
- package/e2e/export.spec.ts +58 -0
- package/e2e/fixtures/sample.html +13 -0
- package/e2e/fixtures/sample.md +7 -0
- package/e2e/persistence-file.spec.ts +342 -0
- package/e2e/utils/cli.ts +84 -0
- package/e2e/utils/selection.ts +135 -0
- package/{dist/index.html → index.html} +8 -2
- package/lefthook.yml +8 -0
- package/package.json +17 -39
- package/playwright.config.ts +22 -0
- package/skills-lock.json +20 -0
- package/src/App.tsx +396 -0
- package/src/cli/index.ts +467 -0
- package/src/components/ActionsMenu.tsx +110 -0
- package/src/components/DocumentViewer/CodeBlock.tsx +83 -0
- package/src/components/DocumentViewer/DocumentViewer.tsx +257 -0
- package/src/components/DocumentViewer/IframeContainer.tsx +251 -0
- package/src/components/DocumentViewer/MermaidDiagram.tsx +137 -0
- package/src/components/DocumentViewer/index.ts +1 -0
- package/src/components/FloatingTOC.tsx +59 -0
- package/src/components/Header.tsx +63 -0
- package/src/components/InlineEditor.tsx +72 -0
- package/src/components/MarginNote.tsx +198 -0
- package/src/components/MarginNotes.tsx +50 -0
- package/src/components/RawModal.tsx +141 -0
- package/src/components/ReanchorConfirm.tsx +33 -0
- package/src/components/SettingsModal.tsx +221 -0
- package/src/components/ShortcutCapture.tsx +45 -0
- package/src/components/ShortcutList.tsx +157 -0
- package/src/components/TabBar.tsx +60 -0
- package/src/components/TableOfContents.tsx +108 -0
- package/src/components/comments/CommentBadge.tsx +43 -0
- package/src/components/comments/CommentInput.tsx +119 -0
- package/src/components/comments/CommentListItem.tsx +82 -0
- package/src/components/comments/CommentManager.tsx +106 -0
- package/src/components/comments/CommentMinimap.tsx +62 -0
- package/src/components/comments/CommentNav.tsx +104 -0
- package/src/components/ui/ActionBar.tsx +16 -0
- package/src/components/ui/ActionLink.tsx +32 -0
- package/src/components/ui/Button.tsx +55 -0
- package/src/components/ui/Dialog.tsx +156 -0
- package/src/components/ui/DropdownMenu.tsx +114 -0
- package/src/components/ui/SeparatorDot.tsx +9 -0
- package/src/components/ui/Text.tsx +54 -0
- package/src/contexts/CommentContext.tsx +222 -0
- package/src/contexts/LayoutContext.tsx +76 -0
- package/src/hooks/useClickOutside.ts +35 -0
- package/src/hooks/useClipboard.ts +79 -0
- package/src/hooks/useCommentNavigation.ts +130 -0
- package/src/hooks/useComments.ts +323 -0
- package/src/hooks/useDocument.ts +141 -0
- package/src/hooks/useFontPreference.ts +76 -0
- package/src/hooks/useHeadings.test.ts +159 -0
- package/src/hooks/useHeadings.ts +129 -0
- package/src/hooks/useKeybindings.ts +120 -0
- package/src/hooks/useKeyboardShortcuts.ts +63 -0
- package/src/hooks/useLayoutMode.ts +44 -0
- package/src/hooks/useReanchorMode.ts +33 -0
- package/src/hooks/useScrollMetrics.ts +56 -0
- package/src/hooks/useScrollSpy.ts +81 -0
- package/src/hooks/useTextSelection.ts +123 -0
- package/src/hooks/useThemePreference.ts +66 -0
- package/src/index.css +823 -0
- package/src/lib/__fixtures__/bench-data.ts +167 -0
- package/src/lib/anchor.bench.ts +112 -0
- package/src/lib/anchor.test.ts +531 -0
- package/src/lib/anchor.ts +465 -0
- package/src/lib/comment-storage.bench.ts +63 -0
- package/src/lib/comment-storage.test.ts +624 -0
- package/src/lib/comment-storage.ts +263 -0
- package/src/lib/context.bench.ts +41 -0
- package/src/lib/context.test.ts +224 -0
- package/src/lib/context.ts +193 -0
- package/src/lib/export.bench.ts +35 -0
- package/src/lib/export.ts +43 -0
- package/src/lib/highlight/colors.ts +37 -0
- package/src/lib/highlight/core.test.ts +98 -0
- package/src/lib/highlight/core.ts +54 -0
- package/src/lib/highlight/dom.ts +342 -0
- package/src/lib/highlight/highlighter.ts +427 -0
- package/src/lib/highlight/index.ts +23 -0
- package/src/lib/highlight/script-builder.ts +485 -0
- package/src/lib/highlight/types.ts +57 -0
- package/src/lib/html-processor.test.tsx +170 -0
- package/src/lib/html-processor.tsx +95 -0
- package/src/lib/layout-constants.ts +12 -0
- package/src/lib/margin-layout.bench.ts +28 -0
- package/src/lib/margin-layout.ts +100 -0
- package/src/lib/scroll.test.ts +118 -0
- package/src/lib/scroll.ts +47 -0
- package/src/lib/shortcut-registry.test.ts +173 -0
- package/src/lib/shortcut-registry.ts +209 -0
- package/src/lib/utils.test.ts +110 -0
- package/src/lib/utils.ts +61 -0
- package/src/main.tsx +10 -0
- package/src/server/index.ts +883 -0
- package/src/store/index.test.ts +220 -0
- package/src/store/index.ts +234 -0
- package/src/test-setup.ts +1 -0
- package/src/types/index.ts +115 -0
- package/test.md +74 -0
- package/tsconfig.cli.json +12 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +15 -0
- package/dist/assets/_basePickBy-hOr-yGsE.js +0 -1
- package/dist/assets/_baseUniq-b7bzdUSn.js +0 -1
- package/dist/assets/arc-D65wG9gm.js +0 -1
- package/dist/assets/architecture-PBZL5I3N-DBa6CAv_.js +0 -1
- package/dist/assets/architectureDiagram-2XIMDMQ5-Djwpsh98.js +0 -36
- package/dist/assets/array-DOVTz2Mq.js +0 -1
- package/dist/assets/blockDiagram-WCTKOSBZ-BdW5TTxj.js +0 -132
- package/dist/assets/c4Diagram-IC4MRINW-DTmkHEXu.js +0 -10
- package/dist/assets/channel-B3MUFipN.js +0 -1
- package/dist/assets/chunk-4BX2VUAB-DEqzsvDc.js +0 -1
- package/dist/assets/chunk-55IACEB6-BzVuSUV8.js +0 -1
- package/dist/assets/chunk-7E7YKBS2-CZ8IcA4c.js +0 -1
- package/dist/assets/chunk-7R4GIKGN-CWVVC8HX.js +0 -79
- package/dist/assets/chunk-C72U2L5F-B1Tso5TH.js +0 -1
- package/dist/assets/chunk-EGIJ26TM-Cx_7CFik.js +0 -1
- package/dist/assets/chunk-FMBD7UC4-Cfk_iGhv.js +0 -15
- package/dist/assets/chunk-GEFDOKGD-C_5hRbJt.js +0 -2
- package/dist/assets/chunk-GLR3WWYH-CkY7IyBj.js +0 -2
- package/dist/assets/chunk-HHEYEP7N-B0I4X5cr.js +0 -1
- package/dist/assets/chunk-JSJVCQXG-CAjwlVLg.js +0 -1
- package/dist/assets/chunk-KX2RTZJC-DWqnZZ02.js +0 -1
- package/dist/assets/chunk-KYZI473N-gjRVhJgJ.js +0 -53
- package/dist/assets/chunk-L3YUKLVL-D7C9GuxL.js +0 -1
- package/dist/assets/chunk-MX3YWQON-i-77iuVj.js +0 -1
- package/dist/assets/chunk-NQ4KR5QH-B22Pvemm.js +0 -220
- package/dist/assets/chunk-O4XLMI2P-ZQd5L6ZD.js +0 -7
- package/dist/assets/chunk-OZEHJAEY-BaPKTELw.js +0 -1
- package/dist/assets/chunk-PQ6SQG4A-DqE1eupT.js +0 -1
- package/dist/assets/chunk-PU5JKC2W-BTqWqedh.js +0 -70
- package/dist/assets/chunk-QZHKN3VN-Nm9TvMss.js +0 -1
- package/dist/assets/chunk-R5LLSJPH-DkiNs1dN.js +0 -1
- package/dist/assets/chunk-WL4C6EOR-CioD2fv2.js +0 -189
- package/dist/assets/chunk-XIRO2GV7-B4GGQONY.js +0 -1
- package/dist/assets/chunk-XPW4576I-C0IbbQos.js +0 -32
- package/dist/assets/chunk-XZSTWKYB-DMOqFWmT.js +0 -94
- package/dist/assets/chunk-YBOYWFTD-CoeQgeVY.js +0 -1
- package/dist/assets/classDiagram-VBA2DB6C-DV9ltQ7h.js +0 -1
- package/dist/assets/classDiagram-v2-RAHNMMFH-C6nD9wmM.js +0 -1
- package/dist/assets/clone-DuY6BQEm.js +0 -1
- package/dist/assets/cose-bilkent-S5V4N54A-B6FexK6p.js +0 -1
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +0 -321
- package/dist/assets/dagre-CCcocoCU.js +0 -1
- package/dist/assets/dagre-KLK3FWXG-DIELowj9.js +0 -4
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +0 -1
- package/dist/assets/diagram-E7M64L7V-D1mm0PoO.js +0 -24
- package/dist/assets/diagram-IFDJBPK2-7DVjly8y.js +0 -43
- package/dist/assets/diagram-P4PSJMXO-jO7pfyMb.js +0 -24
- package/dist/assets/dist-BywRdrPx.js +0 -1
- package/dist/assets/erDiagram-INFDFZHY-DSRxlRFy.js +0 -70
- package/dist/assets/flowDiagram-PKNHOUZH-CgKzzNdR.js +0 -162
- package/dist/assets/ganttDiagram-A5KZAMGK-CtsE7Y4E.js +0 -292
- package/dist/assets/gitGraph-HDMCJU4V-BU9uhwtz.js +0 -1
- package/dist/assets/gitGraphDiagram-K3NZZRJ6-DOU8RGdw.js +0 -65
- package/dist/assets/graphlib-WkJoBgka.js +0 -1
- package/dist/assets/index-CKVArt9D.js +0 -562
- package/dist/assets/index-DzRKJazf.css +0 -2
- package/dist/assets/info-3K5VOQVL-CPpvM-SG.js +0 -1
- package/dist/assets/infoDiagram-LFFYTUFH-VKLs5DsF.js +0 -2
- package/dist/assets/init-Bft5Ffpj.js +0 -1
- package/dist/assets/isArrayLikeObject-icl0H0jo.js +0 -1
- package/dist/assets/isEmpty-Du8sNmkE.js +0 -1
- package/dist/assets/ishikawaDiagram-PHBUUO56-CsWvEjux.js +0 -70
- package/dist/assets/journeyDiagram-4ABVD52K-BzJGTdIT.js +0 -139
- package/dist/assets/kanban-definition-K7BYSVSG-B_9ClJ1A.js +0 -89
- package/dist/assets/katex-BJrMXEjr.js +0 -261
- package/dist/assets/line-CC_tDGId.js +0 -1
- package/dist/assets/linear-Cts_d04Y.js +0 -1
- package/dist/assets/math-CNhlSIO3.js +0 -1
- package/dist/assets/mermaid-parser.core-Vb9KKv1R.js +0 -4
- package/dist/assets/mermaid.core-C_7xsp3d.js +0 -11
- package/dist/assets/mindmap-definition-YRQLILUH-BWmfy5wB.js +0 -68
- package/dist/assets/ordinal-DIg8h6NI.js +0 -1
- package/dist/assets/packet-RMMSAZCW-Q-WG6o3b.js +0 -1
- package/dist/assets/path-DfRbCp9y.js +0 -1
- package/dist/assets/pie-UPGHQEXC-Cwi2tLlt.js +0 -1
- package/dist/assets/pieDiagram-SKSYHLDU-Dyf3X_in.js +0 -30
- package/dist/assets/quadrantDiagram-337W2JSQ-B5_5m61Q.js +0 -7
- package/dist/assets/radar-KQ55EAFF-Dtw2VzxY.js +0 -1
- package/dist/assets/requirementDiagram-Z7DCOOCP-BSERBnlW.js +0 -73
- package/dist/assets/rough.esm-KjoEK0it.js +0 -1
- package/dist/assets/sankeyDiagram-WA2Y5GQK-CMcEY8Cz.js +0 -10
- package/dist/assets/sequenceDiagram-2WXFIKYE-D28qcXwC.js +0 -145
- package/dist/assets/src-C8kkzlHX.js +0 -1
- package/dist/assets/stateDiagram-RAJIS63D-7oVrCmRl.js +0 -1
- package/dist/assets/stateDiagram-v2-FVOUBMTO-DtFptQAd.js +0 -1
- package/dist/assets/timeline-definition-YZTLITO2-rbCfBEvG.js +0 -61
- package/dist/assets/treemap-KZPCXAKY-BlRvF0um.js +0 -1
- package/dist/assets/vennDiagram-LZ73GAT5-DBit3zWa.js +0 -34
- package/dist/assets/xychartDiagram-JWTSCODW-BVYXv51y.js +0 -7
- package/dist/index.js +0 -1040
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Element, Root } from "hast";
|
|
2
|
+
import { TriangleAlert } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import rehypeParse from "rehype-parse";
|
|
6
|
+
import rehypeReact from "rehype-react";
|
|
7
|
+
import { unified } from "unified";
|
|
8
|
+
import { visit } from "unist-util-visit";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Placeholder component for stripped dangerous elements
|
|
12
|
+
*/
|
|
13
|
+
function StrippedElement({ tagName }: { tagName: string }) {
|
|
14
|
+
return (
|
|
15
|
+
<span className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-red-50 text-red-700 border border-red-200 rounded-md font-mono">
|
|
16
|
+
<TriangleAlert className="w-4 h-4" />
|
|
17
|
+
<{tagName}> removed
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Component mappings for dangerous elements - renders placeholders instead
|
|
24
|
+
*/
|
|
25
|
+
const dangerousElementComponents = {
|
|
26
|
+
script: () => <StrippedElement tagName="script" />,
|
|
27
|
+
// Style tags targeting body/html/* can leak outside Shadow DOM, so strip them
|
|
28
|
+
// Our base styles in ShadowContainer provide typography instead
|
|
29
|
+
style: () => null,
|
|
30
|
+
link: () => null, // External stylesheets break app styles
|
|
31
|
+
iframe: () => <StrippedElement tagName="iframe" />,
|
|
32
|
+
object: () => <StrippedElement tagName="object" />,
|
|
33
|
+
embed: () => <StrippedElement tagName="embed" />,
|
|
34
|
+
frame: () => <StrippedElement tagName="frame" />,
|
|
35
|
+
frameset: () => <StrippedElement tagName="frameset" />,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rehype plugin to strip event handler attributes (onclick, onerror, etc.)
|
|
40
|
+
* and dangerous href/src values (javascript:, data:, vbscript:)
|
|
41
|
+
*/
|
|
42
|
+
function rehypeStripDangerousAttributes() {
|
|
43
|
+
const dangerousSchemes = /^(javascript|vbscript|data):/i;
|
|
44
|
+
|
|
45
|
+
return (tree: Root) => {
|
|
46
|
+
visit(tree, "element", (node: Element) => {
|
|
47
|
+
const props = node.properties;
|
|
48
|
+
if (!props) return;
|
|
49
|
+
|
|
50
|
+
for (const key of Object.keys(props)) {
|
|
51
|
+
// Strip all event handlers (on*)
|
|
52
|
+
if (key.startsWith("on") || key.startsWith("On")) {
|
|
53
|
+
delete props[key];
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Neutralize dangerous href/src schemes
|
|
58
|
+
if (key === "href" || key === "src") {
|
|
59
|
+
const value = props[key];
|
|
60
|
+
if (
|
|
61
|
+
typeof value === "string" &&
|
|
62
|
+
dangerousSchemes.test(value.trim())
|
|
63
|
+
) {
|
|
64
|
+
props[key] = "#";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create the unified processor for HTML -> React conversion
|
|
74
|
+
*/
|
|
75
|
+
const processor = unified()
|
|
76
|
+
.use(rehypeParse, { fragment: true })
|
|
77
|
+
.use(rehypeStripDangerousAttributes)
|
|
78
|
+
.use(rehypeReact, {
|
|
79
|
+
jsx,
|
|
80
|
+
jsxs,
|
|
81
|
+
Fragment,
|
|
82
|
+
components: dangerousElementComponents,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Process HTML content and return safe React elements
|
|
87
|
+
*
|
|
88
|
+
* - Dangerous tags (script, iframe, etc.) become visible placeholders
|
|
89
|
+
* - Event handlers (onclick, onerror, etc.) are stripped
|
|
90
|
+
* - Dangerous URLs (javascript:, data:, etc.) are neutralized
|
|
91
|
+
*/
|
|
92
|
+
export function processHtml(content: string): ReactNode {
|
|
93
|
+
const result = processor.processSync(content);
|
|
94
|
+
return result.result as ReactNode;
|
|
95
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Layout constants for margin notes and document viewer
|
|
2
|
+
|
|
3
|
+
export const HEADER_HEIGHT_PX = 48;
|
|
4
|
+
|
|
5
|
+
// Minimum gap between margin notes (accounts for quote + comment + actions + padding)
|
|
6
|
+
export const MARGIN_NOTE_MIN_GAP_PX = 150;
|
|
7
|
+
|
|
8
|
+
// Height reserved for comment input form when selecting text
|
|
9
|
+
export const COMMENT_INPUT_HEIGHT_PX = 160;
|
|
10
|
+
|
|
11
|
+
// Minimap starts below the header
|
|
12
|
+
export const MINIMAP_HEADER_OFFSET_PX = HEADER_HEIGHT_PX;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { bench, describe } from "vitest";
|
|
2
|
+
import { makeHighlightPositions } from "./__fixtures__/bench-data";
|
|
3
|
+
import { resolveMarginNotePositions } from "./margin-layout";
|
|
4
|
+
|
|
5
|
+
// Pre-compute fixture data outside bench loops
|
|
6
|
+
const ids5 = Array.from({ length: 5 }, (_, i) => `c${i}`);
|
|
7
|
+
const pos5 = makeHighlightPositions(5);
|
|
8
|
+
|
|
9
|
+
const ids50 = Array.from({ length: 50 }, (_, i) => `c${i}`);
|
|
10
|
+
const pos50 = makeHighlightPositions(50);
|
|
11
|
+
|
|
12
|
+
describe("resolveMarginNotePositions", () => {
|
|
13
|
+
bench("5 notes, no input zone", () => {
|
|
14
|
+
resolveMarginNotePositions(ids5, pos5, undefined);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
bench("5 notes, with input zone collision", () => {
|
|
18
|
+
resolveMarginNotePositions(ids5, pos5, 400);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
bench("50 notes, no input zone", () => {
|
|
22
|
+
resolveMarginNotePositions(ids50, pos50, undefined);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
bench("50 notes, with input zone collision", () => {
|
|
26
|
+
resolveMarginNotePositions(ids50, pos50, 3000);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMMENT_INPUT_HEIGHT_PX,
|
|
3
|
+
MARGIN_NOTE_MIN_GAP_PX,
|
|
4
|
+
} from "./layout-constants";
|
|
5
|
+
|
|
6
|
+
interface NotePosition {
|
|
7
|
+
commentId: string;
|
|
8
|
+
top: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves margin note positions to avoid overlaps.
|
|
13
|
+
*
|
|
14
|
+
* Algorithm:
|
|
15
|
+
* 1. Build initial positions from highlight positions
|
|
16
|
+
* 2. Handle input zone collision (push notes up or down to avoid input)
|
|
17
|
+
* 3. Resolve note-to-note overlaps:
|
|
18
|
+
* - Pass 1: Notes above input zone → push UP
|
|
19
|
+
* - Pass 2: Notes at/below input zone → push DOWN
|
|
20
|
+
*
|
|
21
|
+
* @param commentIds - Comment IDs in document order (sorted by startOffset)
|
|
22
|
+
* @param highlightPositions - Map of comment ID to top position
|
|
23
|
+
* @param pendingSelectionTop - Top position of input zone (if any)
|
|
24
|
+
* @returns Map of comment ID to resolved top position
|
|
25
|
+
*/
|
|
26
|
+
export function resolveMarginNotePositions(
|
|
27
|
+
commentIds: string[],
|
|
28
|
+
highlightPositions: Record<string, number>,
|
|
29
|
+
pendingSelectionTop: number | undefined,
|
|
30
|
+
): Map<string, number> {
|
|
31
|
+
// Only include comments with known positions (avoids jolt to position 0)
|
|
32
|
+
const positions: NotePosition[] = commentIds
|
|
33
|
+
.filter((id) => id in highlightPositions)
|
|
34
|
+
.map((id) => ({
|
|
35
|
+
commentId: id,
|
|
36
|
+
top: highlightPositions[id],
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Sort by top position
|
|
40
|
+
positions.sort((a, b) => a.top - b.top);
|
|
41
|
+
|
|
42
|
+
// Handle input zone collision - check visual overlap, not just top position
|
|
43
|
+
if (pendingSelectionTop !== null && pendingSelectionTop !== undefined) {
|
|
44
|
+
const inputStart = pendingSelectionTop;
|
|
45
|
+
const inputEnd = pendingSelectionTop + COMMENT_INPUT_HEIGHT_PX;
|
|
46
|
+
|
|
47
|
+
for (const pos of positions) {
|
|
48
|
+
const noteBottom = pos.top + MARGIN_NOTE_MIN_GAP_PX;
|
|
49
|
+
|
|
50
|
+
// Check if note visually overlaps with input zone
|
|
51
|
+
const overlaps = noteBottom > inputStart && pos.top < inputEnd;
|
|
52
|
+
|
|
53
|
+
if (overlaps) {
|
|
54
|
+
if (pos.top < inputStart) {
|
|
55
|
+
// Note is above input but overlaps - push UP
|
|
56
|
+
pos.top = Math.max(0, inputStart - MARGIN_NOTE_MIN_GAP_PX);
|
|
57
|
+
} else {
|
|
58
|
+
// Note is within/below input zone - push DOWN
|
|
59
|
+
pos.top = inputEnd;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Re-sort after potential position changes
|
|
64
|
+
positions.sort((a, b) => a.top - b.top);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Resolve note-to-note overlaps
|
|
68
|
+
const inputStartForOverlap = pendingSelectionTop ?? Infinity;
|
|
69
|
+
const inputEndForOverlap =
|
|
70
|
+
pendingSelectionTop != null
|
|
71
|
+
? pendingSelectionTop + COMMENT_INPUT_HEIGHT_PX
|
|
72
|
+
: Infinity;
|
|
73
|
+
|
|
74
|
+
// Pass 1: Notes ABOVE input - resolve by pushing UP (bottom to top)
|
|
75
|
+
for (let i = positions.length - 2; i >= 0; i--) {
|
|
76
|
+
const curr = positions[i];
|
|
77
|
+
const next = positions[i + 1];
|
|
78
|
+
// Only process if next note is above the input zone
|
|
79
|
+
if (next.top >= inputStartForOverlap) continue;
|
|
80
|
+
if (next.top - curr.top < MARGIN_NOTE_MIN_GAP_PX) {
|
|
81
|
+
curr.top = Math.max(0, next.top - MARGIN_NOTE_MIN_GAP_PX);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pass 2: Notes at/below input - resolve by pushing DOWN (top to bottom)
|
|
86
|
+
for (let i = 1; i < positions.length; i++) {
|
|
87
|
+
const prev = positions[i - 1];
|
|
88
|
+
const curr = positions[i];
|
|
89
|
+
if (curr.top - prev.top < MARGIN_NOTE_MIN_GAP_PX) {
|
|
90
|
+
let newTop = prev.top + MARGIN_NOTE_MIN_GAP_PX;
|
|
91
|
+
// If new position lands in input zone, skip to below input
|
|
92
|
+
if (newTop >= inputStartForOverlap && newTop < inputEndForOverlap) {
|
|
93
|
+
newTop = inputEndForOverlap;
|
|
94
|
+
}
|
|
95
|
+
curr.top = newTop;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return new Map(positions.map((p) => [p.commentId, p.top]));
|
|
100
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { calculateScrollTarget, getElementTopInDocument } from "./scroll";
|
|
3
|
+
|
|
4
|
+
describe("calculateScrollTarget", () => {
|
|
5
|
+
it("positions element at 25% from top by default", () => {
|
|
6
|
+
// Element at 1000px, viewport 800px
|
|
7
|
+
// Target offset = 800 * 0.25 = 200px
|
|
8
|
+
// Scroll target = 1000 - 200 = 800px
|
|
9
|
+
expect(
|
|
10
|
+
calculateScrollTarget({ elementTop: 1000, viewportHeight: 800 }),
|
|
11
|
+
).toBe(800);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("respects custom offset percent", () => {
|
|
15
|
+
// Element at 1000px, viewport 800px, offset 50%
|
|
16
|
+
// Target offset = 800 * 0.5 = 400px
|
|
17
|
+
// Scroll target = 1000 - 400 = 600px
|
|
18
|
+
expect(
|
|
19
|
+
calculateScrollTarget({
|
|
20
|
+
elementTop: 1000,
|
|
21
|
+
viewportHeight: 800,
|
|
22
|
+
offsetPercent: 0.5,
|
|
23
|
+
}),
|
|
24
|
+
).toBe(600);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns 0 when element is near top", () => {
|
|
28
|
+
// Element at 100px, viewport 800px
|
|
29
|
+
// Target offset = 200px
|
|
30
|
+
// Scroll target = max(0, 100 - 200) = 0
|
|
31
|
+
expect(
|
|
32
|
+
calculateScrollTarget({ elementTop: 100, viewportHeight: 800 }),
|
|
33
|
+
).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns 0 for element at position 0", () => {
|
|
37
|
+
expect(calculateScrollTarget({ elementTop: 0, viewportHeight: 800 })).toBe(
|
|
38
|
+
0,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles small viewport", () => {
|
|
43
|
+
// Element at 500px, viewport 400px
|
|
44
|
+
// Target offset = 100px
|
|
45
|
+
// Scroll target = 500 - 100 = 400px
|
|
46
|
+
expect(
|
|
47
|
+
calculateScrollTarget({ elementTop: 500, viewportHeight: 400 }),
|
|
48
|
+
).toBe(400);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles zero offset percent", () => {
|
|
52
|
+
// Element at 1000px, no offset
|
|
53
|
+
// Scroll target = 1000px (element at very top of viewport)
|
|
54
|
+
expect(
|
|
55
|
+
calculateScrollTarget({
|
|
56
|
+
elementTop: 1000,
|
|
57
|
+
viewportHeight: 800,
|
|
58
|
+
offsetPercent: 0,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(1000);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("getElementTopInDocument", () => {
|
|
65
|
+
it("calculates position for element in main document (not scrolled)", () => {
|
|
66
|
+
// Element at 500px from viewport top, no scroll
|
|
67
|
+
const elementRect = { top: 500 };
|
|
68
|
+
expect(getElementTopInDocument({ elementRect, scrollY: 0 })).toBe(500);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("calculates position for element in main document (scrolled)", () => {
|
|
72
|
+
// Element at 200px from viewport top, scrolled 300px
|
|
73
|
+
// Absolute position = 300 + 200 = 500px
|
|
74
|
+
const elementRect = { top: 200 };
|
|
75
|
+
expect(getElementTopInDocument({ elementRect, scrollY: 300 })).toBe(500);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("calculates position for element inside iframe", () => {
|
|
79
|
+
// Iframe at 100px from viewport top
|
|
80
|
+
// Element at 150px from iframe top (inside iframe)
|
|
81
|
+
// Scrolled 50px
|
|
82
|
+
// Absolute position = 50 + 100 + 150 = 300px
|
|
83
|
+
const elementRect = { top: 150 };
|
|
84
|
+
expect(
|
|
85
|
+
getElementTopInDocument({
|
|
86
|
+
elementRect,
|
|
87
|
+
scrollY: 50,
|
|
88
|
+
iframeTopOffset: 100,
|
|
89
|
+
}),
|
|
90
|
+
).toBe(300);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("handles element at viewport top with scroll", () => {
|
|
94
|
+
// Element at 0px from viewport top, scrolled 1000px
|
|
95
|
+
const elementRect = { top: 0 };
|
|
96
|
+
expect(getElementTopInDocument({ elementRect, scrollY: 1000 })).toBe(1000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("handles negative element position (above viewport)", () => {
|
|
100
|
+
// Element scrolled past viewport top
|
|
101
|
+
const elementRect = { top: -100 };
|
|
102
|
+
expect(getElementTopInDocument({ elementRect, scrollY: 500 })).toBe(400);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("handles iframe with element above iframe viewport", () => {
|
|
106
|
+
// Iframe at 200px, element at -50px within iframe (scrolled past)
|
|
107
|
+
// scrollY = 100
|
|
108
|
+
// Absolute position = 100 + 200 + (-50) = 250px
|
|
109
|
+
const elementRect = { top: -50 };
|
|
110
|
+
expect(
|
|
111
|
+
getElementTopInDocument({
|
|
112
|
+
elementRect,
|
|
113
|
+
scrollY: 100,
|
|
114
|
+
iframeTopOffset: 200,
|
|
115
|
+
}),
|
|
116
|
+
).toBe(250);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll calculation utilities for TOC navigation.
|
|
3
|
+
* These pure functions enable unit testing of scroll position calculations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parameters for scroll target calculation.
|
|
8
|
+
* Using object destructuring per style guide §3.5 for clarity.
|
|
9
|
+
*/
|
|
10
|
+
export interface CalculateScrollTargetParams {
|
|
11
|
+
elementTop: number;
|
|
12
|
+
viewportHeight: number;
|
|
13
|
+
offsetPercent?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GetElementTopParams {
|
|
17
|
+
elementRect: { top: number };
|
|
18
|
+
scrollY: number;
|
|
19
|
+
iframeTopOffset?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Calculate the scroll target position to place an element at a comfortable
|
|
24
|
+
* reading position (default: 25% from top of viewport).
|
|
25
|
+
*/
|
|
26
|
+
export function calculateScrollTarget({
|
|
27
|
+
elementTop,
|
|
28
|
+
viewportHeight,
|
|
29
|
+
offsetPercent = 0.25,
|
|
30
|
+
}: CalculateScrollTargetParams): number {
|
|
31
|
+
const targetOffset = viewportHeight * offsetPercent;
|
|
32
|
+
return Math.max(0, elementTop - targetOffset);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get an element's absolute position in the main document.
|
|
37
|
+
*
|
|
38
|
+
* For elements directly in the document: pass scrollY and the element.
|
|
39
|
+
* For elements inside an iframe: also pass the iframe's top offset.
|
|
40
|
+
*/
|
|
41
|
+
export function getElementTopInDocument({
|
|
42
|
+
elementRect,
|
|
43
|
+
scrollY,
|
|
44
|
+
iframeTopOffset,
|
|
45
|
+
}: GetElementTopParams): number {
|
|
46
|
+
return scrollY + (iframeTopOffset ?? 0) + elementRect.top;
|
|
47
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SHORTCUTS,
|
|
4
|
+
formatBinding,
|
|
5
|
+
isReservedBinding,
|
|
6
|
+
type KeybindingOverride,
|
|
7
|
+
matchesBinding,
|
|
8
|
+
resolveShortcuts,
|
|
9
|
+
ShortcutActions,
|
|
10
|
+
type ShortcutBinding,
|
|
11
|
+
} from "./shortcut-registry";
|
|
12
|
+
|
|
13
|
+
describe("DEFAULT_SHORTCUTS", () => {
|
|
14
|
+
it("defines 7 shortcuts", () => {
|
|
15
|
+
expect(DEFAULT_SHORTCUTS).toHaveLength(7);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("has unique IDs", () => {
|
|
19
|
+
const ids = DEFAULT_SHORTCUTS.map((s) => s.id);
|
|
20
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("all enabled by default", () => {
|
|
24
|
+
for (const shortcut of DEFAULT_SHORTCUTS) {
|
|
25
|
+
expect(shortcut.enabled).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("matchesBinding", () => {
|
|
31
|
+
it("matches exact key + modifiers", () => {
|
|
32
|
+
const binding: ShortcutBinding = { key: "c", alt: true };
|
|
33
|
+
const event = new KeyboardEvent("keydown", { key: "c", altKey: true });
|
|
34
|
+
expect(matchesBinding(event, binding)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects wrong key", () => {
|
|
38
|
+
const binding: ShortcutBinding = { key: "c", alt: true };
|
|
39
|
+
const event = new KeyboardEvent("keydown", { key: "v", altKey: true });
|
|
40
|
+
expect(matchesBinding(event, binding)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects extra modifiers", () => {
|
|
44
|
+
const binding: ShortcutBinding = { key: "c", alt: true };
|
|
45
|
+
const event = new KeyboardEvent("keydown", {
|
|
46
|
+
key: "c",
|
|
47
|
+
altKey: true,
|
|
48
|
+
shiftKey: true,
|
|
49
|
+
});
|
|
50
|
+
expect(matchesBinding(event, binding)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("matches binding with shift", () => {
|
|
54
|
+
const binding: ShortcutBinding = { key: "c", alt: true, shift: true };
|
|
55
|
+
const event = new KeyboardEvent("keydown", {
|
|
56
|
+
key: "c",
|
|
57
|
+
altKey: true,
|
|
58
|
+
shiftKey: true,
|
|
59
|
+
});
|
|
60
|
+
expect(matchesBinding(event, binding)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("matches meta key", () => {
|
|
64
|
+
const binding: ShortcutBinding = { key: "c", meta: true };
|
|
65
|
+
const event = new KeyboardEvent("keydown", { key: "c", metaKey: true });
|
|
66
|
+
expect(matchesBinding(event, binding)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("matches shifted letter key (browser reports uppercase)", () => {
|
|
70
|
+
const binding: ShortcutBinding = { key: "c", alt: true, shift: true };
|
|
71
|
+
const event = new KeyboardEvent("keydown", {
|
|
72
|
+
key: "C",
|
|
73
|
+
altKey: true,
|
|
74
|
+
shiftKey: true,
|
|
75
|
+
});
|
|
76
|
+
expect(matchesBinding(event, binding)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("matches meta+shift letter key (browser reports uppercase)", () => {
|
|
80
|
+
const binding: ShortcutBinding = { key: "c", meta: true, shift: true };
|
|
81
|
+
const event = new KeyboardEvent("keydown", {
|
|
82
|
+
key: "C",
|
|
83
|
+
metaKey: true,
|
|
84
|
+
shiftKey: true,
|
|
85
|
+
});
|
|
86
|
+
expect(matchesBinding(event, binding)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("matches key-only binding (no modifiers)", () => {
|
|
90
|
+
const binding: ShortcutBinding = { key: "Escape" };
|
|
91
|
+
const event = new KeyboardEvent("keydown", { key: "Escape" });
|
|
92
|
+
expect(matchesBinding(event, binding)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("formatBinding", () => {
|
|
97
|
+
it("formats Alt+C", () => {
|
|
98
|
+
const binding: ShortcutBinding = { key: "c", alt: true };
|
|
99
|
+
expect(formatBinding(binding, true)).toBe("Alt+C");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("formats meta key as ⌘ on Mac", () => {
|
|
103
|
+
const binding: ShortcutBinding = { key: "c", meta: true };
|
|
104
|
+
expect(formatBinding(binding, true)).toBe("⌘+C");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("formats meta key as Ctrl on non-Mac", () => {
|
|
108
|
+
const binding: ShortcutBinding = { key: "c", meta: true };
|
|
109
|
+
expect(formatBinding(binding, false)).toBe("Ctrl+C");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("formats shift modifier", () => {
|
|
113
|
+
const binding: ShortcutBinding = { key: "c", meta: true, shift: true };
|
|
114
|
+
expect(formatBinding(binding, true)).toBe("⌘+Shift+C");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("formats arrow keys with symbols", () => {
|
|
118
|
+
const binding: ShortcutBinding = { key: "ArrowUp", alt: true };
|
|
119
|
+
expect(formatBinding(binding, true)).toBe("Alt+↑");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("formats Escape", () => {
|
|
123
|
+
const binding: ShortcutBinding = { key: "Escape" };
|
|
124
|
+
expect(formatBinding(binding, true)).toBe("Esc");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("resolveShortcuts", () => {
|
|
129
|
+
it("returns defaults when no overrides", () => {
|
|
130
|
+
const resolved = resolveShortcuts([]);
|
|
131
|
+
expect(resolved).toEqual(DEFAULT_SHORTCUTS);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("applies enabled override", () => {
|
|
135
|
+
const overrides: KeybindingOverride[] = [
|
|
136
|
+
{ id: ShortcutActions.COPY_ALL, enabled: false },
|
|
137
|
+
];
|
|
138
|
+
const resolved = resolveShortcuts(overrides);
|
|
139
|
+
const copyAll = resolved.find((s) => s.id === ShortcutActions.COPY_ALL);
|
|
140
|
+
expect(copyAll?.enabled).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("applies binding override", () => {
|
|
144
|
+
const overrides: KeybindingOverride[] = [
|
|
145
|
+
{
|
|
146
|
+
id: ShortcutActions.COPY_ALL,
|
|
147
|
+
enabled: true,
|
|
148
|
+
binding: { key: "a", meta: true },
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
const resolved = resolveShortcuts(overrides);
|
|
152
|
+
const copyAll = resolved.find((s) => s.id === ShortcutActions.COPY_ALL);
|
|
153
|
+
expect(copyAll?.binding).toEqual({ key: "a", meta: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ignores unknown override IDs", () => {
|
|
157
|
+
const overrides: KeybindingOverride[] = [
|
|
158
|
+
{ id: "unknown_action", enabled: false },
|
|
159
|
+
];
|
|
160
|
+
const resolved = resolveShortcuts(overrides);
|
|
161
|
+
expect(resolved).toEqual(DEFAULT_SHORTCUTS);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("isReservedBinding", () => {
|
|
166
|
+
it("detects ⌘+W as reserved", () => {
|
|
167
|
+
expect(isReservedBinding({ key: "w", meta: true })).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("allows Alt+C", () => {
|
|
171
|
+
expect(isReservedBinding({ key: "c", alt: true })).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|