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
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
import csrfFetch from './csrf_fetch'
|
|
1
|
+
import csrfFetch, { refreshCsrfToken } from './csrf_fetch'
|
|
2
|
+
import { apiErrorFromResponse } from './api_error'
|
|
2
3
|
|
|
3
4
|
const STORAGE_KEY = 'api_queue'
|
|
4
5
|
const MAX_RETRIES = 3
|
|
5
6
|
|
|
7
|
+
// Client errors that will never succeed on retry (validation, auth, conflict,
|
|
8
|
+
// not-found). Retrying them just delays the real error and wastes round-trips,
|
|
9
|
+
// so they fail fast straight to failedItems. 408 (timeout) and 429 (rate limit)
|
|
10
|
+
// are intentionally excluded — those are worth retrying.
|
|
11
|
+
const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 404, 409])
|
|
12
|
+
|
|
13
|
+
// A 422 is ambiguous. A validation failure carries a server error payload
|
|
14
|
+
// (e.g. { errors: [...] }) and will never succeed on retry. A stale CSRF token
|
|
15
|
+
// (the meta-tag token drifts out of sync after the tab is backgrounded — see
|
|
16
|
+
// Application#set_csrf_token_header) also returns 422 but with no payload, and
|
|
17
|
+
// IS recoverable by refreshing the token and retrying.
|
|
18
|
+
function isStaleCsrf(error) {
|
|
19
|
+
return !!error && error.status === 422 && !(error.errors && error.errors.length)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRetryable(error) {
|
|
23
|
+
if (!error) return true
|
|
24
|
+
if (NON_RETRYABLE_STATUSES.has(error.status)) return false
|
|
25
|
+
// Validation 422 (has payload) fails fast; payload-less 422 (stale CSRF) retries.
|
|
26
|
+
if (error.status === 422) return isStaleCsrf(error)
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
/**
|
|
7
31
|
* API Queue Manager
|
|
8
32
|
* Manages asynchronous API requests with localStorage persistence,
|
|
@@ -267,9 +291,17 @@ class ApiQueueManager {
|
|
|
267
291
|
} catch (error) {
|
|
268
292
|
console.error('API request failed:', error, item)
|
|
269
293
|
|
|
270
|
-
// Retry logic
|
|
271
|
-
|
|
294
|
+
// Retry logic — skip retries for client errors that can't
|
|
295
|
+
// succeed on a repeat (e.g. 422 validation): fail fast so the
|
|
296
|
+
// user sees the real error immediately.
|
|
297
|
+
if (isRetryable(error) && item.retries < MAX_RETRIES) {
|
|
272
298
|
item.retries++
|
|
299
|
+
// A payload-less 422 is a stale CSRF token; refresh it first
|
|
300
|
+
// so the retry has a fresh token (the failed response itself
|
|
301
|
+
// carries none — the forgery exception skips the after_action).
|
|
302
|
+
if (isStaleCsrf(error)) {
|
|
303
|
+
await refreshCsrfToken()
|
|
304
|
+
}
|
|
273
305
|
// Move to end of queue for retry
|
|
274
306
|
this.queue.shift()
|
|
275
307
|
this.queue.push(item)
|
|
@@ -353,7 +385,9 @@ class ApiQueueManager {
|
|
|
353
385
|
const response = await csrfFetch(url, options)
|
|
354
386
|
|
|
355
387
|
if (!response.ok) {
|
|
356
|
-
|
|
388
|
+
// Surface the server's error payload (e.g. Rails { errors: [...] })
|
|
389
|
+
// instead of an opaque "HTTP 422" so the UI can show the real reason.
|
|
390
|
+
throw await apiErrorFromResponse(response)
|
|
357
391
|
}
|
|
358
392
|
|
|
359
393
|
return response
|
|
@@ -12,21 +12,38 @@ export default class CommonPopup {
|
|
|
12
12
|
this.handleOutsideClick = this.handleOutsideClick.bind(this)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
showAt(anchorRect) {
|
|
15
|
+
showAt(anchorRect, boundsElement = null) {
|
|
16
16
|
if (!this.element) return
|
|
17
17
|
|
|
18
|
+
// When set, the popup is caged inside this element's rect (e.g. the chat
|
|
19
|
+
// box) instead of the viewport — see updatePosition.
|
|
20
|
+
this._boundsElement = boundsElement
|
|
21
|
+
|
|
22
|
+
// Re-opening while already open (e.g. clicking the same typo mark twice):
|
|
23
|
+
// a listener from the previous open is still live, so the opening mousedown
|
|
24
|
+
// would bubble to it and hide() the popup right after we set display:block,
|
|
25
|
+
// leaving it stuck (the rAF below only re-flips visibility, not display).
|
|
26
|
+
// Drop stale listeners first so the opening event can't self-close it.
|
|
27
|
+
document.removeEventListener('mousedown', this.handleOutsideClick)
|
|
28
|
+
document.removeEventListener('touchstart', this.handleOutsideClick)
|
|
29
|
+
|
|
18
30
|
this.element.style.display = 'block'
|
|
19
31
|
this.element.style.visibility = 'hidden'
|
|
20
32
|
|
|
21
33
|
requestAnimationFrame(() => {
|
|
22
34
|
this.updatePosition(anchorRect)
|
|
23
35
|
this.element.style.visibility = 'visible'
|
|
36
|
+
// Register the outside-click listeners only after the opening event has
|
|
37
|
+
// finished propagating. When a popup is opened from a mousedown handler
|
|
38
|
+
// (e.g. clicking a typo highlight), registering synchronously here would
|
|
39
|
+
// let that same mousedown keep bubbling to document, hit handleOutsideClick
|
|
40
|
+
// (target is outside the popup), and immediately hide() it — the popup
|
|
41
|
+
// would open and instantly vanish. Deferring one frame avoids that race.
|
|
42
|
+
if (this.closeOnOutsideClick) {
|
|
43
|
+
document.addEventListener('mousedown', this.handleOutsideClick)
|
|
44
|
+
document.addEventListener('touchstart', this.handleOutsideClick)
|
|
45
|
+
}
|
|
24
46
|
})
|
|
25
|
-
|
|
26
|
-
if (this.closeOnOutsideClick) {
|
|
27
|
-
document.addEventListener('mousedown', this.handleOutsideClick)
|
|
28
|
-
document.addEventListener('touchstart', this.handleOutsideClick)
|
|
29
|
-
}
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
updatePosition(anchorRect) {
|
|
@@ -45,11 +62,26 @@ export default class CommonPopup {
|
|
|
45
62
|
let viewportTop = (rect?.bottom || 0) + 4
|
|
46
63
|
|
|
47
64
|
const { offsetWidth: width, offsetHeight: height } = this.element
|
|
48
|
-
const maxLeft = window.innerWidth - width - boundsPadding
|
|
49
|
-
const maxTop = window.innerHeight - height - boundsPadding
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
// Clamp within a container's rect when bounded (keeps the popup caged inside
|
|
67
|
+
// the chat box), otherwise within the viewport.
|
|
68
|
+
const bounds = this._boundsElement?.getBoundingClientRect?.()
|
|
69
|
+
let minLeft = boundsPadding
|
|
70
|
+
let minTop = boundsPadding
|
|
71
|
+
let maxLeft = window.innerWidth - width - boundsPadding
|
|
72
|
+
let maxTop = window.innerHeight - height - boundsPadding
|
|
73
|
+
if (bounds) {
|
|
74
|
+
minLeft = bounds.left + boundsPadding
|
|
75
|
+
minTop = bounds.top + boundsPadding
|
|
76
|
+
maxLeft = bounds.right - width - boundsPadding
|
|
77
|
+
maxTop = bounds.bottom - height - boundsPadding
|
|
78
|
+
// Cap size so a long list scrolls inside the box instead of spilling past it.
|
|
79
|
+
this.element.style.maxWidth = `${bounds.width - boundsPadding * 2}px`
|
|
80
|
+
this.element.style.maxHeight = `${bounds.height - boundsPadding * 2}px`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
viewportLeft = Math.max(minLeft, Math.min(viewportLeft, maxLeft))
|
|
84
|
+
viewportTop = Math.max(minTop, Math.min(viewportTop, maxTop))
|
|
53
85
|
|
|
54
86
|
const left = viewportLeft - parentRect.left + parentScrollX
|
|
55
87
|
const top = viewportTop - parentRect.top + parentScrollY
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
// Does the LIVE-entered Lexical editor color a JS-fenced block token-for-token
|
|
3
|
+
// the same as the rendered view? User report: render → edit shows different
|
|
4
|
+
// colors than the view, but refresh/cancel/title agree. This pins whether the
|
|
5
|
+
// divergence is in token CLASSES (language/tokenizer) or purely CSS container.
|
|
6
|
+
import { createEditor, $getRoot, $createParagraphNode, $isTextNode, $isElementNode, $isLineBreakNode } from "lexical"
|
|
7
|
+
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
|
8
|
+
import { ListItemNode, ListNode } from "@lexical/list"
|
|
9
|
+
import { LinkNode, AutoLinkNode } from "@lexical/link"
|
|
10
|
+
import { CodeNode, CodeHighlightNode, registerCodeHighlighting, $isCodeNode, $isCodeHighlightNode } from "@lexical/code"
|
|
11
|
+
import { $generateNodesFromDOM } from "@lexical/html"
|
|
12
|
+
import { renderMarkdown, highlightCodeBlocks } from "../../utils/markdown"
|
|
13
|
+
import { CODE_TOKEN_THEME } from "../code_token_theme"
|
|
14
|
+
import {
|
|
15
|
+
bridgeCodeFenceLanguages, detectCodeLanguage, normalizeFenceLang,
|
|
16
|
+
markLanguageResolved, isLanguageResolved, clearLanguageResolved,
|
|
17
|
+
} from "../code_languages"
|
|
18
|
+
|
|
19
|
+
const RUBY = `# frozen_string_literal: true
|
|
20
|
+
|
|
21
|
+
module CollavreCompat
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def call(receiver, method_name, *args, **kwargs)
|
|
25
|
+
params = receiver.method(method_name).parameters
|
|
26
|
+
accepted = params.filter_map { |type, name| name if %i[key keyreq].include?(type) }
|
|
27
|
+
receiver.public_send(method_name, *args, **kwargs)
|
|
28
|
+
end
|
|
29
|
+
end`
|
|
30
|
+
|
|
31
|
+
function buildEditor() {
|
|
32
|
+
const editor = createEditor({
|
|
33
|
+
namespace: "test",
|
|
34
|
+
nodes: [HeadingNode, QuoteNode, CodeNode, CodeHighlightNode, ListItemNode, ListNode, LinkNode, AutoLinkNode],
|
|
35
|
+
onError: (e) => { throw e },
|
|
36
|
+
})
|
|
37
|
+
const el = document.createElement("div")
|
|
38
|
+
el.contentEditable = "true"
|
|
39
|
+
document.body.appendChild(el)
|
|
40
|
+
editor.setRootElement(el)
|
|
41
|
+
registerCodeHighlighting(editor)
|
|
42
|
+
editor.registerNodeTransform(CodeNode, (node) => {
|
|
43
|
+
if (isLanguageResolved(editor, node.getKey())) return
|
|
44
|
+
const current = node.getLanguage()
|
|
45
|
+
const norm = normalizeFenceLang(current)
|
|
46
|
+
if (norm && norm !== "javascript") return
|
|
47
|
+
const detected = detectCodeLanguage(node.getTextContent(), current)
|
|
48
|
+
if (detected && detected !== "javascript" && detected !== current) node.setLanguage(detected)
|
|
49
|
+
})
|
|
50
|
+
return { editor, el }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function loadHtml(editor, html) {
|
|
54
|
+
editor.update(() => {
|
|
55
|
+
const root = $getRoot()
|
|
56
|
+
clearLanguageResolved(editor)
|
|
57
|
+
root.getChildren().forEach((c) => c.remove())
|
|
58
|
+
const doc = new DOMParser().parseFromString(html || "", "text/html")
|
|
59
|
+
const container = doc.body
|
|
60
|
+
bridgeCodeFenceLanguages(container)
|
|
61
|
+
const nodes = $generateNodesFromDOM(editor, container)
|
|
62
|
+
const mark = (list) => list.forEach((node) => {
|
|
63
|
+
if ($isCodeNode(node)) { if (node.getLanguage()) markLanguageResolved(editor, node.getKey()) }
|
|
64
|
+
else if ($isElementNode(node) && typeof node.getChildren === "function") mark(node.getChildren())
|
|
65
|
+
})
|
|
66
|
+
mark(nodes)
|
|
67
|
+
let pending = null
|
|
68
|
+
const flush = () => { if (pending) { root.append(pending); pending = null } }
|
|
69
|
+
nodes.forEach((node) => {
|
|
70
|
+
const inline = $isTextNode(node) || $isLineBreakNode(node) || ($isElementNode(node) && node.isInline())
|
|
71
|
+
if (inline) { if (!pending) pending = $createParagraphNode(); pending.append(node); return }
|
|
72
|
+
flush(); root.append(node)
|
|
73
|
+
})
|
|
74
|
+
flush()
|
|
75
|
+
}, { discrete: true })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sequence of [class, text] for every COLORED token span in the VIEW DOM,
|
|
79
|
+
// ignoring structure (linebreaks/tabs/untokenized whitespace) — what determines color.
|
|
80
|
+
function coloredViewTokens(rootEl) {
|
|
81
|
+
const code = rootEl.querySelector("code") || rootEl
|
|
82
|
+
return Array.from(code.querySelectorAll("span"))
|
|
83
|
+
.filter((s) => /(^|\s)lexical-token-/.test(s.className))
|
|
84
|
+
.map((s) => [s.className, s.textContent])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Same sequence from the EDITOR's node state: each CodeHighlightNode maps its
|
|
88
|
+
// highlightType through the SHARED CODE_TOKEN_THEME exactly as the rendered span
|
|
89
|
+
// would. Untyped highlight nodes (whitespace) carry no class → excluded, mirroring
|
|
90
|
+
// the view's bare-text whitespace.
|
|
91
|
+
function coloredEditorTokens(editor) {
|
|
92
|
+
const out = []
|
|
93
|
+
editor.read(() => {
|
|
94
|
+
$getRoot().getChildren().forEach((c) => {
|
|
95
|
+
if (!$isCodeNode(c)) return
|
|
96
|
+
c.getChildren().forEach((child) => {
|
|
97
|
+
if (!$isCodeHighlightNode(child)) return
|
|
98
|
+
const type = child.getHighlightType()
|
|
99
|
+
const cls = type ? CODE_TOKEN_THEME[type] : undefined
|
|
100
|
+
if (cls) out.push([cls, child.getTextContent()])
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("edit ↔ view token-color parity for explicit javascript", () => {
|
|
108
|
+
test("live-entered editor and rendered view emit the same colored-token sequence", () => {
|
|
109
|
+
const { editor } = buildEditor()
|
|
110
|
+
loadHtml(editor, renderMarkdown("```javascript\n" + RUBY + "\n```"))
|
|
111
|
+
const editorTokens = coloredEditorTokens(editor)
|
|
112
|
+
|
|
113
|
+
const viewDiv = document.createElement("div")
|
|
114
|
+
viewDiv.innerHTML = '<pre lang="javascript"><code>' + RUBY.replace(/&/g, "&").replace(/</g, "<") + "</code></pre>"
|
|
115
|
+
highlightCodeBlocks(viewDiv)
|
|
116
|
+
const viewTokens = coloredViewTokens(viewDiv)
|
|
117
|
+
|
|
118
|
+
expect(editorTokens.length).toBeGreaterThan(0)
|
|
119
|
+
expect(editorTokens).toEqual(viewTokens)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
// Integration test for the markdown→rich language round-trip. Mirrors the
|
|
3
|
+
// InlineLexicalEditor wiring: bridge the imported HTML, mark code blocks whose
|
|
4
|
+
// language came from an explicit source label, register the detection transform
|
|
5
|
+
// that honors resolved languages and only re-detects unlabeled/new blocks.
|
|
6
|
+
import { createEditor, $getRoot, $createParagraphNode, $createTextNode, $isTextNode, $isElementNode, $isLineBreakNode } from "lexical"
|
|
7
|
+
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
|
8
|
+
import { ListItemNode, ListNode } from "@lexical/list"
|
|
9
|
+
import { LinkNode, AutoLinkNode } from "@lexical/link"
|
|
10
|
+
import { CodeNode, CodeHighlightNode, registerCodeHighlighting, $isCodeNode, $createCodeNode } from "@lexical/code"
|
|
11
|
+
import { $generateNodesFromDOM } from "@lexical/html"
|
|
12
|
+
import { $convertToMarkdownString } from "@lexical/markdown"
|
|
13
|
+
import { MARKDOWN_TRANSFORMERS, normalizeMarkdownBlankLines } from "../../lexical/markdown_serialize"
|
|
14
|
+
import { renderMarkdown } from "../../utils/markdown"
|
|
15
|
+
import {
|
|
16
|
+
bridgeCodeFenceLanguages, detectCodeLanguage, normalizeFenceLang,
|
|
17
|
+
markLanguageResolved, isLanguageResolved, clearLanguageResolved,
|
|
18
|
+
} from "../code_languages"
|
|
19
|
+
|
|
20
|
+
const RUBY = `module CollavreCompat
|
|
21
|
+
module_function
|
|
22
|
+
def call(receiver, method_name, *args, **kwargs)
|
|
23
|
+
params = receiver.method(method_name).parameters
|
|
24
|
+
receiver.public_send(method_name, *args, **kwargs)
|
|
25
|
+
end
|
|
26
|
+
end`
|
|
27
|
+
|
|
28
|
+
function buildEditor() {
|
|
29
|
+
const editor = createEditor({
|
|
30
|
+
namespace: "test",
|
|
31
|
+
nodes: [HeadingNode, QuoteNode, CodeNode, CodeHighlightNode, ListItemNode, ListNode, LinkNode, AutoLinkNode],
|
|
32
|
+
onError: (e) => { throw e },
|
|
33
|
+
})
|
|
34
|
+
const el = document.createElement("div")
|
|
35
|
+
el.contentEditable = "true"
|
|
36
|
+
document.body.appendChild(el)
|
|
37
|
+
editor.setRootElement(el)
|
|
38
|
+
registerCodeHighlighting(editor)
|
|
39
|
+
editor.registerNodeTransform(CodeNode, (node) => {
|
|
40
|
+
if (isLanguageResolved(editor, node.getKey())) return
|
|
41
|
+
const current = node.getLanguage()
|
|
42
|
+
const norm = normalizeFenceLang(current)
|
|
43
|
+
if (norm && norm !== "javascript") return
|
|
44
|
+
const detected = detectCodeLanguage(node.getTextContent(), current)
|
|
45
|
+
if (detected && detected !== "javascript" && detected !== current) {
|
|
46
|
+
node.setLanguage(detected)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
return editor
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadHtml(editor, html) {
|
|
53
|
+
editor.update(() => {
|
|
54
|
+
const root = $getRoot()
|
|
55
|
+
clearLanguageResolved(editor)
|
|
56
|
+
root.getChildren().forEach((c) => c.remove())
|
|
57
|
+
const doc = new DOMParser().parseFromString(html || "", "text/html")
|
|
58
|
+
const container = doc.body
|
|
59
|
+
bridgeCodeFenceLanguages(container)
|
|
60
|
+
const nodes = $generateNodesFromDOM(editor, container)
|
|
61
|
+
const mark = (list) => list.forEach((node) => {
|
|
62
|
+
if ($isCodeNode(node)) { if (node.getLanguage()) markLanguageResolved(editor, node.getKey()) }
|
|
63
|
+
else if ($isElementNode(node) && typeof node.getChildren === "function") mark(node.getChildren())
|
|
64
|
+
})
|
|
65
|
+
mark(nodes)
|
|
66
|
+
let pending = null
|
|
67
|
+
const flush = () => { if (pending) { root.append(pending); pending = null } }
|
|
68
|
+
nodes.forEach((node) => {
|
|
69
|
+
const inline = $isTextNode(node) || $isLineBreakNode(node) || ($isElementNode(node) && node.isInline())
|
|
70
|
+
if (inline) { if (!pending) pending = $createParagraphNode(); pending.append(node); return }
|
|
71
|
+
flush(); root.append(node)
|
|
72
|
+
})
|
|
73
|
+
flush()
|
|
74
|
+
}, { discrete: true })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function codeLang(editor) {
|
|
78
|
+
let lang = null
|
|
79
|
+
editor.read(() => { $getRoot().getChildren().forEach((c) => { if ($isCodeNode(c)) lang = c.getLanguage() }) })
|
|
80
|
+
return lang
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fence(editor) {
|
|
84
|
+
let md = ""
|
|
85
|
+
editor.update(() => { md = normalizeMarkdownBlankLines($convertToMarkdownString(MARKDOWN_TRANSFORMERS)) }, { discrete: true })
|
|
86
|
+
return md.split("\n")[0]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("code-block language round-trip (markdown ↔ rich)", () => {
|
|
90
|
+
test("explicit javascript fence over ruby content is HONORED (not re-detected)", () => {
|
|
91
|
+
const editor = buildEditor()
|
|
92
|
+
loadHtml(editor, renderMarkdown("```javascript\n" + RUBY + "\n```"))
|
|
93
|
+
expect(codeLang(editor)).toBe("javascript")
|
|
94
|
+
expect(fence(editor)).toBe("```javascript")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("explicit python fence over ruby content is honored", () => {
|
|
98
|
+
const editor = buildEditor()
|
|
99
|
+
loadHtml(editor, renderMarkdown("```python\n" + RUBY + "\n```"))
|
|
100
|
+
expect(codeLang(editor)).toBe("python")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("unlabeled ruby content is auto-detected to ruby", () => {
|
|
104
|
+
const editor = buildEditor()
|
|
105
|
+
loadHtml(editor, renderMarkdown("```\n" + RUBY + "\n```"))
|
|
106
|
+
expect(codeLang(editor)).toBe("ruby")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("a NEW (typed) code block still auto-detects from content", () => {
|
|
110
|
+
const editor = buildEditor()
|
|
111
|
+
editor.update(() => {
|
|
112
|
+
const root = $getRoot()
|
|
113
|
+
clearLanguageResolved(editor)
|
|
114
|
+
root.getChildren().forEach((c) => c.remove())
|
|
115
|
+
const code = $createCodeNode() // no language: simulates toolbar insert
|
|
116
|
+
code.append($createTextNode(RUBY))
|
|
117
|
+
root.append(code)
|
|
118
|
+
}, { discrete: true })
|
|
119
|
+
expect(codeLang(editor)).toBe("ruby")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("server-reopen form <pre lang=javascript> is also honored", () => {
|
|
123
|
+
const editor = buildEditor()
|
|
124
|
+
loadHtml(editor, '<pre lang="javascript"><code>' + RUBY + "</code></pre>")
|
|
125
|
+
expect(codeLang(editor)).toBe("javascript")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
import { highlightCodeBlocks } from "../../utils/markdown"
|
|
130
|
+
|
|
131
|
+
function renderView(html) {
|
|
132
|
+
const div = document.createElement("div")
|
|
133
|
+
div.innerHTML = html
|
|
134
|
+
highlightCodeBlocks(div)
|
|
135
|
+
return div.querySelector("pre code").innerHTML
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("rendered view honors explicit language (edit/view parity)", () => {
|
|
139
|
+
const RUBY_TOKENS = `module M\n def call(x)\n end\nend`
|
|
140
|
+
test("explicit javascript is NOT silently re-detected to ruby in the view", () => {
|
|
141
|
+
const asJs = renderView('<pre lang="javascript"><code>' + RUBY_TOKENS + "</code></pre>")
|
|
142
|
+
const asRuby = renderView('<pre lang="ruby"><code>' + RUBY_TOKENS + "</code></pre>")
|
|
143
|
+
// Different tokenizers → different markup. If js were re-detected to ruby
|
|
144
|
+
// they would be identical (the old bug).
|
|
145
|
+
expect(asJs).not.toBe(asRuby)
|
|
146
|
+
})
|
|
147
|
+
test("unlabeled block is content-detected (matches the ruby tokenization)", () => {
|
|
148
|
+
const unlabeled = renderView("<pre><code>" + RUBY_TOKENS + "</code></pre>")
|
|
149
|
+
const asRuby = renderView('<pre lang="ruby"><code>' + RUBY_TOKENS + "</code></pre>")
|
|
150
|
+
expect(unlabeled).toBe(asRuby)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { detectCodeLanguage, normalizeFenceLang, bridgeCodeFenceLanguages, DEFAULT_CODE_LANGUAGE } from "../code_languages"
|
|
2
|
+
|
|
3
|
+
const RUBY = `# frozen_string_literal: true
|
|
4
|
+
module CollavreCompat
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(receiver, method_name, *args, **kwargs)
|
|
8
|
+
params = receiver.method(method_name).parameters
|
|
9
|
+
receiver.public_send(method_name, *args, **kwargs)
|
|
10
|
+
end
|
|
11
|
+
end`
|
|
12
|
+
|
|
13
|
+
describe("normalizeFenceLang", () => {
|
|
14
|
+
it("canonicalizes aliases", () => {
|
|
15
|
+
expect(normalizeFenceLang("rb")).toBe("ruby")
|
|
16
|
+
expect(normalizeFenceLang("YML")).toBe("yaml")
|
|
17
|
+
expect(normalizeFenceLang("sh")).toBe("bash")
|
|
18
|
+
expect(normalizeFenceLang("html")).toBe("markup")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("returns empty for missing or unsafe input", () => {
|
|
22
|
+
expect(normalizeFenceLang("")).toBe("")
|
|
23
|
+
expect(normalizeFenceLang(null)).toBe("")
|
|
24
|
+
expect(normalizeFenceLang("not a lang")).toBe("")
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("detectCodeLanguage", () => {
|
|
29
|
+
it("honors an explicit, non-default fence language without re-detecting", () => {
|
|
30
|
+
// Even though this looks like Ruby, an explicit `python` fence is respected.
|
|
31
|
+
expect(detectCodeLanguage(RUBY, "python")).toBe("python")
|
|
32
|
+
expect(detectCodeLanguage(RUBY, "rb")).toBe("ruby")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("re-detects Ruby content stuck on the javascript default", () => {
|
|
36
|
+
expect(detectCodeLanguage(RUBY, "javascript")).toBe("ruby")
|
|
37
|
+
expect(detectCodeLanguage(RUBY, "")).toBe("ruby")
|
|
38
|
+
expect(detectCodeLanguage(RUBY, undefined)).toBe("ruby")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("keeps real JavaScript as javascript (detection does not beat JS for JS)", () => {
|
|
42
|
+
const js = "function add(a, b) {\n const total = a + b\n return total\n}"
|
|
43
|
+
expect(detectCodeLanguage(js, "javascript")).toBe("javascript")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("does not detect from a too-short snippet", () => {
|
|
47
|
+
expect(detectCodeLanguage("x = 1", "javascript")).toBe("javascript")
|
|
48
|
+
expect(detectCodeLanguage("x = 1", "")).toBe("")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("exposes the javascript default constant", () => {
|
|
52
|
+
expect(DEFAULT_CODE_LANGUAGE).toBe("javascript")
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("bridgeCodeFenceLanguages", () => {
|
|
57
|
+
function parse(html) {
|
|
58
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
59
|
+
return doc.body
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it("bridges commonmarker's <pre lang> to data-language", () => {
|
|
63
|
+
const c = parse('<pre lang="ruby"><code>x = 1</code></pre>')
|
|
64
|
+
bridgeCodeFenceLanguages(c)
|
|
65
|
+
expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("bridges marked's <code class=\"language-X\"> to data-language on <pre>", () => {
|
|
69
|
+
// This is the markdown→rich toggle case: renderMarkdown emits the fence
|
|
70
|
+
// language on the <code>, not the <pre>, so it was dropped on reopen.
|
|
71
|
+
const c = parse('<pre><code class="hljs language-ruby">x = 1</code></pre>')
|
|
72
|
+
bridgeCodeFenceLanguages(c)
|
|
73
|
+
expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("canonicalizes aliases while bridging", () => {
|
|
77
|
+
const c = parse('<pre><code class="language-rb">x = 1</code></pre>')
|
|
78
|
+
bridgeCodeFenceLanguages(c)
|
|
79
|
+
expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("does not overwrite an existing data-language", () => {
|
|
83
|
+
const c = parse('<pre lang="python" data-language="ruby"><code>x = 1</code></pre>')
|
|
84
|
+
bridgeCodeFenceLanguages(c)
|
|
85
|
+
expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("leaves a language-less block alone (detection handles it later)", () => {
|
|
89
|
+
const c = parse("<pre><code>x = 1</code></pre>")
|
|
90
|
+
bridgeCodeFenceLanguages(c)
|
|
91
|
+
expect(c.querySelector("pre").hasAttribute("data-language")).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
})
|