collavre 0.22.0 → 0.24.0
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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +42 -10
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { renderMarkdown, renderCommentMarkdown, highlightCodeBlocks } from "../markdown"
|
|
2
|
+
|
|
3
|
+
// The renderers run with marked's `breaks: true` so a single newline becomes a
|
|
4
|
+
// <br> (GitHub/Slack style), matching the canonical markdown_source which stores
|
|
5
|
+
// consecutive rich-editor lines one-per-line instead of separated by a blank line.
|
|
6
|
+
describe("renderMarkdown hard breaks", () => {
|
|
7
|
+
it("renders a single newline as <br> within one paragraph", () => {
|
|
8
|
+
const html = renderMarkdown("abc\ndef")
|
|
9
|
+
expect(html).toContain("<br")
|
|
10
|
+
expect((html.match(/<p>/g) || []).length).toBe(1)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("keeps a blank line as a paragraph break", () => {
|
|
14
|
+
const html = renderMarkdown("abc\n\ndef")
|
|
15
|
+
expect((html.match(/<p>/g) || []).length).toBe(2)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("renderCommentMarkdown hard breaks", () => {
|
|
20
|
+
it("renders a single newline in a comment as <br>", () => {
|
|
21
|
+
expect(renderCommentMarkdown("line1\nline2")).toContain("<br")
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Markdown-mode preview and chat/comment code blocks must use the SAME Prism
|
|
26
|
+
// engine + `lexical-token-*` classes as the editor and rendered creative view —
|
|
27
|
+
// not highlight.js's `hljs-*` — so a fenced block looks identical everywhere it
|
|
28
|
+
// is rendered. (highlight.js produces a different palette/tokenization, which is
|
|
29
|
+
// why these surfaces visibly diverged from the editor.)
|
|
30
|
+
describe("markdown renderers use the shared Prism token classes", () => {
|
|
31
|
+
it("renderMarkdown (preview) tokenizes a fenced ruby block with lexical-token classes", () => {
|
|
32
|
+
const html = renderMarkdown("```ruby\ndef foo\n true\nend\n```")
|
|
33
|
+
expect(html).toContain("lexical-token-")
|
|
34
|
+
expect(html).not.toContain("hljs-")
|
|
35
|
+
const keywords = (html.match(/lexical-token-keyword/g) || [])
|
|
36
|
+
expect(keywords.length).toBeGreaterThan(0) // def / end
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("renderCommentMarkdown tokenizes a fenced javascript block with lexical-token classes", () => {
|
|
40
|
+
const html = renderCommentMarkdown("```javascript\nconst x = true\n```")
|
|
41
|
+
expect(html).toContain("lexical-token-")
|
|
42
|
+
expect(html).not.toContain("hljs-")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("honors an explicit fence language instead of re-detecting it (matches the editor)", () => {
|
|
46
|
+
// ruby fence over JS-looking content: detection alone would pick javascript;
|
|
47
|
+
// the editor/view honor the explicit fence, so the preview must too.
|
|
48
|
+
const html = renderMarkdown("```ruby\nconst x = function() {}\n```")
|
|
49
|
+
expect(html).toContain('lang="ruby"')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Creative descriptions arrive as server-rendered `<pre lang="ruby"><code>raw</code></pre>`
|
|
54
|
+
// (commonmarker's inline highlighter is disabled). highlightCodeBlocks re-tokenizes
|
|
55
|
+
// them client-side with the SAME Prism + `lexical-token-*` classes the editor uses,
|
|
56
|
+
// so the rendered creative matches edit mode token-for-token.
|
|
57
|
+
describe("highlightCodeBlocks", () => {
|
|
58
|
+
it("tokenizes a plain server code block with lexical-token classes", () => {
|
|
59
|
+
const el = document.createElement("div")
|
|
60
|
+
el.innerHTML = '<pre lang="javascript"><code>const ok = true</code></pre>'
|
|
61
|
+
highlightCodeBlocks(el)
|
|
62
|
+
const code = el.querySelector("pre code")
|
|
63
|
+
expect(code.querySelector(".lexical-token-keyword")).toBeTruthy() // const
|
|
64
|
+
expect(code.querySelector(".lexical-token-boolean")).toBeTruthy() // true
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("honors an explicit ruby fence and tokenizes with the ruby grammar", () => {
|
|
68
|
+
// The shared code_languages module registers the ruby grammar on the same
|
|
69
|
+
// Prism instance the editor uses, so an explicit ```ruby fence is honored
|
|
70
|
+
// instead of falling back to JavaScript.
|
|
71
|
+
const el = document.createElement("div")
|
|
72
|
+
el.innerHTML = '<pre lang="ruby"><code>def foo\n nil\nend</code></pre>'
|
|
73
|
+
highlightCodeBlocks(el)
|
|
74
|
+
const code = el.querySelector("pre code")
|
|
75
|
+
// `def`/`end` are Ruby keywords; JavaScript tokenization would not mark them.
|
|
76
|
+
const keywords = Array.from(code.querySelectorAll(".lexical-token-keyword")).map((s) => s.textContent)
|
|
77
|
+
expect(keywords).toContain("def")
|
|
78
|
+
expect(keywords).toContain("end")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const RUBY_SRC =
|
|
82
|
+
'# frozen_string_literal: true\n' +
|
|
83
|
+
'module CollavreCompat\n' +
|
|
84
|
+
' module_function\n' +
|
|
85
|
+
' def call(receiver, method_name, *args, **kwargs)\n' +
|
|
86
|
+
' receiver.public_send(method_name, *args, **kwargs)\n' +
|
|
87
|
+
' end\n' +
|
|
88
|
+
'end'
|
|
89
|
+
|
|
90
|
+
it("honors an explicit javascript label instead of re-detecting it (matches the editor)", () => {
|
|
91
|
+
// The user can deliberately choose ```javascript. The view must honor it
|
|
92
|
+
// verbatim — same as the editor, which honors any import-resolved language —
|
|
93
|
+
// so edit and view never disagree. (Previously the view re-detected this to
|
|
94
|
+
// Ruby, which contradicted an explicit choice.)
|
|
95
|
+
const el = document.createElement("div")
|
|
96
|
+
el.innerHTML = '<pre lang="javascript"><code>' + RUBY_SRC + '</code></pre>'
|
|
97
|
+
highlightCodeBlocks(el)
|
|
98
|
+
const code = el.querySelector("pre code")
|
|
99
|
+
const keywords = Array.from(code.querySelectorAll(".lexical-token-keyword")).map((s) => s.textContent)
|
|
100
|
+
// Ruby's `def`/`end` are NOT JavaScript keywords — proof it tokenized as JS,
|
|
101
|
+
// i.e. the explicit label was honored rather than overridden to Ruby.
|
|
102
|
+
expect(keywords).not.toContain("def")
|
|
103
|
+
expect(keywords).not.toContain("end")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("content-detects an UNLABELED Ruby block and tokenizes as ruby", () => {
|
|
107
|
+
// No fence language → fall back to content detection so the block is colored.
|
|
108
|
+
const el = document.createElement("div")
|
|
109
|
+
el.innerHTML = '<pre><code>' + RUBY_SRC + '</code></pre>'
|
|
110
|
+
highlightCodeBlocks(el)
|
|
111
|
+
const code = el.querySelector("pre code")
|
|
112
|
+
const keywords = Array.from(code.querySelectorAll(".lexical-token-keyword")).map((s) => s.textContent)
|
|
113
|
+
expect(keywords).toContain("def")
|
|
114
|
+
expect(keywords).toContain("end")
|
|
115
|
+
expect(code.querySelector(".lexical-token-comment")).toBeTruthy()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("re-highlights legacy blocks from textContent, dropping baked inline styles", () => {
|
|
119
|
+
const el = document.createElement("div")
|
|
120
|
+
// Simulates an old description whose stored HTML still carries syntect's
|
|
121
|
+
// inline-styled spans and a dark <pre> background.
|
|
122
|
+
el.innerHTML = '<pre lang="javascript" style="background-color:#2b303b;"><code>' +
|
|
123
|
+
'<span style="color:#96b5b4;">const </span><span style="color:#a3be8c;">x = true</span></code></pre>'
|
|
124
|
+
highlightCodeBlocks(el)
|
|
125
|
+
const pre = el.querySelector("pre")
|
|
126
|
+
const code = el.querySelector("pre code")
|
|
127
|
+
expect(pre.getAttribute("style")).toBeNull()
|
|
128
|
+
expect(code.innerHTML).not.toContain("#2b303b")
|
|
129
|
+
expect(code.textContent).toBe("const x = true")
|
|
130
|
+
expect(code.querySelector(".lexical-token-keyword")).toBeTruthy()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("does not reinterpret code text as live HTML (no XSS via innerHTML)", () => {
|
|
134
|
+
const el = document.createElement("div")
|
|
135
|
+
// The decoded source text contains an XSS payload; after re-highlighting it
|
|
136
|
+
// must remain inert text, never a live <img> element.
|
|
137
|
+
el.innerHTML = '<pre lang="text"><code><img src=x onerror=alert(1)></code></pre>'
|
|
138
|
+
highlightCodeBlocks(el)
|
|
139
|
+
const code = el.querySelector("pre code")
|
|
140
|
+
// The payload survives only as inert text, never as a live element.
|
|
141
|
+
expect(code.querySelector("img")).toBeNull()
|
|
142
|
+
expect(code.textContent).toBe("<img src=x onerror=alert(1)>")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("is idempotent — already-highlighted blocks are skipped", () => {
|
|
146
|
+
const el = document.createElement("div")
|
|
147
|
+
el.innerHTML = '<pre lang="javascript"><code>const x = true</code></pre>'
|
|
148
|
+
highlightCodeBlocks(el)
|
|
149
|
+
const first = el.querySelector("pre code").innerHTML
|
|
150
|
+
highlightCodeBlocks(el)
|
|
151
|
+
expect(el.querySelector("pre code").innerHTML).toBe(first)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { sanitizeDescriptionHtml } from '../sanitize_description'
|
|
2
|
+
|
|
3
|
+
describe('sanitizeDescriptionHtml', () => {
|
|
4
|
+
test('keeps a YouTube embed iframe the server generated', () => {
|
|
5
|
+
const html =
|
|
6
|
+
'<p><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" ' +
|
|
7
|
+
'title="YouTube video player" frameborder="0" ' +
|
|
8
|
+
'allow="autoplay; encrypted-media" allowfullscreen></iframe></p>'
|
|
9
|
+
const out = sanitizeDescriptionHtml(html)
|
|
10
|
+
expect(out).toContain('<iframe')
|
|
11
|
+
expect(out).toContain('youtube.com/embed/dQw4w9WgXcQ')
|
|
12
|
+
expect(out).toContain('allowfullscreen')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('keeps a youtube-nocookie embed iframe', () => {
|
|
16
|
+
const html =
|
|
17
|
+
'<iframe src="https://www.youtube-nocookie.com/embed/abc123"></iframe>'
|
|
18
|
+
expect(sanitizeDescriptionHtml(html)).toContain('<iframe')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('strips a non-YouTube iframe (XSS protection)', () => {
|
|
22
|
+
const html = '<p><iframe src="https://evil.example.com/x"></iframe></p>'
|
|
23
|
+
const out = sanitizeDescriptionHtml(html)
|
|
24
|
+
expect(out).not.toContain('<iframe')
|
|
25
|
+
expect(out).not.toContain('evil.example.com')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('strips an iframe pointing at the YouTube watch page (not /embed/)', () => {
|
|
29
|
+
const html =
|
|
30
|
+
'<iframe src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"></iframe>'
|
|
31
|
+
expect(sanitizeDescriptionHtml(html)).not.toContain('<iframe')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('strips an iframe with a javascript: src', () => {
|
|
35
|
+
const html = '<iframe src="javascript:alert(1)"></iframe>'
|
|
36
|
+
expect(sanitizeDescriptionHtml(html)).not.toContain('<iframe')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('strips script tags', () => {
|
|
40
|
+
const out = sanitizeDescriptionHtml('<p>hi</p><script>alert(1)</script>')
|
|
41
|
+
expect(out).toContain('hi')
|
|
42
|
+
expect(out).not.toContain('<script')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('preserves ordinary description markup', () => {
|
|
46
|
+
const html = '<p>Hello <strong>world</strong></p>'
|
|
47
|
+
expect(sanitizeDescriptionHtml(html)).toBe(html)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('treats null/undefined as empty string', () => {
|
|
51
|
+
expect(sanitizeDescriptionHtml(null)).toBe('')
|
|
52
|
+
expect(sanitizeDescriptionHtml(undefined)).toBe('')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('global iframe hook does not leak into the markdown/comment path', () => {
|
|
57
|
+
test('renderMarkdown still strips YouTube iframes when the hook is loaded', async () => {
|
|
58
|
+
// Importing the sanitizer registers a global DOMPurify uponSanitizeElement
|
|
59
|
+
// hook. The comment/markdown sanitizer does not allow <iframe>, so even a
|
|
60
|
+
// trusted YouTube iframe must still be stripped there.
|
|
61
|
+
await import('../sanitize_description')
|
|
62
|
+
const { renderMarkdown } = await import('../markdown')
|
|
63
|
+
const out = renderMarkdown(
|
|
64
|
+
'<p><iframe src="https://www.youtube.com/embed/abc"></iframe></p>'
|
|
65
|
+
)
|
|
66
|
+
expect(out).not.toContain('<iframe')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { addTableDownloadButtons, addCreativeTableDownloadButtons } from "../table_download"
|
|
2
|
+
|
|
3
|
+
// addTableDownloadButtons is the single shared utility used to attach CSV/Excel
|
|
4
|
+
// download toolbars to rendered markdown tables. It must be container-agnostic so
|
|
5
|
+
// chat comments (.comment-content) and creative descriptions (.creative-content)
|
|
6
|
+
// get an identical toolbar — that is what "unify creative tables with chat
|
|
7
|
+
// tables" relies on.
|
|
8
|
+
function makeTable() {
|
|
9
|
+
const el = document.createElement("div")
|
|
10
|
+
el.innerHTML =
|
|
11
|
+
"<table><thead><tr><th>이름</th><th>점수</th></tr></thead>" +
|
|
12
|
+
"<tbody><tr><td>철수</td><td>90</td></tr></tbody></table>"
|
|
13
|
+
return el
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("addTableDownloadButtons (shared comment + creative path)", () => {
|
|
17
|
+
it("wraps a table in any container and adds CSV + Excel buttons", () => {
|
|
18
|
+
const container = makeTable()
|
|
19
|
+
container.className = "creative-content"
|
|
20
|
+
|
|
21
|
+
addTableDownloadButtons(container)
|
|
22
|
+
|
|
23
|
+
const wrapper = container.querySelector(".table-download-wrapper")
|
|
24
|
+
expect(wrapper).not.toBeNull()
|
|
25
|
+
const buttons = wrapper.querySelectorAll(".table-download-btn")
|
|
26
|
+
expect(buttons.length).toBe(2)
|
|
27
|
+
const labels = Array.from(buttons).map((b) => b.textContent)
|
|
28
|
+
expect(labels.some((l) => l.includes("CSV"))).toBe(true)
|
|
29
|
+
expect(labels.some((l) => l.includes("Excel"))).toBe(true)
|
|
30
|
+
// The table itself is moved inside the wrapper (toolbar precedes it).
|
|
31
|
+
expect(wrapper.querySelector("table")).not.toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("is idempotent — repeated calls (Lit re-renders) do not double-wrap", () => {
|
|
35
|
+
const container = makeTable()
|
|
36
|
+
container.className = "creative-content"
|
|
37
|
+
|
|
38
|
+
addTableDownloadButtons(container)
|
|
39
|
+
addTableDownloadButtons(container)
|
|
40
|
+
addTableDownloadButtons(container)
|
|
41
|
+
|
|
42
|
+
expect(container.querySelectorAll(".table-download-wrapper").length).toBe(1)
|
|
43
|
+
expect(container.querySelectorAll(".table-download-btn").length).toBe(2)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("does nothing when there is no table", () => {
|
|
47
|
+
const container = document.createElement("div")
|
|
48
|
+
container.className = "creative-content"
|
|
49
|
+
container.innerHTML = "<p>no table here</p>"
|
|
50
|
+
|
|
51
|
+
addTableDownloadButtons(container)
|
|
52
|
+
|
|
53
|
+
expect(container.querySelector(".table-download-wrapper")).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("addCreativeTableDownloadButtons (row-scoped)", () => {
|
|
58
|
+
const tableHtml =
|
|
59
|
+
"<table><thead><tr><th>이름</th></tr></thead><tbody><tr><td>철수</td></tr></tbody></table>"
|
|
60
|
+
|
|
61
|
+
// Mirrors the live DOM: the inline editor is appended into .creative-tree as a
|
|
62
|
+
// sibling of the display areas, so a whole-subtree scan (the old bug) would
|
|
63
|
+
// move its <table> into a wrapper and corrupt in-progress edits.
|
|
64
|
+
function makeRow() {
|
|
65
|
+
const row = document.createElement("div")
|
|
66
|
+
row.innerHTML =
|
|
67
|
+
'<div class="creative-tree">' +
|
|
68
|
+
`<div class="creative-row"><div class="creative-content">${tableHtml}</div></div>` +
|
|
69
|
+
`<div id="inline-edit-form"><div data-lexical-editor-root>${tableHtml}</div>` +
|
|
70
|
+
`<div id="markdown-preview">${tableHtml}</div></div>` +
|
|
71
|
+
"</div>"
|
|
72
|
+
return row
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it("wraps display-area tables but never the inline editor's tables", () => {
|
|
76
|
+
const row = makeRow()
|
|
77
|
+
|
|
78
|
+
addCreativeTableDownloadButtons(row)
|
|
79
|
+
|
|
80
|
+
expect(row.querySelector(".creative-content .table-download-wrapper")).not.toBeNull()
|
|
81
|
+
expect(row.querySelector("#inline-edit-form .table-download-wrapper")).toBeNull()
|
|
82
|
+
expect(row.querySelector("#markdown-preview .table-download-wrapper")).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("also wraps title-content tables", () => {
|
|
86
|
+
const row = document.createElement("div")
|
|
87
|
+
row.innerHTML = `<div class="creative-title-content">${tableHtml}</div>`
|
|
88
|
+
|
|
89
|
+
addCreativeTableDownloadButtons(row)
|
|
90
|
+
|
|
91
|
+
expect(row.querySelector(".creative-title-content .table-download-wrapper")).not.toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* confirm_dialog — backwards-compatible entry point.
|
|
3
|
+
*
|
|
4
|
+
* The implementation now lives in ./dialog.js alongside alertDialog and
|
|
5
|
+
* promptDialog, which share one modal core. This module is kept so existing
|
|
6
|
+
* `import { confirmDialog } from '.../confirm_dialog'` call sites (and their
|
|
7
|
+
* tests) keep working. New code should import from './dialog' to also reach
|
|
8
|
+
* alertDialog / promptDialog.
|
|
9
|
+
*/
|
|
10
|
+
export { confirmDialog, default } from './dialog'
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dialog — Promise-based, in-app replacements for the native browser dialogs
|
|
3
|
+
* window.alert(), window.confirm() and window.prompt().
|
|
4
|
+
*
|
|
5
|
+
* Why: the packaged desktop app runs inside a Tauri (WKWebView) webview, which
|
|
6
|
+
* does not reliably present native dialogs — alert() shows nothing, confirm()
|
|
7
|
+
* returns false with no UI, and prompt() returns null. Every action gated on
|
|
8
|
+
* `if (confirm(...))` then silently no-ops, and every `alert(error)` swallows
|
|
9
|
+
* the message, so a failure looks like data loss. Rendering our own DOM modal
|
|
10
|
+
* behaves identically in a browser and in the webview, removing the native
|
|
11
|
+
* dialog dependency entirely.
|
|
12
|
+
*
|
|
13
|
+
* Public API (all return Promises so callers `await` them):
|
|
14
|
+
* await alertDialog(message) → undefined (single OK button)
|
|
15
|
+
* await confirmDialog(message, { danger }) → boolean (OK / Cancel)
|
|
16
|
+
* await promptDialog(message, { defaultValue })→ string|null (input + OK / Cancel)
|
|
17
|
+
*
|
|
18
|
+
* Reuses the shared .modal-dialog styles (modal_dialog.css) so any host app or
|
|
19
|
+
* engine that bundles the collavre stylesheet renders these consistently.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Localized default button labels. Dialog *messages* are localized by the
|
|
23
|
+
// caller (server-side strings / data attributes); only the generic OK/Cancel
|
|
24
|
+
// buttons need a locale here. Locale comes from <html lang>, falling back to
|
|
25
|
+
// the webview/browser language.
|
|
26
|
+
const LABELS = {
|
|
27
|
+
ko: { ok: '확인', cancel: '취소' },
|
|
28
|
+
en: { ok: 'OK', cancel: 'Cancel' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function localeLabels() {
|
|
32
|
+
const lang = (
|
|
33
|
+
document.documentElement.lang ||
|
|
34
|
+
navigator.language ||
|
|
35
|
+
'en'
|
|
36
|
+
).toLowerCase()
|
|
37
|
+
const key = Object.keys(LABELS).find((l) => lang.startsWith(l))
|
|
38
|
+
return LABELS[key] || LABELS.en
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Stack of open dialogs so keyboard handling only targets the topmost one.
|
|
42
|
+
const stack = []
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Internal modal builder shared by alert/confirm/prompt.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} config
|
|
48
|
+
* @param {string} config.message Body text (rendered as text, never HTML).
|
|
49
|
+
* @param {string|null} config.title Optional header title.
|
|
50
|
+
* @param {boolean} config.danger Style the confirm button as destructive.
|
|
51
|
+
* @param {boolean} config.showCancel Render a Cancel button (confirm/prompt).
|
|
52
|
+
* @param {string} config.confirmText Confirm button label.
|
|
53
|
+
* @param {string} config.cancelText Cancel button label.
|
|
54
|
+
* @param {object|null} config.input When set, render a text input:
|
|
55
|
+
* { defaultValue?: string, placeholder?: string }
|
|
56
|
+
* @returns {Promise<{ confirmed: boolean, value: string|null }>}
|
|
57
|
+
* confirmed=true on OK/Enter; false on Cancel/Escape/overlay dismiss.
|
|
58
|
+
* value=current input text (always '' when no input is rendered).
|
|
59
|
+
*/
|
|
60
|
+
function runDialog(config) {
|
|
61
|
+
const labels = localeLabels()
|
|
62
|
+
const {
|
|
63
|
+
message,
|
|
64
|
+
title = null,
|
|
65
|
+
danger = false,
|
|
66
|
+
showCancel = false,
|
|
67
|
+
confirmText = labels.ok,
|
|
68
|
+
cancelText = labels.cancel,
|
|
69
|
+
input = null,
|
|
70
|
+
} = config
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const previousFocus = document.activeElement
|
|
74
|
+
|
|
75
|
+
const overlay = document.createElement('div')
|
|
76
|
+
overlay.className = 'modal-dialog-overlay open'
|
|
77
|
+
|
|
78
|
+
// A real <dialog> (not a <div>): showModal() promotes it into the browser
|
|
79
|
+
// top layer so it renders ABOVE native <dialog> lightboxes (also top layer)
|
|
80
|
+
// and z-index:9999 fullscreen popups. A plain z-index can never beat the
|
|
81
|
+
// top layer, so an in-app confirm gated behind those surfaces would be
|
|
82
|
+
// invisible and look hung — the exact silent-no-op failure this module
|
|
83
|
+
// exists to remove.
|
|
84
|
+
const dialog = document.createElement('dialog')
|
|
85
|
+
dialog.className = 'modal-dialog modal-dialog-compact open'
|
|
86
|
+
dialog.setAttribute('role', 'alertdialog')
|
|
87
|
+
dialog.setAttribute('aria-modal', 'true')
|
|
88
|
+
|
|
89
|
+
if (title) {
|
|
90
|
+
const header = document.createElement('div')
|
|
91
|
+
header.className = 'modal-dialog-header'
|
|
92
|
+
const titleEl = document.createElement('div')
|
|
93
|
+
titleEl.className = 'modal-dialog-title'
|
|
94
|
+
titleEl.textContent = title
|
|
95
|
+
header.appendChild(titleEl)
|
|
96
|
+
dialog.appendChild(header)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const body = document.createElement('div')
|
|
100
|
+
body.className = 'modal-dialog-body'
|
|
101
|
+
|
|
102
|
+
// Message text. textContent (not innerHTML): the message may contain
|
|
103
|
+
// untrusted content, and native dialogs never rendered HTML either.
|
|
104
|
+
// white-space: pre-wrap (CSS) preserves the \n line breaks they honored.
|
|
105
|
+
if (message != null && String(message) !== '') {
|
|
106
|
+
const text = document.createElement('div')
|
|
107
|
+
text.className = 'confirm-dialog-message'
|
|
108
|
+
text.textContent = String(message)
|
|
109
|
+
body.appendChild(text)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Optional input field (promptDialog).
|
|
113
|
+
let inputEl = null
|
|
114
|
+
if (input) {
|
|
115
|
+
inputEl = document.createElement('input')
|
|
116
|
+
inputEl.type = 'text'
|
|
117
|
+
inputEl.className = 'modal-dialog-input'
|
|
118
|
+
if (input.placeholder) inputEl.placeholder = input.placeholder
|
|
119
|
+
inputEl.value = input.defaultValue != null ? String(input.defaultValue) : ''
|
|
120
|
+
// Spacing when a message precedes the input.
|
|
121
|
+
if (body.childNodes.length > 0) inputEl.style.marginTop = 'var(--space-3)'
|
|
122
|
+
body.appendChild(inputEl)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dialog.appendChild(body)
|
|
126
|
+
|
|
127
|
+
const footer = document.createElement('div')
|
|
128
|
+
footer.className = 'modal-dialog-footer'
|
|
129
|
+
|
|
130
|
+
let cancelBtn = null
|
|
131
|
+
if (showCancel) {
|
|
132
|
+
cancelBtn = document.createElement('button')
|
|
133
|
+
cancelBtn.type = 'button'
|
|
134
|
+
cancelBtn.className = 'modal-dialog-btn modal-dialog-btn-secondary'
|
|
135
|
+
cancelBtn.textContent = cancelText
|
|
136
|
+
footer.appendChild(cancelBtn)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const confirmBtn = document.createElement('button')
|
|
140
|
+
confirmBtn.type = 'button'
|
|
141
|
+
confirmBtn.className =
|
|
142
|
+
'modal-dialog-btn ' +
|
|
143
|
+
(danger ? 'modal-dialog-btn-danger' : 'modal-dialog-btn-primary')
|
|
144
|
+
confirmBtn.textContent = confirmText
|
|
145
|
+
footer.appendChild(confirmBtn)
|
|
146
|
+
|
|
147
|
+
dialog.appendChild(footer)
|
|
148
|
+
|
|
149
|
+
const entry = { dialog }
|
|
150
|
+
let settled = false
|
|
151
|
+
|
|
152
|
+
function close(confirmed) {
|
|
153
|
+
if (settled) return
|
|
154
|
+
settled = true
|
|
155
|
+
const value = inputEl ? inputEl.value : ''
|
|
156
|
+
const i = stack.indexOf(entry)
|
|
157
|
+
if (i !== -1) stack.splice(i, 1)
|
|
158
|
+
document.removeEventListener('keydown', onKeydown, true)
|
|
159
|
+
overlay.remove() // no-op when never appended (top-layer path)
|
|
160
|
+
// Exit the top layer before detaching (close event is not observed here).
|
|
161
|
+
try {
|
|
162
|
+
if (dialog.open && typeof dialog.close === 'function') dialog.close()
|
|
163
|
+
} catch (_) {
|
|
164
|
+
/* unsupported — removal below still drops it from the top layer */
|
|
165
|
+
}
|
|
166
|
+
dialog.remove()
|
|
167
|
+
// Restore focus to whatever triggered the dialog.
|
|
168
|
+
try {
|
|
169
|
+
if (previousFocus && typeof previousFocus.focus === 'function') {
|
|
170
|
+
previousFocus.focus()
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
/* element gone — ignore */
|
|
174
|
+
}
|
|
175
|
+
resolve({ confirmed, value })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onKeydown(e) {
|
|
179
|
+
// Only the topmost dialog reacts to keys.
|
|
180
|
+
if (stack[stack.length - 1] !== entry) return
|
|
181
|
+
if (e.key === 'Escape') {
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
e.stopPropagation()
|
|
184
|
+
close(false)
|
|
185
|
+
} else if (e.key === 'Enter') {
|
|
186
|
+
// In a text input, Enter submits (parity with native prompt).
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
e.stopPropagation()
|
|
189
|
+
close(true)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (cancelBtn) cancelBtn.addEventListener('click', () => close(false))
|
|
194
|
+
confirmBtn.addEventListener('click', () => close(true))
|
|
195
|
+
// Click on the ::backdrop (its event target is the <dialog> itself, since
|
|
196
|
+
// the panel content is nested) dismisses — parity with the old overlay.
|
|
197
|
+
dialog.addEventListener('click', (e) => {
|
|
198
|
+
if (e.target === dialog) close(false)
|
|
199
|
+
})
|
|
200
|
+
// Escape on a modal <dialog> fires `cancel`; route it through our teardown
|
|
201
|
+
// (focus restore + promise resolution) instead of the bare native close.
|
|
202
|
+
dialog.addEventListener('cancel', (e) => {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
close(false)
|
|
205
|
+
})
|
|
206
|
+
document.addEventListener('keydown', onKeydown, true)
|
|
207
|
+
|
|
208
|
+
document.body.appendChild(dialog)
|
|
209
|
+
stack.push(entry)
|
|
210
|
+
|
|
211
|
+
// Promote into the top layer. Fall back to a z-index overlay backdrop only
|
|
212
|
+
// where showModal is unavailable (jsdom under tests, very old webviews).
|
|
213
|
+
let topLayer = false
|
|
214
|
+
try {
|
|
215
|
+
dialog.showModal()
|
|
216
|
+
topLayer = true
|
|
217
|
+
} catch (_) {
|
|
218
|
+
/* no showModal — manual backdrop + stacking below */
|
|
219
|
+
}
|
|
220
|
+
if (!topLayer) {
|
|
221
|
+
dialog.setAttribute('open', '')
|
|
222
|
+
overlay.addEventListener('click', () => close(false))
|
|
223
|
+
document.body.insertBefore(overlay, dialog)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Focus the input (prompt) or the confirm button (alert/confirm) so Enter
|
|
227
|
+
// accepts, matching native dialog behavior.
|
|
228
|
+
if (inputEl) {
|
|
229
|
+
inputEl.focus()
|
|
230
|
+
inputEl.select()
|
|
231
|
+
} else {
|
|
232
|
+
confirmBtn.focus()
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* alertDialog — in-app replacement for window.alert().
|
|
239
|
+
* Resolves (undefined) when dismissed. Usually fire-and-forget at call sites,
|
|
240
|
+
* but returns a Promise so callers may `await` it when ordering matters.
|
|
241
|
+
*
|
|
242
|
+
* alertDialog('Failed to save')
|
|
243
|
+
* await alertDialog('Done', { title: 'Export' })
|
|
244
|
+
*/
|
|
245
|
+
export function alertDialog(message, options = {}) {
|
|
246
|
+
const { title = null, confirmText, danger = false } = options
|
|
247
|
+
return runDialog({
|
|
248
|
+
message,
|
|
249
|
+
title,
|
|
250
|
+
danger,
|
|
251
|
+
showCancel: false,
|
|
252
|
+
confirmText,
|
|
253
|
+
}).then(() => undefined)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* confirmDialog — in-app replacement for window.confirm().
|
|
258
|
+
* Resolves true when confirmed, false when cancelled/dismissed.
|
|
259
|
+
*
|
|
260
|
+
* if (await confirmDialog(message, { danger: true })) doDestructiveThing()
|
|
261
|
+
*/
|
|
262
|
+
export function confirmDialog(message, options = {}) {
|
|
263
|
+
const { confirmText, cancelText, danger = false, title = null } = options
|
|
264
|
+
return runDialog({
|
|
265
|
+
message,
|
|
266
|
+
title,
|
|
267
|
+
danger,
|
|
268
|
+
showCancel: true,
|
|
269
|
+
confirmText,
|
|
270
|
+
cancelText,
|
|
271
|
+
}).then((r) => r.confirmed)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* promptDialog — in-app replacement for window.prompt().
|
|
276
|
+
* Resolves the entered string when confirmed, or null when cancelled/dismissed
|
|
277
|
+
* (parity with native prompt, which returns null on cancel).
|
|
278
|
+
*
|
|
279
|
+
* const name = await promptDialog('New name?', { defaultValue: current })
|
|
280
|
+
* if (name !== null) rename(name)
|
|
281
|
+
*/
|
|
282
|
+
export function promptDialog(message, options = {}) {
|
|
283
|
+
const {
|
|
284
|
+
defaultValue = '',
|
|
285
|
+
placeholder = '',
|
|
286
|
+
confirmText,
|
|
287
|
+
cancelText,
|
|
288
|
+
title = null,
|
|
289
|
+
} = options
|
|
290
|
+
return runDialog({
|
|
291
|
+
message,
|
|
292
|
+
title,
|
|
293
|
+
showCancel: true,
|
|
294
|
+
confirmText,
|
|
295
|
+
cancelText,
|
|
296
|
+
input: { defaultValue, placeholder },
|
|
297
|
+
}).then((r) => (r.confirmed ? r.value : null))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export default confirmDialog
|