├── .github └── workflows │ ├── ci.yml │ └── update.yml ├── App.tsx ├── Bubble.tsx ├── Card.tsx ├── CardList.tsx ├── LICENSE ├── Page.tsx ├── README.md ├── TextBubble.tsx ├── UserCSS.tsx ├── __snapshots__ └── convert.test.ts.snap ├── app.css ├── app.min.css.ts ├── bubble.ts ├── cache.ts ├── card.css ├── cardBubble.css ├── convert.test.ts ├── convert.ts ├── debug.ts ├── deno.jsonc ├── deno.lock ├── deps ├── async.ts ├── esbuild.ts ├── immer.ts ├── option-t.ts ├── preact.tsx ├── scrapbox-parser.ts ├── scrapbox-std-browser.ts ├── scrapbox-std.ts ├── scrapbox.ts └── testing.ts ├── detectURL.test.ts ├── detectURL.ts ├── eventEmitter.ts ├── global.css ├── hasLink.test.ts ├── hasLink.ts ├── id.test.ts ├── id.ts ├── is.ts ├── katex.ts ├── mod.tsx ├── page.css ├── parseLink.test.ts ├── parseLink.ts ├── position.ts ├── scripts └── minifyCSS.ts ├── statusBar.css ├── stayHovering.ts ├── storage.test.ts ├── storage.ts ├── textBubble.css ├── throttle.test.ts ├── throttle.ts ├── type-traits.ts ├── types.ts ├── useBubbleData.ts ├── useBubbles.ts ├── useEventListener.ts ├── useKaTeX.ts ├── useProject.ts ├── useTheme.ts └── watchList.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: denoland/setup-deno@v2 11 | with: 12 | deno-version: "v2" 13 | - name: Check all 14 | run: deno task check:all 15 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: hasundue/molt-action@v1 18 | with: 19 | commit-prefix: ":package:" 20 | source: |- 21 | deps/*.ts 22 | deno.jsonc 23 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { Bubble } from "./Bubble.tsx"; 4 | import { UserCSS } from "./UserCSS.tsx"; 5 | import { CSS } from "./app.min.css.ts"; 6 | import { useCallback, useEffect } from "./deps/preact.tsx"; 7 | import { useBubbles } from "./useBubbles.ts"; 8 | import { stayHovering } from "./stayHovering.ts"; 9 | import { useEventListener } from "./useEventListener.ts"; 10 | import { isPageLink, isTitle } from "./is.ts"; 11 | import { parseLink } from "./parseLink.ts"; 12 | import { toId } from "./id.ts"; 13 | import { calcBubblePosition } from "./position.ts"; 14 | import { prefetch as prefetch_ } from "./bubble.ts"; 15 | import type { LinkType } from "./types.ts"; 16 | import type { ProjectId, Scrapbox } from "./deps/scrapbox.ts"; 17 | declare const scrapbox: Scrapbox; 18 | 19 | export const userscriptName = "scrap-bubble"; 20 | 21 | export interface AppProps { 22 | /** hoverしてからbubbleを表示するまでのタイムラグ */ 23 | delay: number; 24 | 25 | /** 透過的に扱うprojectのリスト */ 26 | whiteList: Set; 27 | 28 | /** watch list */ 29 | watchList: Set; 30 | 31 | /** カスタムCSS 32 | * 33 | * URL or URL文字列の場合は、CSSファイルへのURLだとみなしてで読み込む 34 | * それ以外の場合は、インラインCSSとして 126 | 127 | {bubbles.map((bubble) => ( 128 | 135 | ))} 136 | 137 | ); 138 | }; 139 | 140 | const getLinkType = (element: HTMLSpanElement | HTMLAnchorElement): LinkType => 141 | isPageLink(element) 142 | ? (element.type === "link" ? "link" : "hashtag") 143 | : "title"; 144 | -------------------------------------------------------------------------------- /Bubble.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { CardList } from "./CardList.tsx"; 4 | import { useLayoutEffect, useMemo, useState } from "./deps/preact.tsx"; 5 | import { toTitleLc } from "./deps/scrapbox-std.ts"; 6 | import { useBubbleData } from "./useBubbleData.ts"; 7 | import { LinkTo } from "./types.ts"; 8 | import { fromId, ID, toId } from "./id.ts"; 9 | import { Bubble as BubbleData } from "./storage.ts"; 10 | import { produce } from "./deps/immer.ts"; 11 | import type { BubbleSource } from "./useBubbles.ts"; 12 | import { createDebug } from "./debug.ts"; 13 | import type { Scrapbox } from "./deps/scrapbox.ts"; 14 | import { TextBubble } from "./TextBubble.tsx"; 15 | declare const scrapbox: Scrapbox; 16 | 17 | const logger = createDebug("ScrapBubble:Bubble.tsx"); 18 | 19 | export interface BubbleProps extends BubbleSource { 20 | whiteList: Set; 21 | delay: number; 22 | prefetch: (project: string, title: string) => void; 23 | } 24 | 25 | export const Bubble = ({ 26 | source, 27 | parentTitles, 28 | whiteList, 29 | ...props 30 | }: BubbleProps) => { 31 | /** 検索対象のproject list */ 32 | const projects = useMemo( 33 | () => 34 | whiteList.has(source.project) 35 | // source.projectを一番先頭にする 36 | ? new Set([source.project, ...whiteList]) 37 | : new Set([source.project]), 38 | [whiteList, source.project], 39 | ); 40 | 41 | const [linked, externalLinked, pages] = useBubbleFilter( 42 | source, 43 | projects, 44 | whiteList, 45 | parentTitles, 46 | ); 47 | 48 | return ( 49 | <> 50 | {hasOneBubble(pages) && ( 51 | 58 | )} 59 | 67 | 68 | ); 69 | }; 70 | 71 | const hasOneBubble = ( 72 | pages: BubbleData[], 73 | ): pages is [BubbleData, ...BubbleData[]] => pages.length > 0; 74 | 75 | /** 指定したsourceからbubblesするページ本文とページカードを、親bubblesやwhiteListを使って絞り込む 76 | * 77 | * でやっている処理の一部を切り出して見通しをよくしただけ 78 | */ 79 | const useBubbleFilter = ( 80 | source: { project: string; title: string }, 81 | projects: Set, 82 | whiteList: Set, 83 | parentTitles: string[], 84 | ) => { 85 | type LinkMap = Map; 86 | 87 | const [[linked, externalLinked, pages], setBubbleData] = useState< 88 | [LinkMap, LinkMap, BubbleData[]] 89 | >([new Map(), new Map(), []]); 90 | 91 | const pageIds = useMemo( 92 | () => { 93 | const pageIds = [...projects].map((project) => 94 | toId(project, source.title) 95 | ); 96 | 97 | logger.debug("projects", pageIds); 98 | return pageIds; 99 | }, 100 | [projects, source.title], 101 | ); 102 | 103 | const bubbles = useBubbleData(pageIds); 104 | const parentsLc = useMemo( 105 | () => parentTitles.map((title) => toTitleLc(title)), 106 | [parentTitles], 107 | ); 108 | 109 | useLayoutEffect( 110 | () => { 111 | /** `source.title`を内部リンク記法で参照しているリンクのリスト */ 112 | const linked = new Map(); 113 | /** `source.title`を外部リンク記法で参照しているリンクのリスト */ 114 | const externalLinked = new Map(); 115 | /** ページ本文 116 | * 117 | * bubbleをそのまま保つことで、参照の違いのみで更新の有無を判断できる 118 | */ 119 | const pages: BubbleData[] = []; 120 | 121 | // 逆リンクおよびpagesから, parentsとwhitelistにないものを除いておく 122 | for (const bubble of bubbles) { 123 | const eLinkTo = { project: bubble.project, titleLc: bubble.titleLc }; 124 | for (const id of bubble.projectLinked ?? []) { 125 | const { project, titleLc } = fromId(id); 126 | // External Linksの内、projectがwhiteListに属するlinksも重複除去処理を施す 127 | if (parentsLc.includes(titleLc) && whiteList.has(project)) { 128 | continue; 129 | } 130 | if (externalLinked.has(id)) continue; 131 | externalLinked.set(id, eLinkTo); 132 | } 133 | // whiteLitにないprojectのページは、External Links以外表示しない 134 | if (!whiteList.has(bubble.project)) continue; 135 | const linkTo = { titleLc: bubble.titleLc }; 136 | // 親と重複しない逆リンクのみ格納する 137 | for (const linkLc of bubble.linked ?? []) { 138 | if (parentsLc.includes(linkLc)) continue; 139 | const id = toId(bubble.project, linkLc); 140 | if (linked.has(id)) continue; 141 | linked.set(id, linkTo); 142 | } 143 | // 親と重複しないページ本文のみ格納する 144 | if (parentsLc.includes(bubble.titleLc)) continue; 145 | if (!bubble.exists) continue; 146 | pages.push(bubble); 147 | } 148 | 149 | // 再レンダリングを抑制するために、必要なものだけ更新する 150 | setBubbleData(produce((draft) => { 151 | logger.debug( 152 | `depth: ${parentsLc.length}, bubbled from ${ 153 | toId(source.project, source.title) 154 | }, bubbles,`, 155 | bubbles, 156 | "before", 157 | draft[0], 158 | `internal cards,`, 159 | linked, 160 | "external cards", 161 | externalLinked, 162 | ); 163 | for (const key of draft[0].keys()) { 164 | if (!linked.has(key)) draft[0].delete(key); 165 | } 166 | for (const [key, value] of linked) { 167 | draft[0].set(key, value); 168 | } 169 | 170 | for (const key of draft[1].keys()) { 171 | if (!externalLinked.has(key)) draft[1].delete(key); 172 | } 173 | for (const [key, value] of externalLinked) { 174 | draft[1].set(key, value); 175 | } 176 | draft[2] = pages; 177 | })); 178 | }, 179 | [bubbles, whiteList, parentsLc], 180 | ); 181 | 182 | return [linked, externalLinked, pages] as const; 183 | }; 184 | -------------------------------------------------------------------------------- /Card.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { h, useCallback, useMemo } from "./deps/preact.tsx"; 4 | import { useKaTeX } from "./useKaTeX.ts"; 5 | import { encodeTitleURI } from "./deps/scrapbox-std.ts"; 6 | import { pushPageTransition } from "./deps/scrapbox-std-browser.ts"; 7 | import { 8 | FormulaNode, 9 | HashTagNode, 10 | IconNode, 11 | LinkNode, 12 | Node as NodeType, 13 | StrongIconNode, 14 | } from "./deps/scrapbox-parser.ts"; 15 | import { parse } from "./deps/scrapbox-parser.ts"; 16 | import { useTheme } from "./useTheme.ts"; 17 | import { stayHovering } from "./stayHovering.ts"; 18 | import { BubbleOperators } from "./useBubbles.ts"; 19 | import { calcBubblePosition } from "./position.ts"; 20 | import type { Scrapbox } from "./deps/scrapbox.ts"; 21 | declare const scrapbox: Scrapbox; 22 | 23 | export interface CardProps { 24 | project: string; 25 | title: string; 26 | descriptions: string[]; 27 | thumbnail: string; 28 | linkTo?: { 29 | project?: string; 30 | titleLc: string; 31 | }; 32 | delay: number; 33 | prefetch: (project: string, title: string) => void; 34 | bubble: BubbleOperators["bubble"]; 35 | } 36 | 37 | export const Card = ({ 38 | project, 39 | title, 40 | descriptions, 41 | thumbnail, 42 | linkTo, 43 | bubble, 44 | delay, 45 | prefetch, 46 | }: CardProps) => { 47 | const blocks = useMemo( 48 | () => thumbnail ? [] : parse(descriptions.join("\n"), { hasTitle: false }), 49 | [ 50 | thumbnail, 51 | descriptions, 52 | ], 53 | ); 54 | const theme = useTheme(project); 55 | 56 | const handleEnter = useCallback( 57 | async ( 58 | { currentTarget: a }: h.JSX.TargetedMouseEvent, 59 | ) => { 60 | prefetch(project, title); 61 | 62 | if (!await stayHovering(a, delay)) return; 63 | 64 | bubble({ 65 | project, 66 | title, 67 | linkTo, 68 | type: "link", 69 | position: calcBubblePosition(a), 70 | }); 71 | }, 72 | [project, title, delay, linkTo?.project, linkTo?.titleLc], 73 | ); 74 | 75 | const handleClick = useMemo(() => 76 | linkTo 77 | ? () => { 78 | pushPageTransition({ 79 | type: "page", 80 | from: { project: linkTo.project ?? project, title: linkTo.titleLc }, 81 | to: { project, title }, 82 | }); 83 | } 84 | : () => {}, [project, title, linkTo?.project, linkTo?.titleLc]); 85 | 86 | return ( 87 | 97 |
98 |
99 |
100 |
{title}
101 |
102 | {thumbnail 103 | ? ( 104 |
105 | 106 |
107 | ) 108 | : ( 109 |
110 | {blocks.flatMap((block, index) => 111 | block.type === "line" 112 | ? [ 113 |

114 | {block.nodes.map((node) => ( 115 | 116 | ))} 117 |

, 118 | ] 119 | : [] 120 | )} 121 |
122 | )} 123 |
124 |
125 | ); 126 | }; 127 | 128 | type NodeProps = { 129 | node: NodeType; 130 | project: string; 131 | }; 132 | const SummaryNode = ({ node, project }: NodeProps) => { 133 | switch (node.type) { 134 | case "code": 135 | return {node.text}; 136 | case "formula": 137 | return ; 138 | case "commandLine": 139 | return {node.symbol} ${node.text}; 140 | case "helpfeel": 141 | return ? {node.text}; 142 | case "quote": 143 | case "strong": 144 | case "decoration": 145 | return ( 146 | <> 147 | {node.nodes.map((node) => ( 148 | 149 | ))} 150 | 151 | ); 152 | case "icon": 153 | case "strongIcon": 154 | return ; 155 | case "hashTag": 156 | return ; 157 | case "link": 158 | return ; 159 | case "plain": 160 | case "blank": 161 | return <>{node.text}; 162 | default: 163 | return; 164 | } 165 | }; 166 | 167 | type FormulaProps = { 168 | node: FormulaNode; 169 | }; 170 | const Formula = ({ node: { formula } }: FormulaProps) => { 171 | const { ref, error } = useKaTeX(formula); 172 | 173 | return ( 174 | 175 | {error 176 | ? {formula} 177 | : } 178 | 179 | ); 180 | }; 181 | type IconProps = { 182 | project: string; 183 | node: IconNode | StrongIconNode; 184 | }; 185 | const Icon = ({ node: { pathType, path }, project: _project }: IconProps) => { 186 | const [project, title] = pathType === "relative" 187 | ? [ 188 | _project, 189 | path, 190 | ] 191 | : path.match(/\/([\w\-]+)\/(.+)$/)?.slice?.(1) ?? [_project, path]; 192 | 193 | return ( 194 | 198 | ); 199 | }; 200 | type HashTagProps = { 201 | node: HashTagNode; 202 | }; 203 | const HashTag = ({ node: { href } }: HashTagProps) => ( 204 | #{href} 205 | ); 206 | type LinkProps = { 207 | node: LinkNode; 208 | }; 209 | const Link = ({ node: { pathType, href, content } }: LinkProps) => 210 | pathType !== "absolute" 211 | ? {href} // contentが空のときはundefinedではなく''になるので、 212 | // ??ではなく||でfallback処理をする必要がある 213 | : {content || href}; 214 | -------------------------------------------------------------------------------- /CardList.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { Card } from "./Card.tsx"; 4 | import { h, useMemo } from "./deps/preact.tsx"; 5 | import { useBubbleData } from "./useBubbleData.ts"; 6 | import { Bubble } from "./storage.ts"; 7 | import { LinkTo } from "./types.ts"; 8 | import { ID, toId } from "./id.ts"; 9 | import { BubbleOperators, Source } from "./useBubbles.ts"; 10 | 11 | export interface CardListProps { 12 | delay: number; 13 | prefetch: (project: string, title: string) => void; 14 | /** key ページカードのID, value: 逆リンク先 */ 15 | linked: Map; 16 | /** key ページカードのID, value: 逆リンク先 */ 17 | externalLinked: Map; 18 | bubble: BubbleOperators["bubble"]; 19 | source: Pick; 20 | projectsForSort: Set; 21 | onClick?: h.JSX.MouseEventHandler; 22 | } 23 | 24 | export const CardList = ({ 25 | source, 26 | linked, 27 | externalLinked, 28 | projectsForSort: projectsForSort_, 29 | ...props 30 | }: CardListProps) => { 31 | const ids = useMemo(() => [...linked.keys(), ...externalLinked.keys()], [ 32 | linked, 33 | externalLinked, 34 | ]); 35 | const cards = useBubbleData(ids); 36 | 37 | const projectsForSort = useMemo(() => [...projectsForSort_], [ 38 | projectsForSort_, 39 | ]); 40 | /** 更新日時降順とproject昇順に並び替えた関連ページデータ 41 | * 42 | * externalCardsは分けない 43 | */ 44 | const sortedCards = useMemo( 45 | () => { 46 | const compare = (a: Bubble, b: Bubble) => { 47 | const aIndex = projectsForSort.indexOf(a.project); 48 | const bIndex = projectsForSort.indexOf(b.project); 49 | 50 | if (aIndex === bIndex) return b.updated - a.updated; 51 | if (aIndex < 0) return 1; 52 | if (bIndex < 0) return -1; 53 | return aIndex - bIndex; 54 | }; 55 | 56 | return [...cards].sort(compare); 57 | }, 58 | [cards, projectsForSort], 59 | ); 60 | 61 | const cardStyle = useMemo(() => ({ 62 | bottom: `${source.position.bottom}px`, 63 | maxWidth: `${source.position.maxWidth}px`, 64 | ...("left" in source.position 65 | ? { 66 | left: `${source.position.left}px`, 67 | } 68 | : { 69 | right: `${source.position.right}px`, 70 | }), 71 | }), [ 72 | source.position, 73 | ]); 74 | 75 | return ( 76 |
    81 | {sortedCards.map(( 82 | { 83 | project, 84 | titleLc, 85 | lines: [{ text: title }], 86 | descriptions, 87 | image, 88 | }, 89 | ) => { 90 | const key = toId(project, titleLc); 91 | // hookの計算にラグがあり、linkToが見つからない場合がある 92 | const linkTo = linked.get(key) ?? externalLinked.get(key); 93 | 94 | return ( 95 |
  • 96 | 104 |
  • 105 | ); 106 | })} 107 |
