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,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$createTableCellNode,
|
|
3
|
+
$createTableNode,
|
|
4
|
+
$createTableRowNode,
|
|
5
|
+
$isTableCellNode,
|
|
6
|
+
$isTableNode,
|
|
7
|
+
$isTableRowNode,
|
|
8
|
+
TableCellHeaderStates
|
|
9
|
+
} from "@lexical/table"
|
|
10
|
+
import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"
|
|
11
|
+
import { $isParagraphNode, $isTextNode } from "lexical"
|
|
12
|
+
|
|
13
|
+
// GFM pipe-table support for the markdown-canonical editor. Ported from the
|
|
14
|
+
// Lexical playground's MarkdownTransformers TABLE ElementTransformer, adapted to
|
|
15
|
+
// .js and to a lazily-injected cell transformer list. Unlike the color/decorator
|
|
16
|
+
// transformers (export-only), this one is bidirectional: it exports Lexical
|
|
17
|
+
// tables to GFM and reconstructs them when markdown is parsed back.
|
|
18
|
+
|
|
19
|
+
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/
|
|
20
|
+
// -+ (not -*) is required per cell: a GFM divider must have at least one dash,
|
|
21
|
+
// and the mandatory dash run anchors the surrounding :? colons so they can't both
|
|
22
|
+
// compete for the same character — which would cause exponential backtracking.
|
|
23
|
+
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-+:? ?)+\|\s?$/
|
|
24
|
+
|
|
25
|
+
// Each table cell is (de)serialized with the full transformer list (which
|
|
26
|
+
// includes this TABLE transformer). markdown_serialize.js injects the list once
|
|
27
|
+
// at load via setCellTransformers to avoid a circular import. Cells never nest
|
|
28
|
+
// tables, so the self-reference stays bounded.
|
|
29
|
+
let cellTransformers = []
|
|
30
|
+
export function setCellTransformers(list) {
|
|
31
|
+
cellTransformers = list
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getTableColumnsSize(table) {
|
|
35
|
+
const row = table.getFirstChild()
|
|
36
|
+
return $isTableRowNode(row) ? row.getChildrenSize() : 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function $createTableCell(textContent) {
|
|
40
|
+
// Backslashes are governed by CommonMark and round-trip through the per-cell
|
|
41
|
+
// $convertFromMarkdownString below; we must NOT pre-decode them here. An earlier
|
|
42
|
+
// ".replace(/\\n/g, \"\\n\")" mis-fired on literal "a\nb" (backslash + n typed by
|
|
43
|
+
// the user, e.g. a regex or Windows path), corrupting it into a real newline
|
|
44
|
+
// that breaks the single-line row (CodeQL alert #42). Cells are single-line, so
|
|
45
|
+
// no newline decoding is needed at all.
|
|
46
|
+
const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
|
|
47
|
+
$convertFromMarkdownString(textContent, cellTransformers, cell)
|
|
48
|
+
return cell
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Split a row's inner text on cell boundaries and reverse the export escaping in
|
|
52
|
+
// one left-to-right pass. Export escapes (in order) backslash -> "\\" then pipe
|
|
53
|
+
// -> "\|", so here every backslash starts a 2-char escape: it consumes the next
|
|
54
|
+
// char ("\\" -> "\", "\|" -> a literal in-cell pipe). Only a *bare* pipe is a
|
|
55
|
+
// column boundary. The result still carries CommonMark's own backslash escapes,
|
|
56
|
+
// which $convertFromMarkdownString decodes per cell. Undoing the backslash escape
|
|
57
|
+
// here (not just "\|") is what keeps the escaping complete and unambiguous
|
|
58
|
+
// (CodeQL js/incomplete-sanitization #43). Each cell is trimmed because GFM treats
|
|
59
|
+
// the spaces around "| cell |" as insignificant padding (export re-adds them).
|
|
60
|
+
function splitRowCells(inner) {
|
|
61
|
+
const cells = []
|
|
62
|
+
let current = ""
|
|
63
|
+
for (let i = 0; i < inner.length; i++) {
|
|
64
|
+
if (inner[i] === "\\" && i + 1 < inner.length) {
|
|
65
|
+
current += inner[i + 1] // "\\" -> "\", "\|" -> "|" (any other "\x" -> "x")
|
|
66
|
+
i++
|
|
67
|
+
} else if (inner[i] === "|") {
|
|
68
|
+
cells.push(current.trim())
|
|
69
|
+
current = ""
|
|
70
|
+
} else {
|
|
71
|
+
current += inner[i]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
cells.push(current.trim())
|
|
75
|
+
return cells
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapToTableCells(textContent) {
|
|
79
|
+
const match = textContent.match(TABLE_ROW_REG_EXP)
|
|
80
|
+
if (!match || !match[1]) return null
|
|
81
|
+
return splitRowCells(match[1]).map((text) => $createTableCell(text))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const TABLE = {
|
|
85
|
+
dependencies: [],
|
|
86
|
+
export: (node) => {
|
|
87
|
+
if (!$isTableNode(node)) return null
|
|
88
|
+
const output = []
|
|
89
|
+
for (const row of node.getChildren()) {
|
|
90
|
+
if (!$isTableRowNode(row)) continue
|
|
91
|
+
const rowOutput = []
|
|
92
|
+
let isHeaderRow = false
|
|
93
|
+
for (const cell of row.getChildren()) {
|
|
94
|
+
if ($isTableCellNode(cell)) {
|
|
95
|
+
rowOutput.push(
|
|
96
|
+
$convertToMarkdownString(cellTransformers, cell)
|
|
97
|
+
// A GFM row is one line, so collapse any real newline to a space
|
|
98
|
+
// (multi-line cells aren't representable in GFM anyway). NOT "\\n":
|
|
99
|
+
// that re-imported as a literal backslash-n and then mis-decoded
|
|
100
|
+
// back to a newline, corrupting genuine "a\nb" text (CodeQL #42).
|
|
101
|
+
.replace(/\n+/g, " ")
|
|
102
|
+
// Escape the escape char FIRST, then pipes, so the escaping is
|
|
103
|
+
// complete and unambiguous (CodeQL js/incomplete-sanitization #43).
|
|
104
|
+
// splitRowCells reverses both ("\\"->"\", "\|"->"|") on import,
|
|
105
|
+
// restoring exactly what CommonMark emitted before re-decoding it.
|
|
106
|
+
.replace(/\\/g, "\\\\")
|
|
107
|
+
.replace(/\|/g, "\\|")
|
|
108
|
+
.trim()
|
|
109
|
+
)
|
|
110
|
+
if (cell.__headerState === TableCellHeaderStates.ROW) {
|
|
111
|
+
isHeaderRow = true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
output.push(`| ${rowOutput.join(" | ")} |`)
|
|
116
|
+
if (isHeaderRow) {
|
|
117
|
+
output.push(`| ${rowOutput.map(() => "---").join(" | ")} |`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return output.join("\n")
|
|
121
|
+
},
|
|
122
|
+
regExp: TABLE_ROW_REG_EXP,
|
|
123
|
+
replace: (parentNode, _children, match) => {
|
|
124
|
+
// A divider row ("| --- | --- |") promotes the previous table's last row to
|
|
125
|
+
// a header row, then removes itself.
|
|
126
|
+
if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) {
|
|
127
|
+
const table = parentNode.getPreviousSibling()
|
|
128
|
+
if (!table || !$isTableNode(table)) return
|
|
129
|
+
const rows = table.getChildren()
|
|
130
|
+
const lastRow = rows[rows.length - 1]
|
|
131
|
+
if (!lastRow || !$isTableRowNode(lastRow)) return
|
|
132
|
+
lastRow.getChildren().forEach((cell) => {
|
|
133
|
+
if (!$isTableCellNode(cell)) return
|
|
134
|
+
cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW)
|
|
135
|
+
})
|
|
136
|
+
parentNode.remove()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const matchCells = mapToTableCells(match[0])
|
|
141
|
+
if (matchCells == null) return
|
|
142
|
+
|
|
143
|
+
// Walk backwards over preceding single-text paragraphs that are also table
|
|
144
|
+
// rows, accumulating them so a multi-line table is parsed as one node.
|
|
145
|
+
const rows = [matchCells]
|
|
146
|
+
let sibling = parentNode.getPreviousSibling()
|
|
147
|
+
let maxCells = matchCells.length
|
|
148
|
+
while (sibling) {
|
|
149
|
+
if (!$isParagraphNode(sibling) || sibling.getChildrenSize() !== 1) break
|
|
150
|
+
const firstChild = sibling.getFirstChild()
|
|
151
|
+
if (!$isTextNode(firstChild)) break
|
|
152
|
+
const cells = mapToTableCells(firstChild.getTextContent())
|
|
153
|
+
if (cells == null) break
|
|
154
|
+
maxCells = Math.max(maxCells, cells.length)
|
|
155
|
+
rows.unshift(cells)
|
|
156
|
+
const previousSibling = sibling.getPreviousSibling()
|
|
157
|
+
sibling.remove()
|
|
158
|
+
sibling = previousSibling
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const table = $createTableNode()
|
|
162
|
+
for (const cells of rows) {
|
|
163
|
+
const tableRow = $createTableRowNode()
|
|
164
|
+
table.append(tableRow)
|
|
165
|
+
for (let i = 0; i < maxCells; i++) {
|
|
166
|
+
tableRow.append(i < cells.length ? cells[i] : $createTableCell(""))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Merge with an adjacent table of the same width (the divider row already
|
|
171
|
+
// removed itself, leaving the header table directly before this body row).
|
|
172
|
+
const previousSibling = parentNode.getPreviousSibling()
|
|
173
|
+
if ($isTableNode(previousSibling) && getTableColumnsSize(previousSibling) === maxCells) {
|
|
174
|
+
previousSibling.append(...table.getChildren())
|
|
175
|
+
parentNode.remove()
|
|
176
|
+
} else {
|
|
177
|
+
parentNode.replace(table)
|
|
178
|
+
}
|
|
179
|
+
table.selectEnd()
|
|
180
|
+
},
|
|
181
|
+
type: "element"
|
|
182
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { $createParagraphNode, $isDecoratorNode, RootNode } from "lexical"
|
|
2
|
+
|
|
3
|
+
// A top-level DecoratorNode (image / video / attachment) renders as
|
|
4
|
+
// non-editable content. When one is the last child of the root there is no
|
|
5
|
+
// text position after it, so the caret can't land below it and the whole
|
|
6
|
+
// document becomes uneditable — the reported "an image steals focus / can't
|
|
7
|
+
// edit" bug, worst when the image is the only block. Guarantee the root always
|
|
8
|
+
// ends in a paragraph so there is always an editable landing spot after a
|
|
9
|
+
// trailing decorator.
|
|
10
|
+
//
|
|
11
|
+
// Must run inside an editor.update()/transform.
|
|
12
|
+
export function ensureTrailingParagraph(root) {
|
|
13
|
+
const last = root.getLastChild()
|
|
14
|
+
if (last !== null && $isDecoratorNode(last)) {
|
|
15
|
+
last.insertAfter($createParagraphNode())
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Keep the guarantee while editing too: deleting the text below an image, or
|
|
22
|
+
// pasting an image as the last block, would otherwise re-trap the caret.
|
|
23
|
+
// Appending leaves the root dirty, so the transform re-runs once more and then
|
|
24
|
+
// no-ops (last child is now the paragraph) — it terminates.
|
|
25
|
+
export function registerTrailingParagraph(editor) {
|
|
26
|
+
return editor.registerNodeTransform(RootNode, (root) => {
|
|
27
|
+
ensureTrailingParagraph(root)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// turbo_confirm — route Turbo's declarative `data-turbo-confirm` prompts through
|
|
2
|
+
// the in-app confirm dialog instead of the native window.confirm().
|
|
3
|
+
//
|
|
4
|
+
// Why: Rails/Turbo forms gate destructive submits with `data-turbo-confirm`.
|
|
5
|
+
// Turbo's default confirm method calls window.confirm(), which in the packaged
|
|
6
|
+
// desktop app (Tauri WKWebView) returns false with no UI — so the submit
|
|
7
|
+
// silently no-ops (e.g. token revoke, user delete, share removal) and looks
|
|
8
|
+
// like the button is broken. Converting hand-written confirm() calls does not
|
|
9
|
+
// cover these, because Turbo intercepts them before any of our code runs. The
|
|
10
|
+
// fix is a single global override of Turbo's confirm method.
|
|
11
|
+
//
|
|
12
|
+
// Returns a Promise<boolean>; Turbo awaits it before submitting (Turbo 8
|
|
13
|
+
// `config.forms.confirm`, with a fallback to the deprecated setConfirmMethod).
|
|
14
|
+
|
|
15
|
+
import { Turbo } from "@hotwired/turbo-rails"
|
|
16
|
+
import { confirmDialog } from "./utils/dialog"
|
|
17
|
+
|
|
18
|
+
// Turbo invokes this with (message, formElement, submitter). Style the confirm
|
|
19
|
+
// button as destructive when the submit deletes (the common case for
|
|
20
|
+
// data-turbo-confirm), so it matches the danger styling used elsewhere.
|
|
21
|
+
function turboConfirm(message, formElement, submitter) {
|
|
22
|
+
const method = (
|
|
23
|
+
submitter?.getAttribute?.("data-turbo-method") ||
|
|
24
|
+
formElement?.getAttribute?.("data-turbo-method") ||
|
|
25
|
+
formElement?.querySelector?.("input[name='_method']")?.value ||
|
|
26
|
+
formElement?.getAttribute?.("method") ||
|
|
27
|
+
""
|
|
28
|
+
).toLowerCase()
|
|
29
|
+
const danger = method === "delete" || method === "destroy"
|
|
30
|
+
return confirmDialog(message, { danger })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Install on both the bundled Turbo and any pre-existing global window.Turbo,
|
|
34
|
+
// mirroring turbo_stream_actions.js — the two can be distinct instances.
|
|
35
|
+
function installTurboConfirm(turbo) {
|
|
36
|
+
if (!turbo) return
|
|
37
|
+
if (turbo.config && turbo.config.forms) {
|
|
38
|
+
turbo.config.forms.confirm = turboConfirm
|
|
39
|
+
} else if (typeof turbo.setConfirmMethod === "function") {
|
|
40
|
+
// Turbo < 8 fallback.
|
|
41
|
+
turbo.setConfirmMethod(turboConfirm)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
installTurboConfirm(Turbo)
|
|
46
|
+
if (window.Turbo && window.Turbo !== Turbo) installTurboConfirm(window.Turbo)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Pure logic for client-side typo correction (Phase 1: chat input).
|
|
2
|
+
//
|
|
3
|
+
// This module holds the framework-free core so it can be unit-tested without a
|
|
4
|
+
// DOM: device classification, the 2D gating decision (mirrors the server's
|
|
5
|
+
// `Collavre::User#typo_correction_active_for?`), candidate-list construction for
|
|
6
|
+
// the selection popup, and edit anchoring/application with re-anchoring of the
|
|
7
|
+
// remaining edits after one is applied.
|
|
8
|
+
//
|
|
9
|
+
// The overlay highlight is a *volatile* presentation layer — the textarea value
|
|
10
|
+
// is the only canonical text. Nothing here ever serializes a highlight.
|
|
11
|
+
|
|
12
|
+
export const DEVICE_VOICE = 'voice'
|
|
13
|
+
export const DEVICE_SOFT_KEYBOARD = 'soft_keyboard'
|
|
14
|
+
export const DEVICE_PHYSICAL_KEYBOARD = 'physical_keyboard'
|
|
15
|
+
|
|
16
|
+
// Classify the active typing device from the signals we can observe.
|
|
17
|
+
//
|
|
18
|
+
// - Voice dictation is explicit (the composer exposes its recognition state).
|
|
19
|
+
// - A physical keyboard fires a `keydown` for a printable key immediately
|
|
20
|
+
// before the resulting `input` event. Soft keyboards and IME composition
|
|
21
|
+
// insert text without that printable keydown (key is 'Unidentified'/'Process'
|
|
22
|
+
// or no keydown at all), which is the standard heuristic we rely on.
|
|
23
|
+
export function detectDevice({ voiceActive = false, lastPrintableKeydownAt = null, inputAt = null } = {}) {
|
|
24
|
+
if (voiceActive) return DEVICE_VOICE
|
|
25
|
+
if (lastPrintableKeydownAt == null || inputAt == null) return DEVICE_SOFT_KEYBOARD
|
|
26
|
+
// A printable keydown that landed within the same input tick means a physical
|
|
27
|
+
// key produced this character.
|
|
28
|
+
const delta = inputAt - lastPrintableKeydownAt
|
|
29
|
+
if (delta >= 0 && delta <= 50) return DEVICE_PHYSICAL_KEYBOARD
|
|
30
|
+
return DEVICE_SOFT_KEYBOARD
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Mirror of the server-side 2D gate so the client avoids a pointless round-trip
|
|
34
|
+
// when the feature is off for this device/location. The server re-checks; this
|
|
35
|
+
// is an optimization, not the source of truth.
|
|
36
|
+
export function shouldRun(settings, { device, location }) {
|
|
37
|
+
if (!settings || settings.enabled !== true) return false
|
|
38
|
+
|
|
39
|
+
const deviceOn =
|
|
40
|
+
device === DEVICE_VOICE ? settings.onVoice
|
|
41
|
+
: device === DEVICE_SOFT_KEYBOARD ? settings.onSoftKeyboard
|
|
42
|
+
: device === DEVICE_PHYSICAL_KEYBOARD ? settings.onPhysicalKeyboard
|
|
43
|
+
: false
|
|
44
|
+
if (deviceOn !== true) return false
|
|
45
|
+
|
|
46
|
+
const locationOn =
|
|
47
|
+
location === 'chat' ? settings.inChat
|
|
48
|
+
: location === 'editor' ? settings.inEditor
|
|
49
|
+
: false
|
|
50
|
+
return locationOn === true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Build the popup's option list for one edit: the word currently sitting in the
|
|
54
|
+
// document first (so Enter = keep), then the suggestions in confidence order,
|
|
55
|
+
// de-duplicated. `currentValue` is whatever text is actually in the textarea at
|
|
56
|
+
// the edit's span right now (original word, or the corrected word if it was
|
|
57
|
+
// auto-applied). `originalWord` is the pre-correction text, surfaced as the undo
|
|
58
|
+
// option when the current value is already the suggestion.
|
|
59
|
+
export function buildCandidateList({ currentValue, originalWord, suggestions = [] }) {
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
const list = []
|
|
62
|
+
const push = (value, role) => {
|
|
63
|
+
if (value == null || value === '') return
|
|
64
|
+
if (seen.has(value)) return
|
|
65
|
+
seen.add(value)
|
|
66
|
+
list.push({ value, label: value, isCurrent: value === currentValue, role })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Current document value is always the pre-selected, pre-filled option.
|
|
70
|
+
push(currentValue, currentValue === originalWord ? 'original' : 'applied')
|
|
71
|
+
// If we're showing a corrected word, the original is the undo option.
|
|
72
|
+
push(originalWord, 'original')
|
|
73
|
+
// Suggestions, highest confidence first (assumed already sorted; sort defensively).
|
|
74
|
+
;[...suggestions]
|
|
75
|
+
.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
76
|
+
.forEach((s) => push(s.suggestion, 'suggestion'))
|
|
77
|
+
|
|
78
|
+
return list
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Attach character offsets to each edit. Prefer the server-supplied `offset`
|
|
82
|
+
// (the first occurrence the server verified is *outside* a protected span) so a
|
|
83
|
+
// duplicate word sitting inside code/URL/markup is never anchored — anchoring by
|
|
84
|
+
// our own search would bind the first textual occurrence, which can be the
|
|
85
|
+
// protected one the server already excluded. We only trust `offset` if the
|
|
86
|
+
// substring still matches there (text is identical to what the server saw).
|
|
87
|
+
// Without an offset we fall back to a left-to-right search, advancing the cursor
|
|
88
|
+
// so repeated words bind to distinct occurrences. Edits whose original can no
|
|
89
|
+
// longer be found are dropped — the caller re-runs the detector.
|
|
90
|
+
export function anchorEdits(text, edits = []) {
|
|
91
|
+
const anchored = []
|
|
92
|
+
let cursor = 0
|
|
93
|
+
for (const edit of edits) {
|
|
94
|
+
if (!edit || !edit.original) continue
|
|
95
|
+
let idx
|
|
96
|
+
if (
|
|
97
|
+
Number.isInteger(edit.offset)
|
|
98
|
+
&& text.slice(edit.offset, edit.offset + edit.original.length) === edit.original
|
|
99
|
+
) {
|
|
100
|
+
idx = edit.offset
|
|
101
|
+
} else {
|
|
102
|
+
idx = text.indexOf(edit.original, cursor)
|
|
103
|
+
}
|
|
104
|
+
if (idx === -1) continue
|
|
105
|
+
anchored.push({ ...edit, start: idx, end: idx + edit.original.length })
|
|
106
|
+
cursor = Math.max(cursor, idx + edit.original.length)
|
|
107
|
+
}
|
|
108
|
+
return anchored
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Replace the span [start, end) of `text` with `replacement` and return the new
|
|
112
|
+
// text plus the delta, so remaining edits can be shifted. We re-anchor by delta
|
|
113
|
+
// rather than re-searching to avoid mis-binding when the same word repeats.
|
|
114
|
+
export function applyEditAt(text, { start, end, replacement }) {
|
|
115
|
+
const before = text.slice(0, start)
|
|
116
|
+
const after = text.slice(end)
|
|
117
|
+
const newText = before + replacement + after
|
|
118
|
+
const delta = replacement.length - (end - start)
|
|
119
|
+
return { text: newText, delta }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Shift every edit positioned at/after `fromOffset` by `delta`. Edits before the
|
|
123
|
+
// applied span are untouched; the applied edit itself should be removed by the
|
|
124
|
+
// caller before calling this.
|
|
125
|
+
export function shiftEditsAfter(edits, fromOffset, delta) {
|
|
126
|
+
return edits.map((edit) => {
|
|
127
|
+
if (edit.start >= fromOffset) {
|
|
128
|
+
return { ...edit, start: edit.start + delta, end: edit.end + delta }
|
|
129
|
+
}
|
|
130
|
+
return edit
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Partition edits the server returned into auto-applied (confidence at/above the
|
|
135
|
+
// user's threshold) and candidates (below it). Threshold is 0–100 to match the
|
|
136
|
+
// profile setting; confidence is 0–1 from the model.
|
|
137
|
+
export function partitionByThreshold(edits = [], threshold = 80) {
|
|
138
|
+
const cut = threshold / 100
|
|
139
|
+
const autoApplied = []
|
|
140
|
+
const candidates = []
|
|
141
|
+
for (const edit of edits) {
|
|
142
|
+
if ((edit.confidence ?? 0) >= cut) autoApplied.push(edit)
|
|
143
|
+
else candidates.push(edit)
|
|
144
|
+
}
|
|
145
|
+
return { autoApplied, candidates }
|
|
146
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { confirmDialog } from '../confirm_dialog'
|
|
5
|
+
|
|
6
|
+
const modal = () => document.querySelector('.modal-dialog')
|
|
7
|
+
const overlay = () => document.querySelector('.modal-dialog-overlay')
|
|
8
|
+
const message = () => document.querySelector('.confirm-dialog-message')
|
|
9
|
+
|
|
10
|
+
describe('confirmDialog', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = ''
|
|
13
|
+
document.documentElement.lang = ''
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('renders a modal with the message and OK/Cancel buttons', () => {
|
|
17
|
+
confirmDialog('Delete this?')
|
|
18
|
+
expect(modal()).not.toBeNull()
|
|
19
|
+
expect(overlay()).not.toBeNull()
|
|
20
|
+
expect(message().textContent).toBe('Delete this?')
|
|
21
|
+
expect(document.querySelector('.modal-dialog-btn-secondary')).not.toBeNull()
|
|
22
|
+
expect(document.querySelector('.modal-dialog-btn-primary')).not.toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('resolves true when confirm is clicked, then removes the modal', async () => {
|
|
26
|
+
const p = confirmDialog('ok?')
|
|
27
|
+
document.querySelector('.modal-dialog-btn-primary').click()
|
|
28
|
+
await expect(p).resolves.toBe(true)
|
|
29
|
+
expect(modal()).toBeNull()
|
|
30
|
+
expect(overlay()).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('resolves false when cancel is clicked', async () => {
|
|
34
|
+
const p = confirmDialog('x')
|
|
35
|
+
document.querySelector('.modal-dialog-btn-secondary').click()
|
|
36
|
+
await expect(p).resolves.toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('resolves false when the backdrop is clicked', async () => {
|
|
40
|
+
const p = confirmDialog('x')
|
|
41
|
+
overlay().click()
|
|
42
|
+
await expect(p).resolves.toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('Enter resolves true, Escape resolves false', async () => {
|
|
46
|
+
const p1 = confirmDialog('x')
|
|
47
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
48
|
+
await expect(p1).resolves.toBe(true)
|
|
49
|
+
|
|
50
|
+
const p2 = confirmDialog('x')
|
|
51
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
52
|
+
await expect(p2).resolves.toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('danger option styles the confirm button as destructive', () => {
|
|
56
|
+
confirmDialog('Delete?', { danger: true })
|
|
57
|
+
expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
|
|
58
|
+
expect(document.querySelector('.modal-dialog-btn-primary')).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('renders the message as text, never HTML (no injection)', () => {
|
|
62
|
+
confirmDialog('<img src=x onerror=alert(1)>')
|
|
63
|
+
expect(message().textContent).toBe('<img src=x onerror=alert(1)>')
|
|
64
|
+
expect(message().querySelector('img')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('localizes the buttons from <html lang>', () => {
|
|
68
|
+
document.documentElement.lang = 'ko'
|
|
69
|
+
confirmDialog('삭제?')
|
|
70
|
+
expect(document.querySelector('.modal-dialog-btn-primary').textContent).toBe('확인')
|
|
71
|
+
expect(document.querySelector('.modal-dialog-btn-secondary').textContent).toBe('취소')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('only the topmost stacked dialog reacts to keys', async () => {
|
|
75
|
+
const p1 = confirmDialog('first')
|
|
76
|
+
const p2 = confirmDialog('second')
|
|
77
|
+
expect(document.querySelectorAll('.modal-dialog').length).toBe(2)
|
|
78
|
+
|
|
79
|
+
// Enter resolves only the topmost (second) dialog.
|
|
80
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
81
|
+
await expect(p2).resolves.toBe(true)
|
|
82
|
+
expect(document.querySelectorAll('.modal-dialog').length).toBe(1)
|
|
83
|
+
|
|
84
|
+
// The first dialog is now topmost and reacts.
|
|
85
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
86
|
+
await expect(p1).resolves.toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { alertDialog, confirmDialog, promptDialog } from '../dialog'
|
|
5
|
+
|
|
6
|
+
const modal = () => document.querySelector('.modal-dialog')
|
|
7
|
+
const overlay = () => document.querySelector('.modal-dialog-overlay')
|
|
8
|
+
const message = () => document.querySelector('.confirm-dialog-message')
|
|
9
|
+
const input = () => document.querySelector('.modal-dialog-input')
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = ''
|
|
13
|
+
document.documentElement.lang = ''
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('alertDialog', () => {
|
|
17
|
+
test('renders a single OK button (no Cancel) with the message', () => {
|
|
18
|
+
alertDialog('Failed to save')
|
|
19
|
+
expect(modal()).not.toBeNull()
|
|
20
|
+
expect(message().textContent).toBe('Failed to save')
|
|
21
|
+
expect(document.querySelector('.modal-dialog-btn-primary')).not.toBeNull()
|
|
22
|
+
expect(document.querySelector('.modal-dialog-btn-secondary')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('resolves (undefined) and removes the modal when OK is clicked', async () => {
|
|
26
|
+
const p = alertDialog('done')
|
|
27
|
+
document.querySelector('.modal-dialog-btn-primary').click()
|
|
28
|
+
await expect(p).resolves.toBeUndefined()
|
|
29
|
+
expect(modal()).toBeNull()
|
|
30
|
+
expect(overlay()).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Enter and Escape both dismiss', async () => {
|
|
34
|
+
const p1 = alertDialog('x')
|
|
35
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
36
|
+
await expect(p1).resolves.toBeUndefined()
|
|
37
|
+
|
|
38
|
+
const p2 = alertDialog('x')
|
|
39
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
40
|
+
await expect(p2).resolves.toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('renders the message as text, never HTML (no injection)', () => {
|
|
44
|
+
alertDialog('<img src=x onerror=alert(1)>')
|
|
45
|
+
expect(message().textContent).toBe('<img src=x onerror=alert(1)>')
|
|
46
|
+
expect(message().querySelector('img')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('promptDialog', () => {
|
|
51
|
+
test('renders a text input seeded with defaultValue plus OK/Cancel', () => {
|
|
52
|
+
promptDialog('New name?', { defaultValue: 'old' })
|
|
53
|
+
expect(message().textContent).toBe('New name?')
|
|
54
|
+
expect(input()).not.toBeNull()
|
|
55
|
+
expect(input().value).toBe('old')
|
|
56
|
+
expect(document.querySelector('.modal-dialog-btn-secondary')).not.toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('resolves the entered value when confirmed', async () => {
|
|
60
|
+
const p = promptDialog('Name?')
|
|
61
|
+
input().value = 'typed'
|
|
62
|
+
document.querySelector('.modal-dialog-btn-primary').click()
|
|
63
|
+
await expect(p).resolves.toBe('typed')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('resolves null when cancelled (parity with native prompt)', async () => {
|
|
67
|
+
const p = promptDialog('Name?', { defaultValue: 'x' })
|
|
68
|
+
document.querySelector('.modal-dialog-btn-secondary').click()
|
|
69
|
+
await expect(p).resolves.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('resolves null when dismissed via Escape', async () => {
|
|
73
|
+
const p = promptDialog('Name?', { defaultValue: 'x' })
|
|
74
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
75
|
+
await expect(p).resolves.toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('Enter submits the current input value', async () => {
|
|
79
|
+
const p = promptDialog('Name?')
|
|
80
|
+
input().value = 'viaEnter'
|
|
81
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
82
|
+
await expect(p).resolves.toBe('viaEnter')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('confirmDialog (re-exported from dialog)', () => {
|
|
87
|
+
test('still resolves true/false', async () => {
|
|
88
|
+
const p = confirmDialog('ok?')
|
|
89
|
+
document.querySelector('.modal-dialog-btn-primary').click()
|
|
90
|
+
await expect(p).resolves.toBe(true)
|
|
91
|
+
})
|
|
92
|
+
})
|