@jant/core 0.6.14 → 0.6.15
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/import-site.js +81 -13
- package/bin/commands/migrate.js +55 -0
- package/bin/commands/posts/rebuild-html.js +184 -0
- package/bin/commands/site/snapshot/import.js +14 -10
- package/bin/lib/d1-query.js +23 -5
- package/bin/lib/migration-runner.js +29 -7
- package/bin/lib/node-database.js +32 -0
- package/bin/lib/site-snapshot.js +347 -46
- package/bin/lib/sql-export.js +1 -1
- package/bin/lib/thread-collection-migration.js +326 -0
- package/dist/app-BxCOR3Uc.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-B_eGKN5p.css +2 -0
- package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
- package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
- package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/node.js +5 -5
- package/package.json +11 -10
- package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
- package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
- package/src/__tests__/export-hugo-build.test.ts +63 -4
- package/src/__tests__/export-import-roundtrip.test.ts +6 -4
- package/src/__tests__/export-service.test.ts +142 -9
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +2 -0
- package/src/__tests__/import-site-command.test.ts +65 -0
- package/src/__tests__/vite-config.test.ts +45 -0
- package/src/app.tsx +16 -3
- package/src/client/__tests__/collection-created-notice.test.ts +36 -0
- package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
- package/src/client/__tests__/collection-page-actions.test.ts +71 -2
- package/src/client/__tests__/compose-bridge.test.ts +64 -0
- package/src/client/__tests__/feed-video-player.test.ts +16 -1
- package/src/client/__tests__/footnote-rail.test.ts +472 -0
- package/src/client/__tests__/hydrate-partial.test.ts +3 -0
- package/src/client/__tests__/toast.test.ts +18 -1
- package/src/client/collection-created-notice.ts +66 -0
- package/src/client/collection-form-bridge.ts +17 -0
- package/src/client/collection-navigation.ts +44 -0
- package/src/client/collection-page-actions.ts +57 -1
- package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
- package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
- package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
- package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
- package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-manager-types.ts +9 -3
- package/src/client/components/compose-types.ts +8 -0
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +2 -1
- package/src/client/components/jant-compose-dialog.ts +85 -21
- package/src/client/components/jant-compose-editor.ts +13 -7
- package/src/client/components/jant-compose-fullscreen.ts +24 -4
- package/src/client/components/jant-config-editor.ts +722 -0
- package/src/client/components/jant-media-lightbox.ts +100 -18
- package/src/client/components/jant-nav-manager.ts +1283 -54
- package/src/client/components/jant-post-form.ts +3 -3
- package/src/client/components/jant-post-menu.ts +86 -74
- package/src/client/components/nav-manager-types.ts +51 -2
- package/src/client/components/post-form-types.ts +1 -1
- package/src/client/compose-bridge.ts +4 -1
- package/src/client/feed-video-player.ts +62 -2
- package/src/client/footnote-rail.ts +478 -0
- package/src/client/hydrate-partial.ts +2 -0
- package/src/client/media-lightbox-events.ts +63 -0
- package/src/client/site-header-fragment.ts +38 -0
- package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
- package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
- package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
- package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
- package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
- package/src/client/tiptap/bubble-menu.ts +88 -10
- package/src/client/tiptap/continuous-lists.ts +102 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +14 -5
- package/src/client/tiptap/markdown-clipboard.ts +608 -11
- package/src/client/tiptap/slash-commands.ts +8 -1
- package/src/client/tiptap/structural-keymap.ts +211 -0
- package/src/client/tiptap/tab-indent.ts +8 -22
- package/src/client/tiptap/table-control-labels.ts +30 -0
- package/src/client/tiptap/table-controls.ts +537 -0
- package/src/client/tiptap/table-size-picker.ts +254 -0
- package/src/client/toast.ts +8 -6
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -0
- package/src/db/__tests__/d1-query.test.ts +43 -1
- package/src/db/__tests__/migration-runner.test.ts +63 -0
- package/src/db/__tests__/migrations.test.ts +469 -0
- package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
- package/src/db/migrations/0027_old_blue_blade.sql +39 -0
- package/src/db/migrations/0028_superb_post.sql +2 -0
- package/src/db/migrations/0029_boring_magma.sql +1 -0
- package/src/db/migrations/0030_typical_vivisector.sql +50 -0
- package/src/db/migrations/meta/0027_snapshot.json +2511 -0
- package/src/db/migrations/meta/0028_snapshot.json +2511 -0
- package/src/db/migrations/meta/0029_snapshot.json +2519 -0
- package/src/db/migrations/meta/0030_snapshot.json +2541 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
- package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
- package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
- package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
- package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
- package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +28 -20
- package/src/db/rehearsal-fixtures/demo-current.json +39 -4
- package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
- package/src/db/schema.ts +28 -20
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +133 -30
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +128 -25
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +128 -25
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +513 -10
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +515 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +515 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/api-settings.test.ts +299 -0
- package/src/lib/__tests__/constants.test.ts +5 -0
- package/src/lib/__tests__/feed-policy.test.ts +25 -0
- package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
- package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
- package/src/lib/__tests__/markdown.test.ts +8 -6
- package/src/lib/__tests__/navigation.test.ts +59 -2
- package/src/lib/__tests__/post-body-html.test.ts +176 -0
- package/src/lib/__tests__/post-display.test.ts +64 -0
- package/src/lib/__tests__/resolve-config.test.ts +113 -1
- package/src/lib/__tests__/summary.test.ts +82 -0
- package/src/lib/__tests__/timeline.test.ts +109 -70
- package/src/lib/__tests__/timezones.test.ts +31 -3
- package/src/lib/__tests__/tiptap-render.test.ts +268 -9
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
- package/src/lib/__tests__/view.test.ts +53 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
- package/src/lib/api-settings.ts +239 -12
- package/src/lib/collection-sort.ts +7 -5
- package/src/lib/constants.ts +2 -0
- package/src/lib/feed-path.ts +25 -0
- package/src/lib/feed-policy.ts +64 -0
- package/src/lib/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +10 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/markdown-manager.ts +188 -5
- package/src/lib/markdown.ts +10 -3
- package/src/lib/navigation.ts +16 -3
- package/src/lib/post-body-html.ts +131 -0
- package/src/lib/post-display.ts +7 -1
- package/src/lib/render.tsx +9 -2
- package/src/lib/resolve-config.ts +100 -16
- package/src/lib/schemas.ts +133 -1
- package/src/lib/settings-paths.ts +5 -0
- package/src/lib/site-skill.ts +78 -0
- package/src/lib/summary.ts +16 -5
- package/src/lib/timeline.ts +98 -122
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +355 -81
- package/src/lib/view.ts +30 -20
- package/src/lib/worker-response-cache.ts +11 -18
- package/src/middleware/__tests__/public-content-access.test.ts +91 -0
- package/src/middleware/config.ts +2 -2
- package/src/middleware/public-content-access.ts +87 -0
- package/src/node/__tests__/cli-export.test.ts +14 -0
- package/src/node/__tests__/cli-migrate.test.ts +6 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
- package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
- package/src/node/index.ts +2 -1
- package/src/preset.css +16 -2
- package/src/routes/__tests__/compose.test.ts +51 -0
- package/src/routes/__tests__/site-skill.test.ts +112 -0
- package/src/routes/api/__tests__/collections.test.ts +91 -19
- package/src/routes/api/__tests__/mcp.test.ts +65 -1
- package/src/routes/api/__tests__/nav-items.test.ts +91 -0
- package/src/routes/api/__tests__/public-access.test.ts +81 -0
- package/src/routes/api/__tests__/settings.test.ts +217 -0
- package/src/routes/api/collections.ts +22 -26
- package/src/routes/api/export.ts +2 -0
- package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
- package/src/routes/api/internal/post-body-html.ts +43 -0
- package/src/routes/api/internal/sites.ts +24 -0
- package/src/routes/api/nav-items.ts +24 -1
- package/src/routes/api/posts.ts +2 -2
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +208 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +41 -24
- package/src/routes/api/search.ts +3 -0
- package/src/routes/api/settings.ts +33 -0
- package/src/routes/auth/__tests__/setup.test.ts +7 -4
- package/src/routes/compose.tsx +2 -1
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
- package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
- package/src/routes/pages/__tests__/collections.test.ts +9 -9
- package/src/routes/pages/__tests__/preview.test.ts +174 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +27 -7
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +83 -11
- package/src/routes/site-skill.ts +18 -0
- package/src/services/__tests__/auth.test.ts +7 -5
- package/src/services/__tests__/collection.test.ts +159 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +109 -30
- package/src/services/__tests__/post.test.ts +567 -5
- package/src/services/__tests__/search.test.ts +48 -1
- package/src/services/__tests__/settings.test.ts +8 -0
- package/src/services/__tests__/site-admin.test.ts +121 -1
- package/src/services/auth.ts +2 -2
- package/src/services/bootstrap.ts +3 -2
- package/src/services/collection.ts +226 -167
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +46 -25
- package/src/services/export-theme/layouts/_default/list.html +18 -4
- package/src/services/export-theme/layouts/_default/rss.xml +8 -12
- package/src/services/export-theme/layouts/featured/list.html +5 -4
- package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
- package/src/services/export-theme/styles/main.css +30 -2
- package/src/services/export.ts +91 -80
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/post.ts +874 -271
- package/src/services/search.ts +8 -2
- package/src/services/settings.ts +2 -10
- package/src/services/site-admin.ts +63 -43
- package/src/style-cjk-jp.css +1 -1
- package/src/style-cjk-kr.css +1 -1
- package/src/style-cjk-tc.css +1 -1
- package/src/style-cjk.css +1 -1
- package/src/styles/components.css +540 -0
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +14 -9
- package/src/styles/ui.css +829 -29
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +243 -12
- package/src/types/constants.ts +22 -1
- package/src/types/entities.ts +9 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +8 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +16 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +92 -2
- package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
- package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
- package/src/ui/dash/settings/GeneralContent.tsx +15 -5
- package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
- package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
- package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
- package/src/ui/feed/LinkCard.tsx +2 -12
- package/src/ui/feed/NoteCard.tsx +10 -20
- package/src/ui/feed/QuoteCard.tsx +2 -12
- package/src/ui/feed/ThreadPreview.tsx +26 -34
- package/src/ui/feed/TimelineFeed.tsx +2 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
- package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
- package/src/ui/feed/thread-preview-state.ts +5 -7
- package/src/ui/font-themes.ts +74 -26
- package/src/ui/layouts/BaseLayout.tsx +22 -8
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
- package/src/ui/pages/CollectionPage.tsx +101 -22
- package/src/ui/pages/CollectionsPage.tsx +3 -1
- package/src/ui/pages/PostPage.tsx +39 -12
- package/src/ui/pages/SearchPage.tsx +6 -14
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
- package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -9
- package/src/ui/shared/CollectionsManager.tsx +17 -7
- package/src/ui/shared/DraftPreviewBar.tsx +33 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +3 -1
- package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
- package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
- package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
- package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
- package/src/ui/shared/collection-management-labels.ts +33 -2
- package/src/ui/shared/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +46 -0
- package/dist/app-DZDlmh7C.js +0 -6
- package/dist/client/_assets/client-Cevfpm8H.css +0 -2
- package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
- package/src/client/tiptap/wrapping-input-rules.ts +0 -102
|
@@ -13,9 +13,10 @@ import type { PasteMediaOptions } from "./paste-media.js";
|
|
|
13
13
|
import { BubbleMenu } from "./bubble-menu.js";
|
|
14
14
|
import { LinkToolbar } from "./link-toolbar.js";
|
|
15
15
|
import { ExitableMarks } from "./exitable-marks.js";
|
|
16
|
-
import {
|
|
16
|
+
import { CodeBlockIndent } from "./tab-indent.js";
|
|
17
|
+
import { StructuralKeymap } from "./structural-keymap.js";
|
|
18
|
+
import { ContinuousLists } from "./continuous-lists.js";
|
|
17
19
|
import { LinkInputRules } from "./link-input-rules.js";
|
|
18
|
-
import { WrappingInputRules } from "./wrapping-input-rules.js";
|
|
19
20
|
import { InsertParagraphAround } from "./insert-paragraph-around.js";
|
|
20
21
|
import { Footnotes } from "./footnotes.js";
|
|
21
22
|
import type { FormattingToolbarMode } from "./toolbar-mode.js";
|
|
@@ -28,6 +29,8 @@ import { RehostImages } from "./rehost-images.js";
|
|
|
28
29
|
import type { RehostImagesOptions } from "./rehost-images.js";
|
|
29
30
|
import { MarkdownClipboard } from "./markdown-clipboard.js";
|
|
30
31
|
import { ImageInputRules } from "./image-input-rules.js";
|
|
32
|
+
import { TableControls } from "./table-controls.js";
|
|
33
|
+
import type { TableControlLabels } from "./table-control-labels.js";
|
|
31
34
|
import {
|
|
32
35
|
MARKDOWN_MARKED_OPTIONS,
|
|
33
36
|
createMarkdownContentExtensions,
|
|
@@ -39,6 +42,7 @@ export interface EditorExtensionOptions {
|
|
|
39
42
|
pasteMedia?: PasteMediaOptions;
|
|
40
43
|
rehostImages?: RehostImagesOptions;
|
|
41
44
|
imageNodeLabels?: Partial<ImageNodeLabels>;
|
|
45
|
+
tableControlLabels?: TableControlLabels;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -83,10 +87,11 @@ export function createSettingsEditorExtensions(
|
|
|
83
87
|
}),
|
|
84
88
|
ReclaimModEnter,
|
|
85
89
|
LinkInputRules,
|
|
86
|
-
WrappingInputRules,
|
|
87
90
|
MarkdownClipboard,
|
|
88
91
|
ExitableMarks,
|
|
89
92
|
InsertParagraphAround,
|
|
93
|
+
StructuralKeymap,
|
|
94
|
+
ContinuousLists,
|
|
90
95
|
BubbleMenu.configure({
|
|
91
96
|
toolbarMode: "compose",
|
|
92
97
|
}),
|
|
@@ -116,9 +121,11 @@ export function createEditorExtensions(
|
|
|
116
121
|
Footnotes,
|
|
117
122
|
ImageInputRules,
|
|
118
123
|
LinkInputRules,
|
|
119
|
-
WrappingInputRules,
|
|
120
124
|
MarkdownClipboard,
|
|
121
125
|
SlashCommands,
|
|
126
|
+
...(options.tableControlLabels
|
|
127
|
+
? [TableControls.configure({ labels: options.tableControlLabels })]
|
|
128
|
+
: []),
|
|
122
129
|
EmbedPaste,
|
|
123
130
|
PasteMedia.configure(options.pasteMedia ?? {}),
|
|
124
131
|
RehostImages.configure(options.rehostImages ?? {}),
|
|
@@ -128,6 +135,8 @@ export function createEditorExtensions(
|
|
|
128
135
|
LinkToolbar,
|
|
129
136
|
ExitableMarks,
|
|
130
137
|
InsertParagraphAround,
|
|
131
|
-
|
|
138
|
+
StructuralKeymap,
|
|
139
|
+
ContinuousLists,
|
|
140
|
+
CodeBlockIndent,
|
|
132
141
|
];
|
|
133
142
|
}
|
|
@@ -1,7 +1,38 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
createNodeFromContent,
|
|
3
|
+
Extension,
|
|
4
|
+
getTextBetween,
|
|
5
|
+
getTextSerializersFromSchema,
|
|
6
|
+
type Editor,
|
|
7
|
+
} from "@tiptap/core";
|
|
8
|
+
import {
|
|
9
|
+
Fragment,
|
|
10
|
+
Slice,
|
|
11
|
+
type Node as ProseMirrorNode,
|
|
12
|
+
} from "@tiptap/pm/model";
|
|
13
|
+
import { AllSelection, Plugin } from "@tiptap/pm/state";
|
|
14
|
+
import {
|
|
15
|
+
getFootnoteLabelKey,
|
|
16
|
+
getFootnoteReferenceText,
|
|
17
|
+
normalizeFootnoteLabel,
|
|
18
|
+
} from "../../lib/footnotes.js";
|
|
19
|
+
import {
|
|
20
|
+
normalizeMarkdownDocument,
|
|
21
|
+
serializeMarkdownDocument,
|
|
22
|
+
} from "../../lib/markdown-manager.js";
|
|
23
|
+
|
|
24
|
+
const OBSIDIAN_CLIPBOARD_MARKER = /<!--\s*obsidian\s*-->/i;
|
|
25
|
+
const FOOTNOTE_CONTAINER_SELECTOR =
|
|
26
|
+
'section.footnotes, [data-footnotes], [role~="doc-endnotes"]';
|
|
27
|
+
const FOOTNOTE_BACKLINK_SELECTOR =
|
|
28
|
+
'a.footnote-backref, a.footnote-back, a[data-footnote-backref], a[role~="doc-backlink"]';
|
|
29
|
+
|
|
30
|
+
interface HtmlFootnoteDefinition {
|
|
31
|
+
container: globalThis.Element | null;
|
|
32
|
+
element: globalThis.Element;
|
|
33
|
+
label: string;
|
|
34
|
+
targetId: string;
|
|
35
|
+
}
|
|
5
36
|
|
|
6
37
|
function toFragment(
|
|
7
38
|
content: ReturnType<typeof createNodeFromContent>,
|
|
@@ -9,6 +40,52 @@ function toFragment(
|
|
|
9
40
|
return content instanceof Fragment ? content : content.content;
|
|
10
41
|
}
|
|
11
42
|
|
|
43
|
+
function selectionCoversDocument(editor: Editor): boolean {
|
|
44
|
+
const { doc, selection } = editor.state;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
selection instanceof AllSelection ||
|
|
48
|
+
(selection.from === 0 && selection.to === doc.content.size)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function serializeReadableSelection(editor: Editor): string {
|
|
53
|
+
const { doc, schema, selection } = editor.state;
|
|
54
|
+
const textSerializers = getTextSerializersFromSchema(schema);
|
|
55
|
+
const sortedRanges = [...selection.ranges].sort(
|
|
56
|
+
(left, right) => left.$from.pos - right.$from.pos,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return sortedRanges
|
|
60
|
+
.map(({ $from, $to }) =>
|
|
61
|
+
getTextBetween(
|
|
62
|
+
doc,
|
|
63
|
+
{ from: $from.pos, to: $to.pos },
|
|
64
|
+
{ textSerializers },
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
.join("\n\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Serializes clipboard plain text without changing the editor document.
|
|
72
|
+
*
|
|
73
|
+
* A complete document selection expresses an export-like intent, so it uses
|
|
74
|
+
* Jant's canonical Markdown contract. Partial selections retain Tiptap's
|
|
75
|
+
* readable plain-text behavior; rich HTML is serialized separately by
|
|
76
|
+
* ProseMirror and is unaffected by this hook.
|
|
77
|
+
*
|
|
78
|
+
* @param editor - Active Tiptap editor
|
|
79
|
+
* @returns Text to write to the clipboard's `text/plain` flavor
|
|
80
|
+
*/
|
|
81
|
+
function serializeClipboardText(editor: Editor): string {
|
|
82
|
+
if (selectionCoversDocument(editor)) {
|
|
83
|
+
return serializeMarkdownDocument(editor.getJSON()).trimEnd();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return serializeReadableSelection(editor);
|
|
87
|
+
}
|
|
88
|
+
|
|
12
89
|
/**
|
|
13
90
|
* Detects whether pasted HTML originates from a code editor (VS Code,
|
|
14
91
|
* JetBrains, etc.) rather than a rich-text source. These editors copy
|
|
@@ -43,24 +120,544 @@ export function isCodeEditorHtml(html: string): boolean {
|
|
|
43
120
|
return false;
|
|
44
121
|
}
|
|
45
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Detects the marker emitted by Obsidian 1.12+ for editor-authored HTML.
|
|
125
|
+
*
|
|
126
|
+
* Obsidian writes the original Markdown to `text/plain` and `text/markdown`,
|
|
127
|
+
* while this comment marks the rendered `text/html` flavor. The marker can be
|
|
128
|
+
* preceded by browser- or OS-inserted metadata, so detection is not anchored.
|
|
129
|
+
*
|
|
130
|
+
* @param html - Clipboard HTML
|
|
131
|
+
* @returns Whether the HTML carries Obsidian's editor clipboard marker
|
|
132
|
+
*/
|
|
133
|
+
export function isObsidianHtml(html: string): boolean {
|
|
134
|
+
return OBSIDIAN_CLIPBOARD_MARKER.test(html);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isBareObsidianSpacer(element: globalThis.Element): boolean {
|
|
138
|
+
return (
|
|
139
|
+
element.tagName === "P" &&
|
|
140
|
+
element.attributes.length === 0 &&
|
|
141
|
+
Array.from(element.childNodes).every(
|
|
142
|
+
(child) =>
|
|
143
|
+
child.nodeType === globalThis.Node.TEXT_NODE &&
|
|
144
|
+
/^[\t\n\f\r ]*$/.test(child.textContent ?? ""),
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isClipboardMetadataElement(element: globalThis.Element): boolean {
|
|
150
|
+
return element.matches("meta, link, style, title");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Removes bare top-level spacer paragraphs emitted by Obsidian's HTML flavor.
|
|
155
|
+
*
|
|
156
|
+
* Markdown blank lines separate blocks; they are not authored empty
|
|
157
|
+
* paragraphs. Obsidian's clipboard HTML can materialize those separators as
|
|
158
|
+
* bare `<p></p>` siblings, which ProseMirror would otherwise preserve. The
|
|
159
|
+
* cleanup is deliberately marker-gated and narrow: attributed, nested,
|
|
160
|
+
* media-bearing, `<br>`, non-breaking-space, and edge paragraphs are retained.
|
|
161
|
+
*
|
|
162
|
+
* @param html - Clipboard HTML fragment
|
|
163
|
+
* @returns Normalized HTML, or the original string when no artifact was found
|
|
164
|
+
*/
|
|
165
|
+
export function normalizeObsidianClipboardArtifacts(html: string): string {
|
|
166
|
+
if (
|
|
167
|
+
!html.trim() ||
|
|
168
|
+
!isObsidianHtml(html) ||
|
|
169
|
+
typeof document === "undefined"
|
|
170
|
+
) {
|
|
171
|
+
return html;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const template = document.createElement("template");
|
|
175
|
+
template.innerHTML = html;
|
|
176
|
+
const elements = Array.from(template.content.children);
|
|
177
|
+
const spacers = new Set(elements.filter(isBareObsidianSpacer));
|
|
178
|
+
if (spacers.size === 0) return html;
|
|
179
|
+
|
|
180
|
+
const contentElements = elements.filter(
|
|
181
|
+
(element) => !spacers.has(element) && !isClipboardMetadataElement(element),
|
|
182
|
+
);
|
|
183
|
+
const contentIndexes = contentElements.map((element) =>
|
|
184
|
+
elements.indexOf(element),
|
|
185
|
+
);
|
|
186
|
+
let changed = false;
|
|
187
|
+
|
|
188
|
+
for (const spacer of spacers) {
|
|
189
|
+
const index = elements.indexOf(spacer);
|
|
190
|
+
const hasContentBefore = contentIndexes.some(
|
|
191
|
+
(contentIndex) => contentIndex < index,
|
|
192
|
+
);
|
|
193
|
+
const hasContentAfter = contentIndexes.some(
|
|
194
|
+
(contentIndex) => contentIndex > index,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (hasContentBefore && hasContentAfter) {
|
|
198
|
+
spacer.remove();
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return changed ? template.innerHTML : html;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getMarkdownClipboardText(clipboardData: DataTransfer): string | null {
|
|
207
|
+
const html = clipboardData.getData("text/html");
|
|
208
|
+
const markdown = clipboardData.getData("text/markdown");
|
|
209
|
+
if (markdown.trim()) return markdown;
|
|
210
|
+
|
|
211
|
+
const plain = clipboardData.getData("text/plain");
|
|
212
|
+
if (!plain.trim()) return null;
|
|
213
|
+
|
|
214
|
+
return isObsidianHtml(html) || isCodeEditorHtml(html) ? plain : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hasRole(element: globalThis.Element, role: string): boolean {
|
|
218
|
+
return (element.getAttribute("role") ?? "").split(/\s+/).includes(role);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getHashTarget(anchor: HTMLAnchorElement): string | null {
|
|
222
|
+
const href = anchor.getAttribute("href") ?? "";
|
|
223
|
+
if (!href.startsWith("#") || href.length === 1) return null;
|
|
224
|
+
|
|
225
|
+
const target = href.slice(1);
|
|
226
|
+
try {
|
|
227
|
+
return decodeURIComponent(target);
|
|
228
|
+
} catch {
|
|
229
|
+
return target;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getDefinitionTargetId(element: globalThis.Element): string {
|
|
234
|
+
return (
|
|
235
|
+
element.getAttribute("id") ?? element.getAttribute("data-footnote-id") ?? ""
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isNestedList(
|
|
240
|
+
list: globalThis.Element,
|
|
241
|
+
container: globalThis.Element,
|
|
242
|
+
): boolean {
|
|
243
|
+
let ancestor = list.parentElement;
|
|
244
|
+
|
|
245
|
+
while (ancestor && ancestor !== container) {
|
|
246
|
+
if (ancestor.tagName === "LI") return true;
|
|
247
|
+
ancestor = ancestor.parentElement;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collectContainerDefinitionElements(
|
|
254
|
+
container: globalThis.Element,
|
|
255
|
+
): globalThis.Element[] {
|
|
256
|
+
const definitions: globalThis.Element[] = [];
|
|
257
|
+
|
|
258
|
+
for (const list of container.querySelectorAll("ol, ul")) {
|
|
259
|
+
if (
|
|
260
|
+
list.closest(FOOTNOTE_CONTAINER_SELECTOR) !== container ||
|
|
261
|
+
isNestedList(list, container)
|
|
262
|
+
) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const child of list.children) {
|
|
267
|
+
if (child.tagName === "LI") definitions.push(child);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return definitions;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isFootnoteReferenceAnchor(anchor: HTMLAnchorElement): boolean {
|
|
275
|
+
if (anchor.matches(FOOTNOTE_BACKLINK_SELECTOR)) return false;
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
anchor.hasAttribute("data-footnote-ref") ||
|
|
279
|
+
anchor.classList.contains("footnote-ref") ||
|
|
280
|
+
hasRole(anchor, "doc-noteref") ||
|
|
281
|
+
anchor.closest("sup.footnote-ref") !== null
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function deriveLabelFromReference(anchor: HTMLAnchorElement): string {
|
|
286
|
+
const sourceLabel =
|
|
287
|
+
anchor.getAttribute("data-footref") ??
|
|
288
|
+
anchor.getAttribute("data-footnote-label");
|
|
289
|
+
if (normalizeFootnoteLabel(sourceLabel)) {
|
|
290
|
+
return normalizeFootnoteLabel(sourceLabel);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return normalizeFootnoteLabel(anchor.textContent)
|
|
294
|
+
.replace(/^\[\^?/, "")
|
|
295
|
+
.replace(/\]$/, "");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function deriveLabelFromTargetId(targetId: string): string {
|
|
299
|
+
const footnoteSuffix = targetId.match(/(?:^|[-_:])fn[-_:]?(.+)$/i)?.[1];
|
|
300
|
+
return normalizeFootnoteLabel(footnoteSuffix ?? targetId);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function allocateUniqueFootnoteLabel(
|
|
304
|
+
candidate: string,
|
|
305
|
+
fallbackIndex: number,
|
|
306
|
+
usedLabels: Set<string>,
|
|
307
|
+
): string {
|
|
308
|
+
const base = normalizeFootnoteLabel(candidate) || String(fallbackIndex + 1);
|
|
309
|
+
let label = base;
|
|
310
|
+
let suffix = 2;
|
|
311
|
+
|
|
312
|
+
while (usedLabels.has(getFootnoteLabelKey(label))) {
|
|
313
|
+
label = `${base}-${suffix}`;
|
|
314
|
+
suffix += 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
usedLabels.add(getFootnoteLabelKey(label));
|
|
318
|
+
return label;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function createCanonicalFootnoteReference(
|
|
322
|
+
ownerDocument: globalThis.Document,
|
|
323
|
+
label: string,
|
|
324
|
+
): HTMLElement {
|
|
325
|
+
const reference = ownerDocument.createElement("sup");
|
|
326
|
+
reference.setAttribute("data-footnote-reference", "");
|
|
327
|
+
reference.setAttribute("data-footnote-label", label);
|
|
328
|
+
reference.className = "tiptap-footnote-reference";
|
|
329
|
+
reference.textContent = getFootnoteReferenceText(label);
|
|
330
|
+
return reference;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function createCanonicalFootnoteDefinition(
|
|
334
|
+
definition: HtmlFootnoteDefinition,
|
|
335
|
+
): HTMLElement {
|
|
336
|
+
const ownerDocument = definition.element.ownerDocument;
|
|
337
|
+
const source = definition.element.cloneNode(true) as globalThis.Element;
|
|
338
|
+
|
|
339
|
+
for (const backlink of source.querySelectorAll(FOOTNOTE_BACKLINK_SELECTOR)) {
|
|
340
|
+
backlink.remove();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const wrapper of Array.from(
|
|
344
|
+
source.querySelectorAll("p, span"),
|
|
345
|
+
).reverse()) {
|
|
346
|
+
if (
|
|
347
|
+
!wrapper.textContent?.trim() &&
|
|
348
|
+
!wrapper.querySelector(
|
|
349
|
+
"img, video, audio, iframe, svg, math, br, hr, input, table",
|
|
350
|
+
)
|
|
351
|
+
) {
|
|
352
|
+
wrapper.remove();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const canonical = ownerDocument.createElement("div");
|
|
357
|
+
canonical.setAttribute("data-footnote-definition", "");
|
|
358
|
+
canonical.setAttribute("data-footnote-label", definition.label);
|
|
359
|
+
canonical.className = "tiptap-footnote-definition";
|
|
360
|
+
|
|
361
|
+
while (source.firstChild) {
|
|
362
|
+
canonical.appendChild(source.firstChild);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!canonical.hasChildNodes()) {
|
|
366
|
+
canonical.appendChild(ownerDocument.createElement("p"));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return canonical;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Normalizes common rendered-footnote HTML into Jant's structural editor DOM.
|
|
374
|
+
*
|
|
375
|
+
* Supported inputs include Obsidian/markdown-it (`.footnote-ref` and
|
|
376
|
+
* `.footnotes`), GitHub/unified (`data-footnote-*`), and DPUB-ARIA
|
|
377
|
+
* (`doc-noteref`, `doc-footnote`, and `doc-endnotes`). Conversion requires a
|
|
378
|
+
* recognized definition container or role, so ordinary superscript links and
|
|
379
|
+
* ordered lists are left untouched.
|
|
380
|
+
*
|
|
381
|
+
* @param html - Clipboard HTML fragment
|
|
382
|
+
* @returns The normalized fragment, or the original string when no footnotes
|
|
383
|
+
* were recognized
|
|
384
|
+
*/
|
|
385
|
+
export function normalizePastedFootnoteHtml(html: string): string {
|
|
386
|
+
if (!html.trim() || typeof document === "undefined") return html;
|
|
387
|
+
|
|
388
|
+
const template = document.createElement("template");
|
|
389
|
+
template.innerHTML = html;
|
|
390
|
+
const root = template.content;
|
|
391
|
+
const containers = Array.from(
|
|
392
|
+
root.querySelectorAll(FOOTNOTE_CONTAINER_SELECTOR),
|
|
393
|
+
).filter(
|
|
394
|
+
(container) =>
|
|
395
|
+
!container.parentElement?.closest(FOOTNOTE_CONTAINER_SELECTOR),
|
|
396
|
+
);
|
|
397
|
+
const containerDefinitions = new Map<
|
|
398
|
+
globalThis.Element,
|
|
399
|
+
globalThis.Element[]
|
|
400
|
+
>();
|
|
401
|
+
const rawDefinitions: Array<{
|
|
402
|
+
container: globalThis.Element | null;
|
|
403
|
+
element: globalThis.Element;
|
|
404
|
+
targetId: string;
|
|
405
|
+
}> = [];
|
|
406
|
+
|
|
407
|
+
for (const container of containers) {
|
|
408
|
+
const elements = collectContainerDefinitionElements(container);
|
|
409
|
+
if (elements.length === 0) continue;
|
|
410
|
+
|
|
411
|
+
containerDefinitions.set(container, elements);
|
|
412
|
+
for (const element of elements) {
|
|
413
|
+
rawDefinitions.push({
|
|
414
|
+
container,
|
|
415
|
+
element,
|
|
416
|
+
targetId: getDefinitionTargetId(element),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const element of root.querySelectorAll('[role~="doc-footnote"]')) {
|
|
422
|
+
if (element.closest(FOOTNOTE_CONTAINER_SELECTOR)) continue;
|
|
423
|
+
rawDefinitions.push({
|
|
424
|
+
container: null,
|
|
425
|
+
element,
|
|
426
|
+
targetId: getDefinitionTargetId(element),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (rawDefinitions.length === 0) return html;
|
|
431
|
+
|
|
432
|
+
const definitionsByTarget = new Map<
|
|
433
|
+
string,
|
|
434
|
+
(typeof rawDefinitions)[number]
|
|
435
|
+
>();
|
|
436
|
+
for (const definition of rawDefinitions) {
|
|
437
|
+
if (definition.targetId && !definitionsByTarget.has(definition.targetId)) {
|
|
438
|
+
definitionsByTarget.set(definition.targetId, definition);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const referencesByTarget = new Map<string, HTMLAnchorElement[]>();
|
|
443
|
+
for (const anchor of root.querySelectorAll<HTMLAnchorElement>("a[href]")) {
|
|
444
|
+
if (!isFootnoteReferenceAnchor(anchor)) continue;
|
|
445
|
+
const targetId = getHashTarget(anchor);
|
|
446
|
+
if (!targetId || !definitionsByTarget.has(targetId)) continue;
|
|
447
|
+
|
|
448
|
+
const references = referencesByTarget.get(targetId) ?? [];
|
|
449
|
+
references.push(anchor);
|
|
450
|
+
referencesByTarget.set(targetId, references);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const usedLabels = new Set<string>();
|
|
454
|
+
const definitions: HtmlFootnoteDefinition[] = rawDefinitions.map(
|
|
455
|
+
(definition, index) => {
|
|
456
|
+
const reference = definition.targetId
|
|
457
|
+
? referencesByTarget.get(definition.targetId)?.[0]
|
|
458
|
+
: undefined;
|
|
459
|
+
const candidate = reference
|
|
460
|
+
? deriveLabelFromReference(reference)
|
|
461
|
+
: deriveLabelFromTargetId(definition.targetId);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
...definition,
|
|
465
|
+
label: allocateUniqueFootnoteLabel(candidate, index, usedLabels),
|
|
466
|
+
};
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
const labelsByTarget = new Map(
|
|
470
|
+
definitions
|
|
471
|
+
.filter((definition) => definition.targetId)
|
|
472
|
+
.map((definition) => [definition.targetId, definition.label] as const),
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
let changed = false;
|
|
476
|
+
for (const [targetId, anchors] of referencesByTarget) {
|
|
477
|
+
const label = labelsByTarget.get(targetId);
|
|
478
|
+
if (!label) continue;
|
|
479
|
+
|
|
480
|
+
for (const anchor of anchors) {
|
|
481
|
+
const wrapper = anchor.closest("sup");
|
|
482
|
+
const replaceTarget = wrapper?.contains(anchor) ? wrapper : anchor;
|
|
483
|
+
if (!replaceTarget.parentNode) continue;
|
|
484
|
+
|
|
485
|
+
replaceTarget.replaceWith(
|
|
486
|
+
createCanonicalFootnoteReference(anchor.ownerDocument, label),
|
|
487
|
+
);
|
|
488
|
+
changed = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const normalizedByElement = new Map(
|
|
493
|
+
definitions.map(
|
|
494
|
+
(definition) =>
|
|
495
|
+
[
|
|
496
|
+
definition.element,
|
|
497
|
+
createCanonicalFootnoteDefinition(definition),
|
|
498
|
+
] as const,
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
for (const [container, elements] of containerDefinitions) {
|
|
503
|
+
if (!container.parentNode) continue;
|
|
504
|
+
|
|
505
|
+
const replacement = container.ownerDocument.createDocumentFragment();
|
|
506
|
+
for (const element of elements) {
|
|
507
|
+
const definition = normalizedByElement.get(element);
|
|
508
|
+
if (definition) replacement.appendChild(definition);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const separator = container.previousElementSibling;
|
|
512
|
+
if (separator?.matches("hr.footnotes-sep, hr[data-footnotes-separator]")) {
|
|
513
|
+
separator.remove();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
container.replaceWith(replacement);
|
|
517
|
+
changed = true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const definition of definitions) {
|
|
521
|
+
if (definition.container || !definition.element.parentNode) continue;
|
|
522
|
+
const normalized = normalizedByElement.get(definition.element);
|
|
523
|
+
if (!normalized) continue;
|
|
524
|
+
|
|
525
|
+
definition.element.replaceWith(normalized);
|
|
526
|
+
changed = true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return changed ? template.innerHTML : html;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function normalizePastedHtml(html: string): string {
|
|
533
|
+
return normalizePastedFootnoteHtml(normalizeObsidianClipboardArtifacts(html));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
interface NormalizedPasteNodes {
|
|
537
|
+
nodes: ProseMirrorNode[];
|
|
538
|
+
changed: boolean;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Split a paragraph where rich-text producers used consecutive `<br>` nodes
|
|
543
|
+
* to imitate paragraph spacing. A single hard break remains an intentional
|
|
544
|
+
* line break; a run of two or more becomes a semantic block boundary.
|
|
545
|
+
*/
|
|
546
|
+
function splitPastedParagraph(node: ProseMirrorNode): NormalizedPasteNodes {
|
|
547
|
+
const children: ProseMirrorNode[] = [];
|
|
548
|
+
node.forEach((child) => children.push(child));
|
|
549
|
+
|
|
550
|
+
const paragraphs: ProseMirrorNode[][] = [];
|
|
551
|
+
let current: ProseMirrorNode[] = [];
|
|
552
|
+
let changed = false;
|
|
553
|
+
|
|
554
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
555
|
+
const child = children[index];
|
|
556
|
+
if (child?.type.name !== "hardBreak") {
|
|
557
|
+
if (child) current.push(child);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let runEnd = index + 1;
|
|
562
|
+
while (children[runEnd]?.type.name === "hardBreak") runEnd += 1;
|
|
563
|
+
|
|
564
|
+
if (runEnd - index === 1) {
|
|
565
|
+
current.push(child);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
changed = true;
|
|
570
|
+
if (current.length > 0) paragraphs.push(current);
|
|
571
|
+
current = [];
|
|
572
|
+
index = runEnd - 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!changed) return { nodes: [node], changed: false };
|
|
576
|
+
if (current.length > 0) paragraphs.push(current);
|
|
577
|
+
|
|
578
|
+
// Keep a valid empty paragraph when the pasted block contained only visual
|
|
579
|
+
// spacing (for example `<p><br><br></p>`).
|
|
580
|
+
if (paragraphs.length === 0) paragraphs.push([]);
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
nodes: paragraphs.map((content) => node.copy(Fragment.fromArray(content))),
|
|
584
|
+
changed: true,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Normalize paragraph-like breaks inside a pasted rich-text tree while
|
|
590
|
+
* retaining the surrounding list, quote, table, and inline-mark structure.
|
|
591
|
+
*/
|
|
592
|
+
function normalizePastedNode(node: ProseMirrorNode): NormalizedPasteNodes {
|
|
593
|
+
if (node.type.name === "paragraph") return splitPastedParagraph(node);
|
|
594
|
+
if (node.isLeaf || node.childCount === 0) {
|
|
595
|
+
return { nodes: [node], changed: false };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const children: ProseMirrorNode[] = [];
|
|
599
|
+
let changed = false;
|
|
600
|
+
node.forEach((child) => {
|
|
601
|
+
const normalized = normalizePastedNode(child);
|
|
602
|
+
children.push(...normalized.nodes);
|
|
603
|
+
changed ||= normalized.changed;
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!changed) return { nodes: [node], changed: false };
|
|
607
|
+
|
|
608
|
+
const content = Fragment.fromArray(children);
|
|
609
|
+
if (!node.type.validContent(content)) {
|
|
610
|
+
return { nodes: [node], changed: false };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { nodes: [node.copy(content)], changed: true };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function normalizePastedSlice(slice: Slice, plain: boolean): Slice {
|
|
617
|
+
if (plain) return slice;
|
|
618
|
+
|
|
619
|
+
const nodes: ProseMirrorNode[] = [];
|
|
620
|
+
let changed = false;
|
|
621
|
+
slice.content.forEach((node) => {
|
|
622
|
+
const normalized = normalizePastedNode(node);
|
|
623
|
+
nodes.push(...normalized.nodes);
|
|
624
|
+
changed ||= normalized.changed;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
return changed
|
|
628
|
+
? new Slice(Fragment.fromArray(nodes), slice.openStart, slice.openEnd)
|
|
629
|
+
: slice;
|
|
630
|
+
}
|
|
631
|
+
|
|
46
632
|
export const MarkdownClipboard = Extension.create({
|
|
47
633
|
name: "markdownClipboard",
|
|
48
634
|
|
|
635
|
+
// Run before Tiptap's built-in plain-text serializer. Paste handling is also
|
|
636
|
+
// intentionally resolved before generic clipboard parsing.
|
|
637
|
+
priority: 1000,
|
|
638
|
+
|
|
49
639
|
addProseMirrorPlugins() {
|
|
50
640
|
return [
|
|
51
641
|
new Plugin({
|
|
52
642
|
props: {
|
|
643
|
+
clipboardTextSerializer: () => serializeClipboardText(this.editor),
|
|
644
|
+
|
|
645
|
+
transformPastedHTML: normalizePastedHtml,
|
|
646
|
+
transformPasted: (slice, _view, plain) =>
|
|
647
|
+
normalizePastedSlice(slice, plain),
|
|
648
|
+
|
|
53
649
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
650
|
+
* Prefer an explicit Markdown flavor. Marked Obsidian and detected
|
|
651
|
+
* code-editor HTML use their plain flavor as Markdown; other rich-text
|
|
652
|
+
* producers keep the normal HTML path.
|
|
57
653
|
*/
|
|
58
654
|
handlePaste: (view, event) => {
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
655
|
+
const clipboardData = event.clipboardData;
|
|
656
|
+
if (!clipboardData || typeof clipboardData.getData !== "function") {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
61
659
|
|
|
62
|
-
const text =
|
|
63
|
-
event.clipboardData?.getData("text/plain")?.trim() ?? "";
|
|
660
|
+
const text = getMarkdownClipboardText(clipboardData);
|
|
64
661
|
if (!text || !this.editor.markdown) return false;
|
|
65
662
|
|
|
66
663
|
const parsed = normalizeMarkdownDocument(
|