Skip to content

リンククリック対応を追加 (Closes #13)#14

Merged
mrmt merged 5 commits into
mainfrom
feat/link-handling
Apr 23, 2026
Merged

リンククリック対応を追加 (Closes #13)#14
mrmt merged 5 commits into
mainfrom
feat/link-handling

Conversation

@mrmt

@mrmt mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • マークダウン内のリンクをクリックした際の動作を実装 (リンク対応 #13)
    • http/https 等の外部URL → 既定ブラウザで開く (NSWorkspace.shared.open)
    • ローカルファイル参照 → .md/.markdown かつ読み取り可能なら別ウインドウで開く。それ以外は何もしない
  • HTMLFormatter でリンク先を事前解決し、相対パスをマークダウンファイルのディレクトリ基準で絶対URL化
  • WKWebViewdecidePolicyFor navigationAction.linkActivated を捕捉してスキーム別にディスパッチ

実装メモ

  • loadHTMLString 時の baseURL は bundle パスのまま維持 (mermaid.min.js の相対ロードに依存)。リンク側は HTML 生成時に絶対化しているため baseURL と独立に解決できる
  • fragment のみ (#section) はそのまま残す

Test plan

  • swift test 全通過 (既存 10 件 + 新規 HTMLFormatterLinkTests 9 件)
    • https/相対/.//親 ..//絶対パス/fragment/mailto/baseURL 未設定/%エンコード のケースを網羅
  • 手動確認 (未実施): 実アプリで https リンクがブラウザで開くこと、相対 .md リンクが別ウインドウで開くこと、存在しないファイルや画像への相対リンクが何もしないこと

🤖 Generated with Claude Code

mrmt and others added 2 commits April 23, 2026 17:33
マークダウン内のリンクに応じて動作を切り替える。
- http/httpsなど外部URLは既定ブラウザで開く
- ローカルファイル参照は.md/.markdownでreadableなら別ウインドウで開き、そうでなければ何もしない

HTMLFormatterで相対パスをマークダウンファイルのディレクトリ基準で絶対URL化し、
WKWebViewのdecidePolicyFor navigationActionでクリックを捕捉してスキーム別にディスパッチする。

Closes #13

Co-authored-by: Claude Code <claude@anthropic.com>
WKWebViewのloadHTMLStringで表示した場合、decidePolicyFor navigationActionが
リンククリックで呼ばれないケースがあった (file originまわりの制約)。
JavaScriptでaタグのclickを捕捉し、WKScriptMessageHandler経由で
Swift側に通知する方式に切り替える。

- fragment-only リンク (#section) はJS側で素通しし、ページ内ジャンプを維持
- decidePolicyForは.linkActivated検知時のフォールバックとして残す

Co-authored-by: Claude Code <claude@anthropic.com>
@mrmt mrmt self-assigned this Apr 23, 2026
@mrmt

mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

マージ前に 2 点気になりました。

  1. MarkdownWebView.swiftresolveLinkDestination で、other.md#section のような「別ファイル + fragment」のリンクが file:///.../other.md%23section に変換されます。結果として openLocalMarkdownFile 側では pathExtensionmd#section になって弾かれるため、この形式のローカルリンクは開けません。

  2. decidePolicyFor navigationAction.linkActivated を無条件に cancel しているので、#section のようなページ内アンカーも WebKit の既定動作まで届かず、ページ内ジャンプが効かないはずです。

swift test は 19 件通っていましたが、どちらも今回のリンク対応の主要ケースに関わるので、この 2 点を直してからマージするのが安全だと思います。

1. 相対パス + fragment (other.md#section) が壊れる問題
   URL(fileURLWithPath:)はパス全体をパス名として扱うため、#が%23に
   パーセントエンコードされ、pathExtensionが"md%23section"になって
   .md判定で弾かれていた。URL(string:relativeTo:)ベースに書き換え、
   fragment/queryを正しく保持したまま絶対URL化する。

2. ページ内アンカー (#section) がジャンプしない問題
   .linkActivated を無条件cancelしていたためWebKitの既定動作まで
   届かなかった。主処理はJSインターセプタ側で href.startsWith('#')
   の時に document.getElementById().scrollIntoView() を実行する
   形に変更。decidePolicyFor側も保険として、現在ドキュメントと
   同一 (scheme/host/path) で fragment のみ異なる navigation を
   allow するフォールバックを追加。

openLocalMarkdownFile 側も fragment/query を取り除いた素のファイル
URLでNotificationをpostするよう修正 (受信側は単純なfile URLを期待)。

テスト追加:
- 相対リンク + fragment が file:///.../other.md#section に解決されること
- そのURLをパースしたときpathExtensionが"md"でfragmentが保持されること
- 相対リンク + query が保持されること

Co-authored-by: Claude Code <claude@anthropic.com>
@mrmt

mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

ご指摘2点対応しました (d274c47)。

1. other.md#section 問題
URL(fileURLWithPath:) がパス全体をパス名として扱い #%23 にエンコードされていたため、URL(string:relativeTo:) ベースに書き換えました。これで fragment/query を正しく保持したまま絶対URL化されます。openLocalMarkdownFile 側も url.pathExtension (fragmentを含まない) で正しく判定でき、ウインドウを開く際は fragment/query を除去した素のfile URLを post するようにしています。

2. #section ページ内ジャンプが効かない問題
.linkActivated 無条件cancelをやめました。主処理はJSインターセプタ側に寄せて、href.startsWith('#') のときに document.getElementById(...).scrollIntoView() を実行する形にしています。decidePolicyFor 側もフォールバックとして「現在ドキュメントと scheme/host/path が同じで fragment のみ異なる navigation」 は allow するように修正。

テスト3件追加 (fragment保持 / pathExtension判定 / query保持) で合計 22 件通過しています。実機確認でも fragment-only リンクでページ内ジャンプ、other.md#inner で別ウインドウが開くのを確認済みです。

- 見出しにGitHub風のslug IDを付与 (HTMLFormatter.slugify)
  例: ## section → <h2 id="section">
  同名見出しの衝突時は-1,-2のsuffixを付ける

- fragment-only リンク (#section) がJS側のgetElementById→
  scrollIntoViewで正しくジャンプするようになる

- other.md#inner のような別ファイル+fragmentリンクで、
  リンク先ファイルを別ウインドウで開いた後、対応する見出しに
  スクロールする:
  - openLocalMarkdownFile で fragment を保持したまま URL を post
  - ContentView.loadMarkdownFile(url:) で fragment を state 化
  - MarkdownWebView.initialFragment → Coordinator.pendingFragment
  - didFinish で pendingFragment があれば scrollIntoView を実行
    (スクロール位置復元より優先)

- テストで HTMLFormatter が swift-markdown の同名型と ambiguous
  だったため MarkdownViewer.HTMLFormatter と明示修飾

テスト7件追加 (slug化6件 + query保持1件)、合計29件通過

Co-authored-by: Claude Code <claude@anthropic.com>
@mrmt

mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

追加で2点対応しました (15b9627)。

  • 見出しに GitHub 風 slug ID を付与 (## section<h2 id="section">)。同名見出しは -1, -2 suffix。これで #section フラグメントが JS 側の getElementById → scrollIntoView で正しくジャンプするようになりました。
  • other.md#inner のような別ファイル + fragment リンクで、別ウインドウを開いた後に対応する見出しへスクロールする経路を追加。ContentView.initialFragmentMarkdownWebView.initialFragmentCoordinator.pendingFragmentdidFinishscrollIntoView (スクロール位置復元より優先)。
  • テストで HTMLFormatter が swift-markdown の同名型と ambiguous だったのも MarkdownViewer.HTMLFormatter と明示修飾に修正。

テスト合計 29 件通過、実機でもページ内ジャンプ / 別ファイル fragment ジャンプとも動作確認済みです。

@mrmt

mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

前回の 2 点は解消されているのを確認できました。追加で 1 点だけ気になっています。

initialFragment が one-shot ではなく ContentView の state として保持され続けているため、other.md#inner のように fragment 付きで開いたファイルは、その後の自動リロード時にも毎回 #inner へスクロールされます。結果として、通常の再描画時に期待される「現在のスクロール位置を維持する」挙動が崩れます。

経路としては:

  • loadMarkdownFile(path:fragment:)initialFragment = fragment
  • updateNSView で毎回 pendingFragment = self.initialFragment
  • didFinish で fragment があると位置復元より優先して scrollIntoView

なので、fragment を一度適用したら initialFragment をクリアするか、親側では保持せず one-shot で渡す形にした方がよさそうです。

指摘対応: fragment付きURL (other.md#inner) で開いたファイルの
自動リロード時にも毎回 scrollIntoView されてしまい、スクロール位置
維持の挙動が崩れていた。

MarkdownWebView.initialFragment を @binding<String?> に変更し、
updateNSView で pendingFragment に引き渡した直後に親側 state を
nil にクリアする。これで initialFragment は一度だけ消費され、
以降の自動リロード等では pendingFragment=nil となり、通常の
スクロール位置復元のみが実行される。

Co-authored-by: Claude Code <claude@anthropic.com>
@mrmt

mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

ご指摘対応しました (d2fdb64)。

MarkdownWebView.initialFragment@Binding<String?> に変更し、updateNSViewCoordinator.pendingFragment に引き渡した直後に親側 state (ContentView.initialFragment) を nil クリアするようにしました。これで fragment は最初のロードの1回だけ消費され、ファイル変更検知による自動リロード時は pendingFragment = nil となり通常のスクロール位置復元のみが走ります。

@mrmt

mrmt commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

最新 head d2fdb64 を確認しました。前回指摘していた initialFragment の one-shot 化も反映されており、追加の懸念はありません。

swift test も clean snapshot 上で 29 件通過していることを確認できたので、現時点ではマージして問題ないです。

@mrmt mrmt merged commit 6f38f25 into main Apr 23, 2026
1 check passed
@mrmt mrmt deleted the feat/link-handling branch April 23, 2026 09:44
This was referenced Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant