@jant/core 0.6.6 → 0.6.8

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.
Files changed (112) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/json.test.ts +94 -0
  22. package/src/client/__tests__/note-expand.test.ts +130 -0
  23. package/src/client/archive-nav.js +2 -1
  24. package/src/client/audio-player.ts +7 -3
  25. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  26. package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
  27. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  29. package/src/client/components/compose-format-convert.ts +255 -0
  30. package/src/client/components/compose-types.ts +2 -0
  31. package/src/client/components/jant-collection-directory.ts +1 -0
  32. package/src/client/components/jant-collection-form.ts +1 -0
  33. package/src/client/components/jant-command-palette.ts +4 -0
  34. package/src/client/components/jant-compose-dialog.ts +106 -44
  35. package/src/client/components/jant-compose-editor.ts +65 -11
  36. package/src/client/components/jant-compose-fullscreen.ts +3 -0
  37. package/src/client/components/jant-nav-manager.ts +4 -0
  38. package/src/client/components/jant-post-menu.ts +3 -0
  39. package/src/client/components/jant-repo-picker.ts +3 -0
  40. package/src/client/components/jant-settings-general.ts +3 -0
  41. package/src/client/compose-bridge.ts +17 -0
  42. package/src/client/feed-video-player.ts +1 -1
  43. package/src/client/hydrate-partial.ts +25 -0
  44. package/src/client/json.ts +56 -2
  45. package/src/client/multipart-upload.ts +17 -7
  46. package/src/client/note-expand.ts +63 -0
  47. package/src/client/upload-session.ts +17 -9
  48. package/src/client.ts +1 -0
  49. package/src/i18n/locales/public/en.po +41 -0
  50. package/src/i18n/locales/public/en.ts +1 -1
  51. package/src/i18n/locales/public/zh-Hans.po +41 -0
  52. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/public/zh-Hant.po +41 -0
  54. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  55. package/src/i18n/locales/settings/en.po +12 -12
  56. package/src/i18n/locales/settings/en.ts +1 -1
  57. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  58. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  59. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  60. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  61. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  62. package/src/lib/__tests__/markdown.test.ts +1 -1
  63. package/src/lib/__tests__/summary.test.ts +87 -0
  64. package/src/lib/__tests__/timeline.test.ts +48 -1
  65. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  66. package/src/lib/__tests__/url.test.ts +44 -0
  67. package/src/lib/__tests__/view.test.ts +168 -1
  68. package/src/lib/navigation.ts +1 -0
  69. package/src/lib/resolve-config.ts +2 -2
  70. package/src/lib/summary.ts +42 -3
  71. package/src/lib/tiptap-render.ts +6 -2
  72. package/src/lib/upload.ts +2 -2
  73. package/src/lib/url.ts +41 -0
  74. package/src/lib/view.ts +102 -40
  75. package/src/preset.css +7 -1
  76. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  77. package/src/routes/api/internal/sites.ts +77 -1
  78. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  79. package/src/routes/api/public/archive.ts +22 -6
  80. package/src/routes/api/telegram.ts +2 -1
  81. package/src/routes/dash/custom-urls.tsx +1 -1
  82. package/src/routes/dash/settings.tsx +8 -5
  83. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  84. package/src/routes/pages/archive.tsx +116 -20
  85. package/src/routes/pages/collections.tsx +1 -0
  86. package/src/services/__tests__/media.test.ts +83 -0
  87. package/src/services/__tests__/post.test.ts +81 -0
  88. package/src/services/export-theme/assets/client-site.js +1 -1
  89. package/src/services/export-theme/styles/main.css +49 -15
  90. package/src/services/media.ts +31 -1
  91. package/src/services/post.ts +22 -2
  92. package/src/services/search.ts +4 -4
  93. package/src/services/site-admin.ts +121 -0
  94. package/src/services/upload-session.ts +18 -0
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles/ui.css +163 -34
  97. package/src/types/config.ts +1 -1
  98. package/src/types/props.ts +3 -0
  99. package/src/ui/compose/ComposeDialog.tsx +13 -0
  100. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  101. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  102. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  103. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  104. package/src/ui/feed/NoteCard.tsx +54 -5
  105. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  106. package/src/ui/pages/ArchivePage.tsx +89 -6
  107. package/src/ui/pages/CollectionsPage.tsx +7 -1
  108. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  109. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  110. package/src/ui/shared/CollectionsManager.tsx +3 -0
  111. package/dist/app-CL2PC1Fl.js +0 -6
  112. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -230,6 +230,8 @@ const labels: ComposeLabels = {
230
230
  showMore: "Show more",
231
231
  showLess: "Show less",
232
232
  newThread: "New Thread",
233
+ replyTitle: "Reply",
234
+ editTitle: "Edit",
233
235
  slashHint: "Type / for commands",
234
236
  collectionFormLabels: {
235
237
  titleLabel: "Title",
@@ -601,6 +603,317 @@ describe("JantComposeDialog", () => {
601
603
  expect(focusSpy).not.toHaveBeenCalled();
602
604
  });
603
605
 
606
+ function mockEditPost(post: Record<string, unknown>) {
607
+ return vi.spyOn(globalThis, "fetch").mockResolvedValue(
608
+ new Response(JSON.stringify({ id: "pst_123", ...post }), {
609
+ headers: { "Content-Type": "application/json" },
610
+ }),
611
+ );
612
+ }
613
+
614
+ it("shows the format switcher instead of a title while editing", async () => {
615
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
616
+ cb(0);
617
+ return 1;
618
+ });
619
+ mockEditPost({ format: "note", title: "Hello", body: null });
620
+
621
+ const el = await createElement();
622
+ await el.openEdit("pst_123");
623
+ await flushUpdates(el);
624
+
625
+ expect(el.querySelector(".compose-segmented")).not.toBeNull();
626
+ // The "Edit post" title is gone — the switcher takes the center slot.
627
+ expect(el.querySelector(".compose-dialog-title")).toBeNull();
628
+ });
629
+
630
+ it("shows a Reply title with the format selector above the post when replying", async () => {
631
+ const el = await createElement();
632
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
633
+ contentHtml: "<p>Parent</p>",
634
+ dateText: "Mar 14",
635
+ });
636
+ await flushUpdates(el);
637
+
638
+ expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
639
+ "Reply",
640
+ );
641
+ // The header center no longer hosts the format selector...
642
+ expect(
643
+ el.querySelector(".compose-dialog-header-center .compose-segmented"),
644
+ ).toBeNull();
645
+ // ...it sits inline above the reply editor instead.
646
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
647
+ });
648
+
649
+ it("shows an Edit title with the format selector above the post when editing a reply", async () => {
650
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
651
+ cb(0);
652
+ return 1;
653
+ });
654
+ const parentId = "019ce8ce-d6d8-7fda-a5df-c2da2bef5ade";
655
+ // A fresh Response per call: openEdit reads the edited post, then
656
+ // _fetchReplyContext reads the parent — a single shared Response body can
657
+ // only be consumed once.
658
+ vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
659
+ const url = String(input);
660
+ const json = url.includes(parentId)
661
+ ? { id: parentId, bodyHtml: "<p>Parent</p>", format: "note" }
662
+ : {
663
+ id: "pst_123",
664
+ format: "note",
665
+ title: "Hello",
666
+ body: null,
667
+ replyToId: parentId,
668
+ };
669
+ return Promise.resolve(
670
+ new Response(JSON.stringify(json), {
671
+ headers: { "Content-Type": "application/json" },
672
+ }),
673
+ );
674
+ });
675
+
676
+ const el = await createElement();
677
+ await el.openEdit("pst_123");
678
+ await flushUpdates(el);
679
+
680
+ expect(el.querySelector(".compose-dialog-title")?.textContent?.trim()).toBe(
681
+ "Edit",
682
+ );
683
+ expect(
684
+ el.querySelector(".compose-dialog-header-center .compose-segmented"),
685
+ ).toBeNull();
686
+ expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
687
+ });
688
+
689
+ it("switches format from the inline selector when replying", async () => {
690
+ const el = await createElement();
691
+ await el.openReply("019ce8ce-d6d8-7fda-a5df-c2da2bef5ade", {
692
+ contentHtml: "<p>Parent</p>",
693
+ dateText: "Mar 14",
694
+ });
695
+ await flushUpdates(el);
696
+
697
+ const items = el.querySelectorAll<HTMLButtonElement>(
698
+ ".compose-thread-post-header .compose-segmented-item",
699
+ );
700
+ // note / link / quote
701
+ expect(items.length).toBe(3);
702
+ items[1].click(); // link
703
+ await flushUpdates(el);
704
+
705
+ expect(el._format).toBe("link");
706
+ });
707
+
708
+ it("edit-mode format switch folds quote fields into the body", async () => {
709
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
710
+ cb(0);
711
+ return 1;
712
+ });
713
+ mockEditPost({
714
+ format: "quote",
715
+ quoteText: "Stay hungry",
716
+ sourceName: "Jobs",
717
+ body: null,
718
+ });
719
+
720
+ const el = await createElement();
721
+ await el.openEdit("pst_123");
722
+ await flushUpdates(el);
723
+
724
+ const editor = requireElement(
725
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
726
+ "expected compose editor",
727
+ );
728
+ expect(editor._quoteText).toBe("Stay hungry");
729
+
730
+ // Click the Note segmented button.
731
+ el.querySelectorAll<HTMLButtonElement>(
732
+ ".compose-segmented-item",
733
+ )[0].click();
734
+ await flushUpdates(el);
735
+
736
+ expect(el._format).toBe("note");
737
+ expect(editor._quoteText).toBe("");
738
+ expect(editor._quoteAuthor).toBe("");
739
+ expect(editor._bodyJson?.content?.[0]?.type).toBe("blockquote");
740
+ });
741
+
742
+ it("edit-mode format switch marks the post as having unsaved changes", async () => {
743
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
744
+ cb(0);
745
+ return 1;
746
+ });
747
+ mockEditPost({ format: "note", title: "Hello", body: null });
748
+
749
+ const el = await createElement();
750
+ await el.openEdit("pst_123");
751
+ await flushUpdates(el);
752
+
753
+ const hasUnsaved = (el as unknown as { _hasUnsavedChanges(): boolean })
754
+ ._hasUnsavedChanges;
755
+ expect(hasUnsaved.call(el)).toBe(false);
756
+
757
+ el.querySelectorAll<HTMLButtonElement>(
758
+ ".compose-segmented-item",
759
+ )[2].click();
760
+ await flushUpdates(el);
761
+
762
+ expect(el._format).toBe("quote");
763
+ expect(hasUnsaved.call(el)).toBe(true);
764
+ });
765
+
766
+ it("edit-mode autosave writes to the edit-specific draft key", async () => {
767
+ vi.useFakeTimers();
768
+ try {
769
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
770
+ cb(0);
771
+ return 1;
772
+ });
773
+ mockEditPost({ format: "note", title: "Hello", body: null });
774
+
775
+ const el = await createElement();
776
+ await el.openEdit("pst_123");
777
+ await el.updateComplete;
778
+
779
+ const editor = requireElement(
780
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
781
+ "expected compose editor",
782
+ );
783
+ editor._bodyJson = {
784
+ type: "doc",
785
+ content: [
786
+ {
787
+ type: "paragraph",
788
+ content: [{ type: "text", text: "Edited body" }],
789
+ },
790
+ ],
791
+ };
792
+ await editor.updateComplete;
793
+
794
+ vi.advanceTimersByTime(1000);
795
+
796
+ expect(
797
+ globalThis.localStorage.getItem("jant:compose-edit:pst_123"),
798
+ ).not.toBeNull();
799
+ expect(globalThis.localStorage.getItem("jant:compose-draft")).toBeNull();
800
+ } finally {
801
+ vi.useRealTimers();
802
+ }
803
+ });
804
+
805
+ it("autosaves only on a real edit, not on open or a bare format switch", async () => {
806
+ vi.useFakeTimers();
807
+ try {
808
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
809
+ cb(0);
810
+ return 1;
811
+ });
812
+ mockEditPost({
813
+ format: "note",
814
+ title: null,
815
+ body: JSON.stringify({
816
+ type: "doc",
817
+ content: [
818
+ { type: "paragraph", content: [{ type: "text", text: "hi" }] },
819
+ ],
820
+ }),
821
+ });
822
+
823
+ const el = await createElement();
824
+ await el.openEdit("pst_123");
825
+ await el.updateComplete;
826
+ const editor = requireElement(
827
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
828
+ "expected compose editor",
829
+ );
830
+ await editor.updateComplete;
831
+
832
+ // Opening a post for edit must not persist a local draft on its own.
833
+ vi.advanceTimersByTime(1000);
834
+ expect(
835
+ globalThis.localStorage.getItem("jant:compose-edit:pst_123"),
836
+ ).toBeNull();
837
+
838
+ // Switching format only must not persist either.
839
+ el.querySelectorAll<HTMLButtonElement>(
840
+ ".compose-segmented-item",
841
+ )[2].click();
842
+ await el.updateComplete;
843
+ await editor.updateComplete;
844
+ vi.advanceTimersByTime(1000);
845
+ expect(
846
+ globalThis.localStorage.getItem("jant:compose-edit:pst_123"),
847
+ ).toBeNull();
848
+
849
+ // A real edit afterwards persists as usual, with the switched format.
850
+ editor._quoteText = "now editing";
851
+ await editor.updateComplete;
852
+ vi.advanceTimersByTime(1000);
853
+
854
+ const saved = globalThis.localStorage.getItem(
855
+ "jant:compose-edit:pst_123",
856
+ );
857
+ expect(saved).not.toBeNull();
858
+ expect(JSON.parse(saved as string).format).toBe("quote");
859
+ } finally {
860
+ vi.useRealTimers();
861
+ }
862
+ });
863
+
864
+ it("enables the publish button right after an edit-mode switch to quote", async () => {
865
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
866
+ cb(0);
867
+ return 1;
868
+ });
869
+ // A note whose body is a blockquote + paragraph — switching to quote
870
+ // extracts the blockquote into the quote-text field.
871
+ mockEditPost({
872
+ format: "note",
873
+ title: null,
874
+ body: JSON.stringify({
875
+ type: "doc",
876
+ content: [
877
+ {
878
+ type: "blockquote",
879
+ content: [
880
+ {
881
+ type: "paragraph",
882
+ content: [{ type: "text", text: "quote2233" }],
883
+ },
884
+ {
885
+ type: "paragraph",
886
+ content: [{ type: "text", text: "— author1" }],
887
+ },
888
+ ],
889
+ },
890
+ { type: "paragraph", content: [{ type: "text", text: "这个22" }] },
891
+ ],
892
+ }),
893
+ });
894
+
895
+ const el = await createElement();
896
+ await el.openEdit("pst_123");
897
+ await flushUpdates(el);
898
+
899
+ el.querySelectorAll<HTMLButtonElement>(
900
+ ".compose-segmented-item",
901
+ )[2].click();
902
+ await flushUpdates(el);
903
+
904
+ const editor = requireElement(
905
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
906
+ "expected compose editor",
907
+ );
908
+ expect(el._format).toBe("quote");
909
+ expect(editor._quoteText).toBe("quote2233");
910
+ // The submit button must reflect the now-valid quote, not the stale
911
+ // pre-switch format.
912
+ expect(
913
+ el.querySelector<HTMLButtonElement>(".compose-publish-main")?.disabled,
914
+ ).toBe(false);
915
+ });
916
+
604
917
  it("submit dispatches jant:compose-submit-deferred with correct payload", async () => {
605
918
  const el = await createElement();
606
919
  const editor = requireElement(
@@ -3319,6 +3632,50 @@ describe("JantComposeDialog", () => {
3319
3632
  expect(el._confirmPanelOpen).toBe(false);
3320
3633
  });
3321
3634
 
3635
+ it("ignores Escape while an IME is composing (e.g. CJK candidate popup)", async () => {
3636
+ // Regression test for GitHub issue #120: when a user types pinyin and
3637
+ // presses Escape to dismiss the IME candidate popup, the compose dialog
3638
+ // must not interpret it as a close request.
3639
+ const el = await createElement();
3640
+ const editor = requireElement(
3641
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
3642
+ "expected compose editor",
3643
+ );
3644
+ editor._bodyJson = {
3645
+ type: "doc",
3646
+ content: [
3647
+ {
3648
+ type: "paragraph",
3649
+ content: [{ type: "text", text: "已经写了一些内容" }],
3650
+ },
3651
+ ],
3652
+ };
3653
+ await editor.updateComplete;
3654
+
3655
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
3656
+ el.dispatchEvent(
3657
+ new globalThis.KeyboardEvent("keydown", {
3658
+ key: "Escape",
3659
+ isComposing: true,
3660
+ bubbles: true,
3661
+ }),
3662
+ );
3663
+ await el.updateComplete;
3664
+
3665
+ expect(requestCloseSpy).not.toHaveBeenCalled();
3666
+ expect(el._confirmPanelOpen).toBe(false);
3667
+
3668
+ // Sanity: once composition ends, Escape works as before.
3669
+ el.dispatchEvent(
3670
+ new globalThis.KeyboardEvent("keydown", {
3671
+ key: "Escape",
3672
+ bubbles: true,
3673
+ }),
3674
+ );
3675
+ await el.updateComplete;
3676
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
3677
+ });
3678
+
3322
3679
  it("still closes normally after file picker selection", async () => {
3323
3680
  const el = await createElement();
3324
3681
  const editor = requireElement(
@@ -264,6 +264,8 @@ const labels: ComposeLabels = {
264
264
  showMore: "Show more",
265
265
  showLess: "Show less",
266
266
  newThread: "New Thread",
267
+ replyTitle: "Reply",
268
+ editTitle: "Edit",
267
269
  slashHint: "Type / for commands",
268
270
  collectionFormLabels: {
269
271
  titleLabel: "Title",
@@ -20,6 +20,8 @@ const labels = {
20
20
  showMore: "Show more",
21
21
  showLess: "Show less",
22
22
  newThread: "New Thread",
23
+ replyTitle: "Reply",
24
+ editTitle: "Edit",
23
25
  } as ComposeLabels;
24
26
 
25
27
  async function flush(el?: JantComposeFullscreen) {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Compose format conversion
3
+ *
4
+ * When a post's format changes while editing (note / link / quote), each format
5
+ * stores a different subset of structured fields. This module converts those
6
+ * fields so nothing is silently lost:
7
+ *
8
+ * - **Fold** (when *leaving* a format): any field the target format can't hold is
9
+ * pushed into the body as a visible block (a blockquote, a heading, or a link
10
+ * paragraph) and the field is cleared. This never loses data.
11
+ * - **Extract** (when *entering* a format): only `blockquote → quoteText` is
12
+ * recovered from the body front, which keeps the common `quote → note → quote`
13
+ * round-trip lossless. url/title are not auto-extracted — they stay visible in
14
+ * the body and the author re-fills the field if needed.
15
+ *
16
+ * Pure and DOM-free so it can be unit-tested in isolation. It deep-clones the
17
+ * body it is given and never mutates its input.
18
+ */
19
+
20
+ import type { JSONContent } from "@tiptap/core";
21
+
22
+ import type { ComposeFormat } from "./compose-types.js";
23
+
24
+ /** The subset of compose fields that participate in format conversion. */
25
+ export interface ComposeConvertFields {
26
+ title: string;
27
+ url: string;
28
+ quoteText: string;
29
+ quoteAuthor: string;
30
+ showTitle: boolean;
31
+ bodyJson: JSONContent | null;
32
+ }
33
+
34
+ /** Matches an attribution paragraph: `— Author` or `— Author https://source`. */
35
+ const ATTRIBUTION_RE = /^—\s*(.*?)(?:\s+(https?:\/\/\S+))?\s*$/;
36
+
37
+ function cloneDoc(doc: JSONContent | null): JSONContent | null {
38
+ return doc ? (JSON.parse(JSON.stringify(doc)) as JSONContent) : null;
39
+ }
40
+
41
+ /** Concatenate all descendant text of a node. */
42
+ function nodeText(node: JSONContent | undefined): string {
43
+ if (!node) return "";
44
+ if (typeof node.text === "string") return node.text;
45
+ if (!Array.isArray(node.content)) return "";
46
+ return node.content.map(nodeText).join("");
47
+ }
48
+
49
+ function makeHeading(text: string, level = 2): JSONContent {
50
+ return {
51
+ type: "heading",
52
+ attrs: { level },
53
+ content: [{ type: "text", text }],
54
+ };
55
+ }
56
+
57
+ function makeParagraph(text: string): JSONContent {
58
+ return text
59
+ ? { type: "paragraph", content: [{ type: "text", text }] }
60
+ : { type: "paragraph" };
61
+ }
62
+
63
+ function makeLinkParagraph(url: string): JSONContent {
64
+ return {
65
+ type: "paragraph",
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: url,
70
+ marks: [{ type: "link", attrs: { href: url } }],
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ function parseAttribution(text: string): {
77
+ author: string;
78
+ url: string | null;
79
+ } {
80
+ const match = ATTRIBUTION_RE.exec(text.trim());
81
+ if (!match) return { author: "", url: null };
82
+ return { author: match[1].trim(), url: match[2] ?? null };
83
+ }
84
+
85
+ /** First link href found anywhere within a node (depth-first), or null. */
86
+ function findLinkHref(node: JSONContent | undefined): string | null {
87
+ if (!node) return null;
88
+ if (Array.isArray(node.marks)) {
89
+ const href = node.marks.find((mark) => mark.type === "link")?.attrs?.href;
90
+ if (typeof href === "string" && href) return href;
91
+ }
92
+ if (Array.isArray(node.content)) {
93
+ for (const child of node.content) {
94
+ const found = findLinkHref(child);
95
+ if (found) return found;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Build the `— Author` attribution paragraph for a folded quote. The author name
103
+ * is linked to the source url so the body stays clean (no raw url) while the
104
+ * `note → quote` round-trip can still recover the url from the link mark. When
105
+ * there is no author, the url itself becomes the link text. Null if neither.
106
+ */
107
+ function makeAttributionParagraph(
108
+ author: string,
109
+ url: string | null,
110
+ ): JSONContent | null {
111
+ const name = author.trim();
112
+ const href = url?.trim() ?? "";
113
+ if (!name && !href) return null;
114
+
115
+ const linked = (text: string): JSONContent => ({
116
+ type: "text",
117
+ text,
118
+ marks: [{ type: "link", attrs: { href } }],
119
+ });
120
+ if (name && href) {
121
+ return {
122
+ type: "paragraph",
123
+ content: [{ type: "text", text: "— " }, linked(name)],
124
+ };
125
+ }
126
+ if (name) {
127
+ return {
128
+ type: "paragraph",
129
+ content: [{ type: "text", text: `— ${name}` }],
130
+ };
131
+ }
132
+ return {
133
+ type: "paragraph",
134
+ content: [{ type: "text", text: "— " }, linked(href)],
135
+ };
136
+ }
137
+
138
+ function makeBlockquote(
139
+ quoteText: string,
140
+ attribution: JSONContent | null,
141
+ ): JSONContent {
142
+ const paragraphs = quoteText.split("\n").map((line) => makeParagraph(line));
143
+ if (attribution) paragraphs.push(attribution);
144
+ return { type: "blockquote", content: paragraphs };
145
+ }
146
+
147
+ /**
148
+ * Convert compose fields from one post format to another.
149
+ *
150
+ * @param from - the current format
151
+ * @param to - the target format
152
+ * @param fields - the current field values (not mutated)
153
+ * @returns new field values appropriate for the target format
154
+ * @example
155
+ * // quote → note: the quote becomes a leading blockquote in the body
156
+ * convertComposeFormat("quote", "note", {
157
+ * title: "", url: "", quoteText: "Stay hungry", quoteAuthor: "Jobs",
158
+ * showTitle: false, bodyJson: null,
159
+ * });
160
+ */
161
+ export function convertComposeFormat(
162
+ from: ComposeFormat,
163
+ to: ComposeFormat,
164
+ fields: ComposeConvertFields,
165
+ ): ComposeConvertFields {
166
+ if (from === to) return fields;
167
+
168
+ const out: ComposeConvertFields = { ...fields };
169
+ const body = cloneDoc(fields.bodyJson);
170
+ const content: JSONContent[] = Array.isArray(body?.content)
171
+ ? [...body.content]
172
+ : [];
173
+
174
+ // ── Extract: leading bare-link paragraph → url (reverses the link→note
175
+ // fold so link↔note round-trips). Only a "bare" link (text === href) is
176
+ // pulled out, so a labeled link line keeps its label instead of silently
177
+ // losing it. ─────────────────────────────────────────────────────────
178
+ if (to === "link" && out.url === "") {
179
+ const first = content[0];
180
+ const href = findLinkHref(first);
181
+ if (
182
+ href &&
183
+ first?.type === "paragraph" &&
184
+ Array.isArray(first.content) &&
185
+ first.content.length === 1 &&
186
+ nodeText(first) === href
187
+ ) {
188
+ out.url = href;
189
+ content.shift();
190
+ }
191
+ }
192
+
193
+ // ── Extract (focused: blockquote → quoteText only) ──────────────────
194
+ if (
195
+ to === "quote" &&
196
+ out.quoteText === "" &&
197
+ content[0]?.type === "blockquote"
198
+ ) {
199
+ const paragraphs = Array.isArray(content[0].content)
200
+ ? [...content[0].content]
201
+ : [];
202
+ const last = paragraphs[paragraphs.length - 1];
203
+ const lastText = nodeText(last);
204
+ if (paragraphs.length > 0 && /^—/.test(lastText.trim())) {
205
+ const parsed = parseAttribution(lastText);
206
+ // Prefer the link mark's href (the linked-author form) over a url parsed
207
+ // from plain text (legacy `— Author https://…`).
208
+ const url = findLinkHref(last) ?? parsed.url;
209
+ // When the attribution is url-only, the link text is the url itself —
210
+ // don't mistake it for an author.
211
+ const author = parsed.author === url ? "" : parsed.author;
212
+ if (author) out.quoteAuthor = out.quoteAuthor || author;
213
+ if (url && out.url === "") out.url = url;
214
+ paragraphs.pop();
215
+ }
216
+ out.quoteText = paragraphs.map(nodeText).join("\n").trim();
217
+ content.shift();
218
+ }
219
+
220
+ // ── Harvest (fold fields the target can't hold into the body) ───────
221
+ const prepend: JSONContent[] = [];
222
+
223
+ // Title → heading (only quote can't hold a title)
224
+ if (to === "quote" && out.title.trim() !== "") {
225
+ prepend.push(makeHeading(out.title.trim()));
226
+ out.title = "";
227
+ }
228
+
229
+ // quoteText → blockquote (note and link can't hold a quote)
230
+ if (to !== "quote" && out.quoteText.trim() !== "") {
231
+ // For a note, the source url has no home either, so fold it into the
232
+ // attribution. For a link, url maps to link.url and is preserved.
233
+ const foldUrl = to === "note" ? out.url : null;
234
+ prepend.push(
235
+ makeBlockquote(
236
+ out.quoteText,
237
+ makeAttributionParagraph(out.quoteAuthor, foldUrl),
238
+ ),
239
+ );
240
+ out.quoteText = "";
241
+ out.quoteAuthor = "";
242
+ if (to === "note") out.url = "";
243
+ } else if (to === "note" && out.url.trim() !== "") {
244
+ // Source was a link (no quote text): notes can't hold a url, so fold it.
245
+ prepend.push(makeLinkParagraph(out.url.trim()));
246
+ out.url = "";
247
+ }
248
+
249
+ if (prepend.length) content.unshift(...prepend);
250
+
251
+ out.bodyJson = content.length ? { type: "doc", content } : null;
252
+ out.showTitle = to === "note" && out.title.trim().length > 0;
253
+
254
+ return out;
255
+ }
@@ -214,6 +214,8 @@ export interface ComposeLabels {
214
214
  showMore: string;
215
215
  showLess: string;
216
216
  newThread: string;
217
+ replyTitle: string;
218
+ editTitle: string;
217
219
  slashHint: string;
218
220
  collectionFormLabels: CollectionFormLabels;
219
221
  }
@@ -1245,6 +1245,7 @@ export class JantCollectionsManager extends LitElement {
1245
1245
  (e.currentTarget as HTMLInputElement).value,
1246
1246
  )}
1247
1247
  @keydown=${(e: globalThis.KeyboardEvent) => {
1248
+ if (e.isComposing || e.keyCode === 229) return;
1248
1249
  const target = e.currentTarget as HTMLInputElement;
1249
1250
  if (e.key === "Enter") {
1250
1251
  e.preventDefault();
@@ -102,6 +102,7 @@ export class JantCollectionForm extends LitElement {
102
102
  connectedCallback() {
103
103
  super.connectedCallback();
104
104
  this.#boundKeydown = (e: KeyboardEvent) => {
105
+ if (e.isComposing || e.keyCode === 229) return;
105
106
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
106
107
  e.preventDefault();
107
108
  void this.#handleSubmit(e);
@@ -386,6 +386,10 @@ export class JantCommandPalette extends LitElement {
386
386
  };
387
387
 
388
388
  #handleKeydown = (event: globalThis.KeyboardEvent) => {
389
+ // Let IME consume keys during composition — Enter commits the candidate,
390
+ // Escape dismisses the candidate popup, arrows pick candidates.
391
+ if (event.isComposing || event.keyCode === 229) return;
392
+
389
393
  const items = this.#displayItems;
390
394
 
391
395
  if (event.key === "ArrowDown") {