108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 takker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Page.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { 4 | ComponentChildren, 5 | createContext, 6 | h, 7 | useCallback, 8 | useContext, 9 | useEffect, 10 | useMemo, 11 | useRef, 12 | useState, 13 | } from "./deps/preact.tsx"; 14 | import { useKaTeX } from "./useKaTeX.ts"; 15 | import { 16 | CodeBlock as CodeBlockType, 17 | CommandLineNode, 18 | DecorationNode, 19 | FormulaNode, 20 | GoogleMapNode, 21 | HashTagNode, 22 | IconNode, 23 | ImageNode, 24 | Line as LineType, 25 | LinkNode, 26 | Node as NodeType, 27 | StrongIconNode, 28 | Table as TableType, 29 | } from "./deps/scrapbox-parser.ts"; 30 | import { parseLink } from "./parseLink.ts"; 31 | import { hasLink } from "./hasLink.ts"; 32 | import { toId } from "./id.ts"; 33 | import { stayHovering } from "./stayHovering.ts"; 34 | import { BubbleOperators } from "./useBubbles.ts"; 35 | import { LinkTo } from "./types.ts"; 36 | import { useBubbleData } from "./useBubbleData.ts"; 37 | import { isEmptyLink } from "./storage.ts"; 38 | import { calcBubblePosition } from "./position.ts"; 39 | import { parse } from "./deps/scrapbox-parser.ts"; 40 | import { 41 | AnchorFMNode, 42 | AudioNode, 43 | encodeTitleURI, 44 | parseAbsoluteLink, 45 | SpotifyNode, 46 | VideoNode, 47 | VimeoNode, 48 | YoutubeListNode, 49 | YoutubeNode, 50 | } from "./deps/scrapbox-std.ts"; 51 | import type { Scrapbox } from "./deps/scrapbox.ts"; 52 | import { useTheme } from "./useTheme.ts"; 53 | import { delay } from "./deps/async.ts"; 54 | declare const scrapbox: Scrapbox; 55 | 56 | declare global { 57 | interface Window { 58 | /** CSRF token */ 59 | _csrf?: string; 60 | } 61 | } 62 | 63 | export interface PageProps extends BubbleOperators { 64 | title: string; 65 | project: string; 66 | whiteList: Set; 67 | delay: number; 68 | lines: { text: string; id: string }[] | string[]; 69 | noIndent?: boolean; 70 | hash?: string; 71 | linkTo?: LinkTo; 72 | 73 | prefetch: (project: string, title: string) => void; 74 | } 75 | 76 | // 限定のcontext 77 | const context = createContext< 78 | & { 79 | title: string; 80 | project: string; 81 | whiteList: Set; 82 | delay: number; 83 | prefetch: (project: string, title: string) => void; 84 | } 85 | & BubbleOperators 86 | >({ 87 | title: "", 88 | project: "", 89 | whiteList: new Set(), 90 | bubble: () => {}, 91 | hide: () => {}, 92 | delay: 0, 93 | prefetch: () => {}, 94 | }); 95 | 96 | export const Page = ( 97 | { lines, project, title, whiteList, noIndent, hash, linkTo, ...props }: 98 | PageProps, 99 | ): h.JSX.Element => { 100 | const lineIds = useMemo( 101 | () => lines.flatMap((line) => typeof line === "string" ? [] : [line.id]), 102 | [lines], 103 | ); 104 | const blocks = useMemo(() => { 105 | let counter = 0; 106 | return parse( 107 | lines.map((line) => typeof line === "string" ? line : line.text).join( 108 | "\n", 109 | ), 110 | { hasTitle: true }, 111 | ).map((block) => { 112 | switch (block.type) { 113 | case "title": 114 | case "line": 115 | return { 116 | ...block, 117 | id: lineIds[counter++], 118 | }; 119 | case "codeBlock": { 120 | const start = counter; 121 | counter += block.content.split("\n").length + 1; 122 | return { 123 | ...block, 124 | ids: lineIds.slice(start, counter), 125 | }; 126 | } 127 | case "table": { 128 | const start = counter; 129 | counter += block.cells.length + 1; 130 | return { 131 | ...block, 132 | ids: lineIds.slice(start, counter), 133 | }; 134 | } 135 | } 136 | }); 137 | }, [lines, lineIds]); 138 | 139 | const scrollId = useMemo(() => { 140 | if (hash && lineIds.includes(hash)) return hash; 141 | if (!linkTo) return; 142 | 143 | return (blocks.find((block) => { 144 | if (block.type !== "line") return false; 145 | 146 | return hasLink(linkTo, block.nodes); 147 | }) as LineType & { id: string } | undefined)?.id; 148 | }, [blocks, lineIds, hash, linkTo?.project, linkTo?.titleLc]); 149 | 150 | // リンク先にスクロールする 151 | const ref = useRef(null); 152 | useEffect(() => { 153 | if (!scrollId) return; 154 | 155 | const targetLine = ref.current?.querySelector(`[data-id="${scrollId}"]`); 156 | const scrollY = globalThis.scrollY; 157 | targetLine?.scrollIntoView?.({ block: "center" }); 158 | globalThis.scroll(0, scrollY); 159 | }, [scrollId]); 160 | 161 | const theme = useTheme(project); 162 | 163 | return ( 164 |
165 | 168 | {blocks.map((block) => { 169 | switch (block.type) { 170 | case "title": 171 | return ( 172 | <> 173 | 180 | 189 | {block.text} 190 | 191 | 192 |
193 | 194 | ); 195 | case "codeBlock": { 196 | return ( 197 | 204 | ); 205 | } 206 | case "table": { 207 | return ( 208 | 215 | ); 216 | } 217 | case "line": 218 | return ( 219 | 226 | {block.nodes.length > 0 227 | ? block.nodes.map((node) => ( 228 | 229 | )) 230 | :
} 231 |
232 | ); 233 | } 234 | })} 235 | 236 | 237 | ); 238 | }; 239 | 240 | type LineProps = { 241 | index: string; 242 | indent: number; 243 | permalink?: boolean; 244 | noIndent?: boolean; 245 | children: ComponentChildren; 246 | }; 247 | 248 | const Line = ({ index, indent, noIndent, children, permalink }: LineProps) => ( 249 |
255 | {children} 256 |
257 | ); 258 | 259 | type CodeBlockProps = { 260 | block: CodeBlockType; 261 | noIndent?: boolean; 262 | ids: string[]; 263 | scrollId?: string; 264 | }; 265 | const CodeBlock = ( 266 | { block: { fileName, content, indent }, ids, scrollId }: CodeBlockProps, 267 | ) => { 268 | const { project, title } = useContext(context); 269 | const [buttonLabel, setButtonLabel] = useState("\uf0c5"); 270 | const handleClick = useCallback( 271 | async (e: h.JSX.TargetedMouseEvent) => { 272 | e.preventDefault(); 273 | e.stopPropagation(); 274 | try { 275 | await navigator.clipboard.writeText(content); 276 | setButtonLabel("Copied"); 277 | await delay(1000); 278 | setButtonLabel("\uf0c5"); 279 | } catch (e: unknown) { 280 | alert(`Failed to copy the code block\nError: ${e}`); 281 | } 282 | }, 283 | [content], 284 | ); 285 | 286 | return ( 287 | <> 288 | 289 | 290 | 291 | 295 | {fileName} 296 | 297 | 298 | 299 | {buttonLabel} 300 | 301 | 302 | 303 | <> 304 | {content.split("\n").map((line, index) => ( 305 | 310 | 311 | {line} 312 | 313 | 314 | ))} 315 | 316 | 317 | ); 318 | }; 319 | 320 | type TableProps = { 321 | block: TableType; 322 | noIndent?: boolean; 323 | scrollId?: string; 324 | ids: string[]; 325 | }; 326 | const Table = ( 327 | { 328 | block: { fileName, cells, indent }, 329 | ids, 330 | scrollId, 331 | }: TableProps, 332 | ) => { 333 | const { project, title } = useContext(context); 334 | 335 | return ( 336 | <> 337 | 338 | 339 | 340 | 346 | {fileName} 347 | 348 | 349 | 350 | 351 | <> 352 | {cells.map((cell, i) => ( 353 | 358 | 359 | {cell.map((row, index) => ( 360 | 361 | {row.map((node) => )} 362 | 363 | ))} 364 | 365 | 366 | ))} 367 | 368 | 369 | ); 370 | }; 371 | 372 | type NodeProps = { 373 | node: NodeType; 374 | }; 375 | const Node = ({ node }: NodeProps) => { 376 | switch (node.type) { 377 | case "code": 378 | return {node.text}; 379 | case "formula": 380 | return ; 381 | case "commandLine": 382 | return ; 383 | case "helpfeel": 384 | return ( 385 | 386 | ?{" "} 387 | {node.text} 388 | 389 | ); 390 | case "quote": 391 | return ( 392 |
393 | {node.nodes.map((node) => )} 394 |
395 | ); 396 | case "strong": 397 | return ( 398 | 399 | {node.nodes.map((node) => )} 400 | 401 | ); 402 | case "decoration": 403 | return ; 404 | case "plain": 405 | case "blank": 406 | return <>{node.text}; 407 | case "hashTag": 408 | return ; 409 | case "link": 410 | return ; 411 | case "googleMap": 412 | return ; 413 | case "icon": 414 | return ; 415 | case "strongIcon": 416 | return ; 417 | case "image": 418 | return ; 419 | case "strongImage": 420 | return ; 421 | case "numberList": 422 | return ( 423 | <> 424 | {`${node.number}. `} 425 | {node.nodes.map((node) => )} 426 | 427 | ); 428 | } 429 | }; 430 | 431 | type FormulaProps = { node: FormulaNode }; 432 | const Formula = ({ node: { formula } }: FormulaProps) => { 433 | const { ref, error } = useKaTeX(formula); 434 | 435 | return ( 436 | 437 | {error 438 | ? {formula} 439 | : } 440 | 441 | ); 442 | }; 443 | type DecorationProps = { node: DecorationNode }; 444 | const Decoration = ( 445 | { node: { decos, nodes } }: DecorationProps, 446 | ) => ( 447 | `deco-${deco}`).join(" ")}> 448 | {nodes.map((node) => )} 449 | 450 | ); 451 | 452 | type CommandLineProps = { node: CommandLineNode }; 453 | const CommandLine = ( 454 | { node }: CommandLineProps, 455 | ) => { 456 | const [buttonLabel, setButtonLabel] = useState("\uf0c5"); 457 | const handleClick = useCallback( 458 | async (e: h.JSX.TargetedMouseEvent) => { 459 | e.preventDefault(); 460 | e.stopPropagation(); 461 | try { 462 | await navigator.clipboard.writeText(node.text); 463 | setButtonLabel("Copied"); 464 | await delay(1000); 465 | setButtonLabel("\uf0c5"); 466 | } catch (e: unknown) { 467 | alert(`Failed to copy the code block\nError: ${e}`); 468 | } 469 | }, 470 | [node.text], 471 | ); 472 | 473 | return ( 474 | <> 475 | 476 | {node.symbol}{" "} 477 | {node.text} 478 | 479 | 480 | 481 | {buttonLabel} 482 | 483 | 484 | 485 | ); 486 | }; 487 | type GoogleMapProps = { node: GoogleMapNode }; 488 | const GoogleMap = ( 489 | { node: { place, latitude, longitude, zoom } }: GoogleMapProps, 490 | ) => ( 491 | 492 | 497 | 503 | 504 | 505 | ); 506 | type IconProps = { 507 | node: IconNode; 508 | strong?: false; 509 | } | { 510 | node: StrongIconNode; 511 | strong: true; 512 | }; 513 | const Icon = ( 514 | { node: { pathType, path }, strong }: IconProps, 515 | ) => { 516 | const { project: _project } = useContext(context); 517 | const [project, title] = pathType === "relative" 518 | ? [ 519 | _project, 520 | path, 521 | ] 522 | : path.match(/\/([\w\-]+)\/(.+)$/)?.slice?.(1) ?? [_project, path]; 523 | const titleLc = encodeTitleURI(title); 524 | 525 | return ( 526 | 531 | {title} 536 | 537 | ); 538 | }; 539 | type ImageProps = { node: ImageNode }; 540 | const Image = ({ node: { link, src } }: ImageProps) => { 541 | const href = link || 542 | // linkが空文字のとき 543 | (/https:\/\/gyazo\.com\/[^\/]+\/thumb\/1000/.test(src) 544 | ? src.slice(0, -"/thumb/1000".length) 545 | : src); 546 | 547 | return ( 548 | 554 | 555 | 556 | ); 557 | }; 558 | 559 | type HashTagProps = { node: HashTagNode }; 560 | const HashTag = ({ node: { href } }: HashTagProps) => { 561 | const { project } = useContext(context); 562 | const emptyLink = useEmptyLink(project, href); 563 | const handleHover = useHover(project, href, "hashtag"); 564 | 565 | return ( 566 | 574 | #{href} 575 | 576 | ); 577 | }; 578 | type LinkProps = { node: LinkNode }; 579 | const Link = ( 580 | { node: { pathType, ...node } }: LinkProps, 581 | ) => { 582 | switch (pathType) { 583 | case "relative": 584 | case "root": 585 | return ; 586 | case "absolute": { 587 | const linkNode = parseAbsoluteLink({ pathType, ...node }); 588 | switch (linkNode.type) { 589 | case "youtube": 590 | return ; 591 | case "vimeo": 592 | return ; 593 | case "spotify": 594 | return ; 595 | case "anchor-fm": 596 | return ; 597 | case "audio": 598 | return