@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.
- package/bin/commands/uploads/cleanup.js +1 -0
- package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/json.test.ts +94 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-collection-directory.ts +1 -0
- package/src/client/components/jant-collection-form.ts +1 -0
- package/src/client/components/jant-command-palette.ts +4 -0
- package/src/client/components/jant-compose-dialog.ts +106 -44
- package/src/client/components/jant-compose-editor.ts +65 -11
- package/src/client/components/jant-compose-fullscreen.ts +3 -0
- package/src/client/components/jant-nav-manager.ts +4 -0
- package/src/client/components/jant-post-menu.ts +3 -0
- package/src/client/components/jant-repo-picker.ts +3 -0
- package/src/client/components/jant-settings-general.ts +3 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/json.ts +56 -2
- package/src/client/multipart-upload.ts +17 -7
- package/src/client/note-expand.ts +63 -0
- package/src/client/upload-session.ts +17 -9
- package/src/client.ts +1 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +77 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/site-admin.ts +121 -0
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-CL2PC1Fl.js +0 -6
- 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",
|
|
@@ -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
|
+
}
|
|
@@ -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") {
|