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,20 +1,50 @@
|
|
|
1
1
|
import creativesApi from '../lib/api/creatives'
|
|
2
2
|
import apiQueue from '../lib/api/queue_manager'
|
|
3
|
-
import { $
|
|
3
|
+
import { $getSelection } from 'lexical'
|
|
4
|
+
import { isSelectionAtDocumentStart, isSelectionAtDocumentEnd } from '../lib/lexical/selection_boundary'
|
|
4
5
|
import { createInlineEditor } from './lexical_inline_editor'
|
|
5
6
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
|
|
6
7
|
import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
|
|
7
8
|
import { renderMarkdown } from '../lib/utils/markdown'
|
|
8
9
|
import { reconcileMarkdownSource } from './markdown_source_reconcile'
|
|
9
10
|
import { isHtmlEmpty } from './html_content_empty'
|
|
11
|
+
import { CreativeSaveQueue } from './creative_save_queue'
|
|
12
|
+
import { confirmDialog, alertDialog } from '../lib/utils/dialog'
|
|
13
|
+
import { serverErrorMessage } from '../lib/api/api_error'
|
|
10
14
|
import yaml from 'js-yaml'
|
|
15
|
+
import {
|
|
16
|
+
treeRowElement,
|
|
17
|
+
hasDatasetValue,
|
|
18
|
+
isMarkdownEmpty,
|
|
19
|
+
readRowLevel,
|
|
20
|
+
editorPaddingForLevel,
|
|
21
|
+
} from './creative_row_editor_helpers'
|
|
22
|
+
import {
|
|
23
|
+
creativeTreeElement,
|
|
24
|
+
creativeIdFrom,
|
|
25
|
+
siblingTreeRow,
|
|
26
|
+
siblingOrderingForRow,
|
|
27
|
+
treeContainerElement,
|
|
28
|
+
nodeAfterTreeBlock,
|
|
29
|
+
normalizeRowNode,
|
|
30
|
+
childrenContainerForTree,
|
|
31
|
+
ensureChildrenContainer,
|
|
32
|
+
expandChildrenContainer,
|
|
33
|
+
moveTreeBlock,
|
|
34
|
+
listAllTreeNodes,
|
|
35
|
+
findPreviousTree,
|
|
36
|
+
getTreeLevel,
|
|
37
|
+
updateTreeLevels,
|
|
38
|
+
setTreeLevel,
|
|
39
|
+
removeTreeElement,
|
|
40
|
+
} from './creative_tree_dom'
|
|
41
|
+
import {
|
|
42
|
+
updateRowFromData,
|
|
43
|
+
inlinePayloadFromTree,
|
|
44
|
+
} from './creative_inline_payload'
|
|
11
45
|
// Import Stimulus application from the global window (set by host app)
|
|
12
46
|
const application = window.Stimulus
|
|
13
47
|
|
|
14
|
-
const BULLET_STARTING_LEVEL = 3;
|
|
15
|
-
const HEADING_INDENT_STEP_EM = 0.4;
|
|
16
|
-
const BULLET_INDENT_STEP_PX = 30;
|
|
17
|
-
|
|
18
48
|
let initialized = false;
|
|
19
49
|
let creativeEditClickHandler = null;
|
|
20
50
|
let addCreativeShortcutHandler = null;
|
|
@@ -57,24 +87,45 @@ export function initializeCreativeRowEditor() {
|
|
|
57
87
|
// In a real app, use a toast notification. For now, alert is safe.
|
|
58
88
|
// Suppress 404 errors for PATCH requests, as this likely means the item was deleted
|
|
59
89
|
// and we don't need to alert the user about it.
|
|
60
|
-
const is404 = error && error.toString().includes('404');
|
|
90
|
+
const is404 = (error && error.status === 404) || (error && error.toString().includes('404'));
|
|
61
91
|
const isPatch = item && item.method === 'PATCH';
|
|
62
92
|
|
|
63
93
|
if (!(is404 && isPatch)) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
// Prefer the server's own error message (e.g. "Description cannot be
|
|
95
|
+
// changed directly for GitHub synced content") and fall back to the
|
|
96
|
+
// generic copy only when the failure carries no usable payload.
|
|
97
|
+
const serverMessage = serverErrorMessage(error);
|
|
98
|
+
alertDialog(serverMessage || 'Failed to save changes. Please check your connection and try again.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// If the failed item matches the current creative, mark it as dirty so it can be retried.
|
|
102
|
+
// Match the id exactly — a substring test (e.g. path.includes("23")) also matches
|
|
103
|
+
// "/creatives/123", flagging the wrong row's toolbar as failed.
|
|
104
|
+
const failedCreativeId = (item.path.match(/\/creatives\/(\d+)/) || [])[1];
|
|
105
|
+
if (form.dataset.creativeId && failedCreativeId === form.dataset.creativeId) {
|
|
69
106
|
console.log('Restoring dirty state for current creative');
|
|
70
107
|
isDirty = true;
|
|
71
108
|
pendingSave = true;
|
|
109
|
+
setSaveStatus('error');
|
|
72
110
|
updateActionButtonStates();
|
|
73
111
|
}
|
|
74
112
|
});
|
|
75
113
|
|
|
76
114
|
// ... rest of initialization
|
|
77
115
|
|
|
116
|
+
// Reflect the inline editor's save lifecycle in the toolbar row.
|
|
117
|
+
// Plain JS controller can't call the i18n `t()` helper, so the localized
|
|
118
|
+
// strings are carried on the span's data-* attributes (set in the ERB).
|
|
119
|
+
// state: 'pending' | 'saving' | 'saved' | 'error' | '' (cleared).
|
|
120
|
+
// 'pending' = dirty, waiting out the debounce; 'saving' = request in flight.
|
|
121
|
+
function setSaveStatus(state) {
|
|
122
|
+
const el = document.getElementById('inline-save-status');
|
|
123
|
+
if (!el) return;
|
|
124
|
+
const label = state ? el.dataset[`label${state.charAt(0).toUpperCase()}${state.slice(1)}`] : '';
|
|
125
|
+
el.textContent = label || '';
|
|
126
|
+
el.dataset.state = state || '';
|
|
127
|
+
}
|
|
128
|
+
|
|
78
129
|
const form = document.getElementById('inline-edit-form-element');
|
|
79
130
|
const descriptionInput = document.getElementById('inline-creative-description');
|
|
80
131
|
const editorContainer = template.querySelector('[data-lexical-editor-root]');
|
|
@@ -112,6 +163,7 @@ export function initializeCreativeRowEditor() {
|
|
|
112
163
|
|
|
113
164
|
// Markdown editor elements
|
|
114
165
|
const contentTypeInput = document.getElementById('inline-content-type');
|
|
166
|
+
const markdownEditorInput = document.getElementById('inline-markdown-editor');
|
|
115
167
|
const markdownSourceInput = document.getElementById('inline-markdown-source');
|
|
116
168
|
const markdownWrapper = document.getElementById('markdown-editor-wrapper');
|
|
117
169
|
const markdownTextarea = document.getElementById('markdown-editor-textarea');
|
|
@@ -149,10 +201,12 @@ export function initializeCreativeRowEditor() {
|
|
|
149
201
|
|
|
150
202
|
let currentTree = null;
|
|
151
203
|
let currentRowElement = null;
|
|
152
|
-
|
|
204
|
+
// Owns the autosave request lifecycle: the debounce timer and the
|
|
205
|
+
// single-flight guard around the in-flight request. `pendingSave` /
|
|
206
|
+
// `isDirty` remain the editor's dirty-tracking flags (what changed), while
|
|
207
|
+
// the queue governs when the request runs. See creative_save_queue.js.
|
|
208
|
+
const saveQueue = new CreativeSaveQueue();
|
|
153
209
|
let pendingSave = false;
|
|
154
|
-
let saving = false;
|
|
155
|
-
let savePromise = Promise.resolve();
|
|
156
210
|
let uploadsPending = false;
|
|
157
211
|
let uploadCompletionPromise = null;
|
|
158
212
|
let resolveUploadCompletion = null;
|
|
@@ -211,104 +265,16 @@ export function initializeCreativeRowEditor() {
|
|
|
211
265
|
}
|
|
212
266
|
}
|
|
213
267
|
|
|
214
|
-
function treeRowElement(node) {
|
|
215
|
-
return node && node.closest ? node.closest('creative-tree-row') : null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
268
|
function currentRowHasChildren() {
|
|
219
269
|
const row = currentRowElement || (currentTree ? treeRowElement(currentTree) : null);
|
|
220
270
|
if (!row) return false;
|
|
221
271
|
return !!(row.hasChildren || row.getAttribute?.('has-children'));
|
|
222
272
|
}
|
|
223
273
|
|
|
224
|
-
function hasDatasetValue(element, key) {
|
|
225
|
-
if (!element || !element.dataset) return false;
|
|
226
|
-
return Object.prototype.hasOwnProperty.call(element.dataset, key);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function setRowDatasetValue(row, key, value) {
|
|
230
|
-
if (!row || !row.dataset) return;
|
|
231
|
-
if (value === undefined || value === null) {
|
|
232
|
-
delete row.dataset[key];
|
|
233
|
-
} else {
|
|
234
|
-
row.dataset[key] = String(value);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function updateRowFromData(row, data) {
|
|
239
|
-
if (!row || !data) return;
|
|
240
|
-
const descriptionHtml = data.description || '';
|
|
241
|
-
const rawHtml = data.description_raw_html || descriptionHtml;
|
|
242
|
-
row.descriptionHtml = descriptionHtml;
|
|
243
|
-
setRowDatasetValue(row, 'descriptionHtml', descriptionHtml);
|
|
244
|
-
setRowDatasetValue(row, 'descriptionRawHtml', rawHtml);
|
|
245
|
-
if (data.progress_html != null) {
|
|
246
|
-
row.progressHtml = data.progress_html;
|
|
247
|
-
setRowDatasetValue(row, 'progressHtml', data.progress_html);
|
|
248
|
-
}
|
|
249
|
-
if (Object.prototype.hasOwnProperty.call(data, 'progress')) {
|
|
250
|
-
setRowDatasetValue(row, 'progressValue', data.progress ?? '');
|
|
251
|
-
}
|
|
252
|
-
if (Object.prototype.hasOwnProperty.call(data, 'origin_id')) {
|
|
253
|
-
setRowDatasetValue(row, 'originId', data.origin_id ?? '');
|
|
254
|
-
}
|
|
255
|
-
if (Object.prototype.hasOwnProperty.call(data, 'content_type')) {
|
|
256
|
-
setRowDatasetValue(row, 'contentType', data.content_type ?? '');
|
|
257
|
-
}
|
|
258
|
-
if (Object.prototype.hasOwnProperty.call(data, 'markdown_source')) {
|
|
259
|
-
setRowDatasetValue(row, 'markdownSource', data.markdown_source ?? '');
|
|
260
|
-
}
|
|
261
|
-
if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
|
|
262
|
-
if (data.has_children) {
|
|
263
|
-
row.setAttribute('has-children', '');
|
|
264
|
-
row.hasChildren = true;
|
|
265
|
-
} else {
|
|
266
|
-
row.removeAttribute('has-children');
|
|
267
|
-
row.hasChildren = false;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
if (typeof row.requestUpdate === 'function') {
|
|
271
|
-
row.requestUpdate();
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function inlinePayloadFromTree(tree) {
|
|
276
|
-
if (!tree) return null;
|
|
277
|
-
const row = treeRowElement(tree);
|
|
278
|
-
if (!row) return null;
|
|
279
|
-
|
|
280
|
-
// Relax validation - allow loading with partial data for instant UI
|
|
281
|
-
const hasDescription = hasDatasetValue(row, 'descriptionRawHtml') || hasDatasetValue(row, 'descriptionHtml');
|
|
282
|
-
const hasProgress = hasDatasetValue(row, 'progressValue');
|
|
283
|
-
|
|
284
|
-
// Only require ID to be present
|
|
285
|
-
const id = tree.dataset?.id;
|
|
286
|
-
if (!id) return null;
|
|
287
|
-
|
|
288
|
-
const rawHtml = hasDatasetValue(row, 'descriptionRawHtml') ? row.dataset.descriptionRawHtml : row.dataset.descriptionHtml || '';
|
|
289
|
-
const description = row.dataset.descriptionHtml || rawHtml || '';
|
|
290
|
-
const progressValue = hasProgress ? Number(row.dataset.progressValue ?? 0) : 0;
|
|
291
|
-
const parentId = tree.dataset?.parentId || '';
|
|
292
|
-
|
|
293
|
-
return {
|
|
294
|
-
id: id,
|
|
295
|
-
description,
|
|
296
|
-
description_raw_html: rawHtml,
|
|
297
|
-
origin_id: row.dataset?.originId || '',
|
|
298
|
-
parent_id: parentId,
|
|
299
|
-
progress: Number.isNaN(progressValue) ? 0 : progressValue,
|
|
300
|
-
content_type: row.dataset?.contentType || null,
|
|
301
|
-
markdown_source: row.dataset?.markdownSource || null
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function isMarkdownEmpty(md) {
|
|
306
|
-
return !md || md.trim().length === 0;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
274
|
function activateMarkdownMode(source) {
|
|
310
275
|
markdownMode = true;
|
|
311
276
|
if (contentTypeInput) contentTypeInput.value = 'markdown';
|
|
277
|
+
if (markdownEditorInput) markdownEditorInput.value = 'source';
|
|
312
278
|
if (markdownTextarea) markdownTextarea.value = source || '';
|
|
313
279
|
if (markdownWrapper) markdownWrapper.style.display = '';
|
|
314
280
|
if (editorContainer) editorContainer.style.display = 'none';
|
|
@@ -323,6 +289,7 @@ export function initializeCreativeRowEditor() {
|
|
|
323
289
|
function deactivateMarkdownMode() {
|
|
324
290
|
markdownMode = false;
|
|
325
291
|
if (contentTypeInput) contentTypeInput.value = 'html';
|
|
292
|
+
if (markdownEditorInput) markdownEditorInput.value = '';
|
|
326
293
|
if (markdownSourceInput) markdownSourceInput.value = '';
|
|
327
294
|
if (markdownWrapper) markdownWrapper.style.display = 'none';
|
|
328
295
|
if (editorContainer) editorContainer.style.display = '';
|
|
@@ -349,26 +316,42 @@ export function initializeCreativeRowEditor() {
|
|
|
349
316
|
const content = data.description_raw_html || data.description || '';
|
|
350
317
|
descriptionInput.value = content;
|
|
351
318
|
|
|
352
|
-
//
|
|
319
|
+
// Markdown is the canonical storage format for BOTH editors now. Which
|
|
320
|
+
// surface opens is decided by the persisted editor preference: only
|
|
321
|
+
// explicitly rich-authored Markdown reopens in Lexical; "source" and
|
|
322
|
+
// legacy (no preference) Markdown reopen in the advanced textarea.
|
|
353
323
|
const isMarkdown = data.content_type === 'markdown';
|
|
354
|
-
|
|
324
|
+
const useTextarea = isMarkdown && data.markdown_editor !== 'rich';
|
|
325
|
+
if (useTextarea) {
|
|
355
326
|
activateMarkdownMode(data.markdown_source || '');
|
|
356
327
|
// Also load Lexical with HTML for fallback/switching
|
|
357
328
|
lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
|
|
358
329
|
} else {
|
|
359
330
|
deactivateMarkdownMode();
|
|
331
|
+
if (isMarkdown) {
|
|
332
|
+
// Rich-authored Markdown: prime the hidden fields so a no-edit save
|
|
333
|
+
// (move, progress toggle) preserves Markdown canonical instead of
|
|
334
|
+
// demoting back to HTML before the first Lexical change fires.
|
|
335
|
+
if (contentTypeInput) contentTypeInput.value = 'markdown';
|
|
336
|
+
if (markdownSourceInput) markdownSourceInput.value = data.markdown_source || '';
|
|
337
|
+
if (markdownEditorInput) markdownEditorInput.value = 'rich';
|
|
338
|
+
}
|
|
360
339
|
lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
|
|
361
340
|
}
|
|
362
341
|
|
|
363
342
|
pendingSave = false;
|
|
364
|
-
//
|
|
365
|
-
|
|
343
|
+
// Dirty detection is HTML-based for the rich surface (compares the editor's
|
|
344
|
+
// HTML projection), and Markdown-source-based for the textarea surface.
|
|
345
|
+
originalContent = useTextarea ? (data.markdown_source || '') : content;
|
|
366
346
|
isDirty = false;
|
|
347
|
+
setSaveStatus('');
|
|
367
348
|
const progressNumber = Number(data.progress ?? 0);
|
|
368
349
|
const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
|
|
369
350
|
setProgressState(normalizedProgress);
|
|
370
351
|
updateProgressInputAvailability(normalizedProgress);
|
|
371
352
|
completionCascadePending = false;
|
|
353
|
+
// parentId convention: `data-parent-id` is always present ("" === root),
|
|
354
|
+
// so this DOM fallback is unambiguous when server data lacks parent_id.
|
|
372
355
|
const fallbackParent = tree?.dataset?.parentId || '';
|
|
373
356
|
parentInput.value = data.parent_id ?? fallbackParent ?? '';
|
|
374
357
|
beforeInput.value = '';
|
|
@@ -384,7 +367,12 @@ export function initializeCreativeRowEditor() {
|
|
|
384
367
|
const effectiveParent = parentInput.value;
|
|
385
368
|
if (unconvertBtn) unconvertBtn.style.display = effectiveParent ? '' : 'none';
|
|
386
369
|
originalProgress = normalizedProgress;
|
|
387
|
-
|
|
370
|
+
// Focus the Lexical editor whenever it is the active surface. Gating on
|
|
371
|
+
// `!isMarkdown` was correct when `content_type === 'markdown'` always meant
|
|
372
|
+
// the textarea surface, but rich-authored Markdown now reopens in Lexical
|
|
373
|
+
// (markdown_editor === 'rich'), so use `!useTextarea` to also focus it.
|
|
374
|
+
// The textarea surface focuses itself in activateMarkdownMode().
|
|
375
|
+
if (!useTextarea) {
|
|
388
376
|
lexicalEditor.focus();
|
|
389
377
|
}
|
|
390
378
|
updateActionButtonStates();
|
|
@@ -400,219 +388,6 @@ export function initializeCreativeRowEditor() {
|
|
|
400
388
|
}
|
|
401
389
|
}
|
|
402
390
|
|
|
403
|
-
function siblingTreeRow(row, direction) {
|
|
404
|
-
if (!row) return null;
|
|
405
|
-
const step = direction === 'previous' ? 'previousSibling' : 'nextSibling';
|
|
406
|
-
let node = row[step];
|
|
407
|
-
while (node) {
|
|
408
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
409
|
-
node = node[step];
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
if (node.matches?.('creative-tree-row')) return node;
|
|
413
|
-
if (node.classList?.contains?.('creative-children')) {
|
|
414
|
-
node = node[step];
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
node = node[step];
|
|
418
|
-
}
|
|
419
|
-
return null;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function siblingOrderingForRow(row) {
|
|
423
|
-
const beforeRow = siblingTreeRow(row, 'next');
|
|
424
|
-
const afterRow = siblingTreeRow(row, 'previous');
|
|
425
|
-
return {
|
|
426
|
-
beforeId: beforeRow ? creativeIdFrom(beforeRow) : '',
|
|
427
|
-
afterId: afterRow ? creativeIdFrom(afterRow) : ''
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function treeContainerElement(tree) {
|
|
432
|
-
if (!tree) return null;
|
|
433
|
-
const row = treeRowElement(tree);
|
|
434
|
-
if (row && row.parentNode) return row.parentNode;
|
|
435
|
-
return tree.parentNode;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function nodeAfterTreeBlock(tree) {
|
|
439
|
-
if (!tree) return null;
|
|
440
|
-
const row = treeRowElement(tree);
|
|
441
|
-
if (!row) return tree.nextSibling;
|
|
442
|
-
let node = row.nextSibling;
|
|
443
|
-
while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
|
|
444
|
-
const treeId = tree.dataset?.id;
|
|
445
|
-
if (treeId) {
|
|
446
|
-
const childrenContainer = document.getElementById(`creative-children-${treeId}`);
|
|
447
|
-
if (childrenContainer && childrenContainer.parentNode === row.parentNode && node === childrenContainer) {
|
|
448
|
-
node = childrenContainer.nextSibling;
|
|
449
|
-
while (node && node.nodeType === Node.TEXT_NODE) node = node.nextSibling;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return node;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function normalizeRowNode(node) {
|
|
456
|
-
if (!node) return null;
|
|
457
|
-
if (node.matches && node.matches('creative-tree-row')) return node;
|
|
458
|
-
if (node.classList && node.classList.contains('creative-tree')) {
|
|
459
|
-
const row = treeRowElement(node);
|
|
460
|
-
return row || node;
|
|
461
|
-
}
|
|
462
|
-
return node;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function childrenContainerForTree(tree) {
|
|
466
|
-
if (!tree) return null;
|
|
467
|
-
const treeId = tree.dataset?.id;
|
|
468
|
-
if (treeId) {
|
|
469
|
-
const byId = document.getElementById(`creative-children-${treeId}`);
|
|
470
|
-
if (byId) return byId;
|
|
471
|
-
}
|
|
472
|
-
if (tree.children && tree.children.length > 0) {
|
|
473
|
-
for (const child of tree.children) {
|
|
474
|
-
if (child && child.classList && child.classList.contains('creative-children')) {
|
|
475
|
-
return child;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
const row = treeRowElement(tree);
|
|
480
|
-
if (row) {
|
|
481
|
-
let sibling = row.nextElementSibling;
|
|
482
|
-
while (sibling) {
|
|
483
|
-
if (sibling.matches?.('creative-tree-row')) break;
|
|
484
|
-
if (sibling.classList?.contains('creative-children')) return sibling;
|
|
485
|
-
sibling = sibling.nextElementSibling;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return null;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function buildChildrenLoadUrl(parentId, childLevel, selectMode) {
|
|
492
|
-
const params = new URLSearchParams();
|
|
493
|
-
params.set('level', String(childLevel));
|
|
494
|
-
params.set('select_mode', selectMode ? '1' : '0');
|
|
495
|
-
return `/creatives/${parentId}/children?${params.toString()}`;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function ensureChildrenContainer(tree) {
|
|
499
|
-
if (!tree) return null;
|
|
500
|
-
let container = childrenContainerForTree(tree);
|
|
501
|
-
if (container) return container;
|
|
502
|
-
const parentId = tree.dataset?.id;
|
|
503
|
-
if (!parentId) return null;
|
|
504
|
-
container = document.createElement('div');
|
|
505
|
-
container.className = 'creative-children';
|
|
506
|
-
container.id = `creative-children-${parentId}`;
|
|
507
|
-
const parentRow = treeRowElement(tree);
|
|
508
|
-
const parentLevel = readRowLevel(parentRow) || 1;
|
|
509
|
-
const childLevel = parentLevel + 1;
|
|
510
|
-
const selectModeActive = parentRow?.hasAttribute?.('select-mode') ? 1 : 0;
|
|
511
|
-
container.dataset.loadUrl = buildChildrenLoadUrl(parentId, childLevel, selectModeActive);
|
|
512
|
-
container.dataset.expanded = 'true';
|
|
513
|
-
if (container.dataset.loaded) delete container.dataset.loaded;
|
|
514
|
-
const row = treeRowElement(tree);
|
|
515
|
-
const parentContainer = row?.parentNode || tree.parentNode;
|
|
516
|
-
if (parentContainer) {
|
|
517
|
-
const afterRow = row?.nextSibling;
|
|
518
|
-
if (afterRow) {
|
|
519
|
-
parentContainer.insertBefore(container, afterRow);
|
|
520
|
-
} else {
|
|
521
|
-
parentContainer.appendChild(container);
|
|
522
|
-
}
|
|
523
|
-
} else {
|
|
524
|
-
tree.appendChild(container);
|
|
525
|
-
}
|
|
526
|
-
return container;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function expandChildrenContainer(container) {
|
|
530
|
-
if (!container) return;
|
|
531
|
-
container.style.display = '';
|
|
532
|
-
if (container.dataset) {
|
|
533
|
-
container.dataset.expanded = 'true';
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function moveTreeBlock(tree, targetContainer, referenceNode = null) {
|
|
538
|
-
if (!tree || !targetContainer) return;
|
|
539
|
-
const row = treeRowElement(tree);
|
|
540
|
-
if (!row) return;
|
|
541
|
-
const nodesToMove = [row];
|
|
542
|
-
const childContainer = childrenContainerForTree(tree);
|
|
543
|
-
if (childContainer) nodesToMove.push(childContainer);
|
|
544
|
-
nodesToMove.forEach((node) => {
|
|
545
|
-
if (!node) return;
|
|
546
|
-
if (referenceNode) {
|
|
547
|
-
targetContainer.insertBefore(node, referenceNode);
|
|
548
|
-
} else {
|
|
549
|
-
targetContainer.appendChild(node);
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function listAllTreeNodes() {
|
|
555
|
-
const root = document.getElementById('creatives');
|
|
556
|
-
if (root) return Array.from(root.querySelectorAll('.creative-tree'));
|
|
557
|
-
return Array.from(document.querySelectorAll('.creative-tree'));
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function findPreviousTree(tree) {
|
|
561
|
-
if (!tree) return null;
|
|
562
|
-
const nodes = listAllTreeNodes();
|
|
563
|
-
const index = nodes.indexOf(tree);
|
|
564
|
-
if (index <= 0) return null;
|
|
565
|
-
const currentLevel = getTreeLevel(tree);
|
|
566
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
567
|
-
const candidate = nodes[i];
|
|
568
|
-
if (!candidate) continue;
|
|
569
|
-
const candidateLevel = getTreeLevel(candidate);
|
|
570
|
-
if (candidateLevel === currentLevel) return candidate;
|
|
571
|
-
if (candidateLevel < currentLevel) return null;
|
|
572
|
-
}
|
|
573
|
-
return null;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function getTreeLevel(tree) {
|
|
577
|
-
if (!tree) return 1;
|
|
578
|
-
const levelValue = Number(tree.dataset?.level);
|
|
579
|
-
if (!Number.isNaN(levelValue) && levelValue > 0) {
|
|
580
|
-
return levelValue;
|
|
581
|
-
}
|
|
582
|
-
const row = treeRowElement(tree);
|
|
583
|
-
return readRowLevel(row) || 1;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function updateTreeLevels(tree, delta) {
|
|
587
|
-
if (!tree || !delta) return;
|
|
588
|
-
const currentLevel = Number(tree.dataset?.level) || 1;
|
|
589
|
-
const nextLevel = Math.max(1, currentLevel + delta);
|
|
590
|
-
tree.dataset.level = String(nextLevel);
|
|
591
|
-
const row = treeRowElement(tree);
|
|
592
|
-
if (row) {
|
|
593
|
-
row.setAttribute('level', nextLevel);
|
|
594
|
-
row.level = nextLevel;
|
|
595
|
-
row.requestUpdate?.();
|
|
596
|
-
}
|
|
597
|
-
const container = childrenContainerForTree(tree);
|
|
598
|
-
if (!container) return;
|
|
599
|
-
Array.from(container.children || []).forEach((childRow) => {
|
|
600
|
-
if (!childRow.matches?.('creative-tree-row')) return;
|
|
601
|
-
const childTree = childRow.querySelector('.creative-tree');
|
|
602
|
-
if (childTree) {
|
|
603
|
-
updateTreeLevels(childTree, delta);
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function setTreeLevel(tree, targetLevel) {
|
|
609
|
-
if (!tree || typeof targetLevel !== 'number') return;
|
|
610
|
-
const currentLevel = Number(tree.dataset?.level) || 1;
|
|
611
|
-
const delta = targetLevel - currentLevel;
|
|
612
|
-
if (delta === 0) return;
|
|
613
|
-
updateTreeLevels(tree, delta);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
391
|
function updateParentChildrenState(parentId) {
|
|
617
392
|
if (!parentId) return;
|
|
618
393
|
const parentTree = document.getElementById(`creative-${parentId}`);
|
|
@@ -642,31 +417,6 @@ export function initializeCreativeRowEditor() {
|
|
|
642
417
|
scheduleSave();
|
|
643
418
|
}
|
|
644
419
|
|
|
645
|
-
function readRowLevel(row) {
|
|
646
|
-
if (!row) return null;
|
|
647
|
-
if (row.isTitle) return 0;
|
|
648
|
-
if (row.getAttribute) {
|
|
649
|
-
const levelAttr = row.getAttribute('level');
|
|
650
|
-
if (levelAttr) {
|
|
651
|
-
const parsed = Number(levelAttr);
|
|
652
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
if (typeof row.level === 'number') {
|
|
656
|
-
return row.level;
|
|
657
|
-
}
|
|
658
|
-
if (row.level) {
|
|
659
|
-
const parsed = Number(row.level);
|
|
660
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
661
|
-
}
|
|
662
|
-
const tree = row.querySelector ? row.querySelector('.creative-tree') : null;
|
|
663
|
-
if (tree && tree.dataset?.level) {
|
|
664
|
-
const parsed = Number(tree.dataset.level);
|
|
665
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
666
|
-
}
|
|
667
|
-
return 1;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
420
|
function computeNewRowLevel(parentId, referenceNode, afterId) {
|
|
671
421
|
if (parentId) {
|
|
672
422
|
const parentRow = document.querySelector(`creative-tree-row[creative-id="${parentId}"]`);
|
|
@@ -685,18 +435,6 @@ export function initializeCreativeRowEditor() {
|
|
|
685
435
|
return readRowLevel(normalized);
|
|
686
436
|
}
|
|
687
437
|
|
|
688
|
-
function editorPaddingForLevel(level) {
|
|
689
|
-
if (typeof level !== 'number' || Number.isNaN(level) || level <= 1) {
|
|
690
|
-
return '0px';
|
|
691
|
-
}
|
|
692
|
-
if (level <= BULLET_STARTING_LEVEL) {
|
|
693
|
-
const emValue = (level - 1) * HEADING_INDENT_STEP_EM;
|
|
694
|
-
return emValue ? `${emValue}em` : '0px';
|
|
695
|
-
}
|
|
696
|
-
const pxValue = (level - BULLET_STARTING_LEVEL) * BULLET_INDENT_STEP_PX;
|
|
697
|
-
return `${pxValue}px`;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
438
|
function syncInlineEditorPadding(source) {
|
|
701
439
|
if (!template) return;
|
|
702
440
|
let level = null;
|
|
@@ -709,16 +447,6 @@ export function initializeCreativeRowEditor() {
|
|
|
709
447
|
template.style.paddingLeft = paddingValue;
|
|
710
448
|
}
|
|
711
449
|
|
|
712
|
-
function removeTreeElement(tree) {
|
|
713
|
-
if (!tree) return;
|
|
714
|
-
const row = treeRowElement(tree);
|
|
715
|
-
if (row) {
|
|
716
|
-
row.remove();
|
|
717
|
-
} else if (tree.remove) {
|
|
718
|
-
tree.remove();
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
450
|
function getUploadCompletion() {
|
|
723
451
|
if (!uploadCompletionPromise) {
|
|
724
452
|
uploadCompletionPromise = new Promise(resolve => {
|
|
@@ -965,27 +693,6 @@ export function initializeCreativeRowEditor() {
|
|
|
965
693
|
if (row) row.style.display = '';
|
|
966
694
|
}
|
|
967
695
|
|
|
968
|
-
function creativeTreeElement(node) {
|
|
969
|
-
if (!node) return null;
|
|
970
|
-
if (node.classList && node.classList.contains('creative-tree')) return node;
|
|
971
|
-
if (node.querySelector) {
|
|
972
|
-
const inner = node.querySelector('.creative-tree');
|
|
973
|
-
if (inner) return inner;
|
|
974
|
-
}
|
|
975
|
-
return null;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function creativeIdFrom(node) {
|
|
979
|
-
const treeEl = creativeTreeElement(node);
|
|
980
|
-
if (treeEl && treeEl.dataset) {
|
|
981
|
-
return treeEl.dataset.id || '';
|
|
982
|
-
}
|
|
983
|
-
if (node?.getAttribute) {
|
|
984
|
-
return node.getAttribute('creative-id') || node.getAttribute('data-id') || '';
|
|
985
|
-
}
|
|
986
|
-
return '';
|
|
987
|
-
}
|
|
988
|
-
|
|
989
696
|
function insertRow(tree, data) {
|
|
990
697
|
if (tree.querySelector('.creative-row')) return;
|
|
991
698
|
const row = document.createElement('div');
|
|
@@ -1032,10 +739,18 @@ export function initializeCreativeRowEditor() {
|
|
|
1032
739
|
}
|
|
1033
740
|
|
|
1034
741
|
function saveForm(tree = currentTree, parentId = parentInput.value) {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
742
|
+
// Reflect the in-flight save immediately, *before* awaiting pending uploads.
|
|
743
|
+
// Direct-save callers (progress checkbox, structure moves) bypass
|
|
744
|
+
// scheduleSave(), so without this an attachment upload still in flight would
|
|
745
|
+
// let the toolbar keep a stale "saved" label for the whole upload window.
|
|
746
|
+
// Gated on the editor still being bound to this row (mirrors applySaveStatus).
|
|
747
|
+
if (tree === currentTree && (pendingSave || saveQueue.saving || isDirty)) setSaveStatus('pending');
|
|
748
|
+
// Build and run the persist request under the queue's single-flight
|
|
749
|
+
// guard: runExclusive cancels any pending debounce, invokes performSave,
|
|
750
|
+
// and (only when it returns a request for non-empty content) holds the
|
|
751
|
+
// in-flight state until that request settles. Returning null keeps the
|
|
752
|
+
// queue idle without ever flipping the in-flight flag.
|
|
753
|
+
function performSave() {
|
|
1039
754
|
// Sync markdown form fields before saving
|
|
1040
755
|
if (markdownMode) syncMarkdownToForm();
|
|
1041
756
|
|
|
@@ -1044,13 +759,22 @@ export function initializeCreativeRowEditor() {
|
|
|
1044
759
|
: isHtmlEmpty(descriptionInput.value);
|
|
1045
760
|
if (isEmpty) {
|
|
1046
761
|
pendingSave = false;
|
|
1047
|
-
|
|
762
|
+
// Nothing to persist — don't strand the "pending" label set above.
|
|
763
|
+
if (tree === currentTree) setSaveStatus('');
|
|
764
|
+
return null;
|
|
1048
765
|
}
|
|
1049
766
|
|
|
1050
767
|
const method = methodInput.value === 'patch' ? 'PATCH' : 'POST';
|
|
1051
768
|
pendingSave = false;
|
|
1052
|
-
if (!form.action) return
|
|
1053
|
-
|
|
769
|
+
if (!form.action) return null;
|
|
770
|
+
// Only reflect this save's outcome while the editor is still bound to the
|
|
771
|
+
// creative it started on. If the user navigates to another row mid-flight
|
|
772
|
+
// (move() reattaches the shared #inline-save-status span to the new row),
|
|
773
|
+
// a late completion must not mislabel the newly opened row.
|
|
774
|
+
const applySaveStatus = function (state) {
|
|
775
|
+
if (tree === currentTree) setSaveStatus(state);
|
|
776
|
+
};
|
|
777
|
+
applySaveStatus('saving');
|
|
1054
778
|
|
|
1055
779
|
// Capture values being saved to update dirty state on success
|
|
1056
780
|
// NOTE: `let` (not `const`) — when the server rewrites markdown_source
|
|
@@ -1068,8 +792,11 @@ export function initializeCreativeRowEditor() {
|
|
|
1068
792
|
if (progressHiddenInput) progressHiddenInput.disabled = true;
|
|
1069
793
|
}
|
|
1070
794
|
|
|
1071
|
-
|
|
1072
|
-
if (!r.ok)
|
|
795
|
+
return creativesApi.save(form.action, method, form).then(function (r) {
|
|
796
|
+
if (!r.ok) {
|
|
797
|
+
applySaveStatus('error');
|
|
798
|
+
return r;
|
|
799
|
+
}
|
|
1073
800
|
return r.text().then(function (text) {
|
|
1074
801
|
try { return text ? JSON.parse(text) : {}; } catch (e) { return {}; }
|
|
1075
802
|
}).then(function (data) {
|
|
@@ -1162,15 +889,26 @@ export function initializeCreativeRowEditor() {
|
|
|
1162
889
|
}
|
|
1163
890
|
}
|
|
1164
891
|
updateActionButtonStates();
|
|
892
|
+
// Only announce "saved" when the current buffer still matches what we
|
|
893
|
+
// just persisted. Text edits during the in-flight save keep isDirty
|
|
894
|
+
// true; a non-text change (e.g. a second progress toggle) re-arms
|
|
895
|
+
// pendingSave while this early-returns on the shared promise. Either
|
|
896
|
+
// means the latest value isn't persisted, so show "pending".
|
|
897
|
+
applySaveStatus((isDirty || pendingSave) ? 'pending' : 'saved');
|
|
1165
898
|
});
|
|
899
|
+
}).catch(function (err) {
|
|
900
|
+
// Preserve existing rejection propagation; only surface save status.
|
|
901
|
+
applySaveStatus('error');
|
|
902
|
+
throw err;
|
|
1166
903
|
}).finally(function () {
|
|
1167
|
-
saving = false;
|
|
1168
904
|
if (!shouldPersistProgress) {
|
|
1169
905
|
if (progressInput) progressInput.disabled = progressInputsDisabled;
|
|
1170
906
|
if (progressHiddenInput) progressHiddenInput.disabled = hiddenProgressDisabled;
|
|
1171
907
|
}
|
|
1172
908
|
});
|
|
1173
|
-
|
|
909
|
+
}
|
|
910
|
+
return waitForUploads().then(function () {
|
|
911
|
+
return saveQueue.runExclusive(performSave);
|
|
1174
912
|
});
|
|
1175
913
|
}
|
|
1176
914
|
|
|
@@ -1197,7 +935,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1197
935
|
|
|
1198
936
|
const finalizeHide = function () {
|
|
1199
937
|
template.style.display = 'none';
|
|
1200
|
-
const p = (pendingSave || saving) ? saveForm(tree, parentId) : Promise.resolve();
|
|
938
|
+
const p = (pendingSave || saveQueue.saving) ? saveForm(tree, parentId) : Promise.resolve();
|
|
1201
939
|
return p.then(() => {
|
|
1202
940
|
if (wasNew && !form.dataset.creativeId) {
|
|
1203
941
|
removeTreeElement(tree);
|
|
@@ -1254,7 +992,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1254
992
|
}
|
|
1255
993
|
|
|
1256
994
|
function beforeNewOrMove(wasNew, prev, prevParent) {
|
|
1257
|
-
const needsSave = pendingSave || wasNew || saving;
|
|
995
|
+
const needsSave = pendingSave || wasNew || saveQueue.saving;
|
|
1258
996
|
const p = needsSave ? saveForm(prev, prevParent) : Promise.resolve();
|
|
1259
997
|
return p.then(() => {
|
|
1260
998
|
if (wasNew && !form.dataset.creativeId) {
|
|
@@ -1288,8 +1026,12 @@ export function initializeCreativeRowEditor() {
|
|
|
1288
1026
|
|
|
1289
1027
|
// CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
|
|
1290
1028
|
// to a different creative while we're waiting for uploads
|
|
1291
|
-
|
|
1292
|
-
|
|
1029
|
+
// Both editor surfaces persist Markdown now. The textarea surface
|
|
1030
|
+
// (markdownMode) syncs its value to the hidden fields here; the rich
|
|
1031
|
+
// surface already kept them current via onLexicalChange/applyCreativeData.
|
|
1032
|
+
if (markdownMode) syncMarkdownToForm();
|
|
1033
|
+
const capturedContentType = contentTypeInput ? contentTypeInput.value : 'html';
|
|
1034
|
+
const isMarkdownSave = capturedContentType === 'markdown';
|
|
1293
1035
|
let currentContent = descriptionInput.value;
|
|
1294
1036
|
let currentProgress = readProgressValue();
|
|
1295
1037
|
let shouldPersistProgress = progressValueChanged();
|
|
@@ -1297,8 +1039,8 @@ export function initializeCreativeRowEditor() {
|
|
|
1297
1039
|
const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
|
|
1298
1040
|
const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
|
|
1299
1041
|
const startCreativeId = creativeId;
|
|
1300
|
-
let capturedMarkdownSource = isMarkdownSave ? (
|
|
1301
|
-
const
|
|
1042
|
+
let capturedMarkdownSource = isMarkdownSave ? (markdownSourceInput ? markdownSourceInput.value : '') : '';
|
|
1043
|
+
const capturedMarkdownEditor = markdownEditorInput ? markdownEditorInput.value : '';
|
|
1302
1044
|
|
|
1303
1045
|
// Prevent saving empty content, matching saveForm behavior
|
|
1304
1046
|
// This avoids overwriting existing descriptions with empty strings during quick navigation
|
|
@@ -1320,9 +1062,12 @@ export function initializeCreativeRowEditor() {
|
|
|
1320
1062
|
// so we must re-sync and re-capture the latest textarea value too — otherwise edits
|
|
1321
1063
|
// made during the upload wait get overwritten by the stale pre-wait source.
|
|
1322
1064
|
if (form.dataset.creativeId === startCreativeId) {
|
|
1323
|
-
if (
|
|
1065
|
+
if (markdownMode) {
|
|
1324
1066
|
syncMarkdownToForm();
|
|
1325
|
-
capturedMarkdownSource =
|
|
1067
|
+
capturedMarkdownSource = markdownSourceInput ? markdownSourceInput.value : '';
|
|
1068
|
+
} else if (isMarkdownSave && markdownSourceInput) {
|
|
1069
|
+
// Rich surface: re-capture any Markdown produced by edits during the wait.
|
|
1070
|
+
capturedMarkdownSource = markdownSourceInput.value;
|
|
1326
1071
|
}
|
|
1327
1072
|
currentContent = descriptionInput.value;
|
|
1328
1073
|
currentProgress = readProgressValue();
|
|
@@ -1338,6 +1083,9 @@ export function initializeCreativeRowEditor() {
|
|
|
1338
1083
|
};
|
|
1339
1084
|
if (isMarkdownSave) {
|
|
1340
1085
|
body['creative[markdown_source]'] = capturedMarkdownSource;
|
|
1086
|
+
if (capturedMarkdownEditor) {
|
|
1087
|
+
body['creative[markdown_editor]'] = capturedMarkdownEditor;
|
|
1088
|
+
}
|
|
1341
1089
|
}
|
|
1342
1090
|
|
|
1343
1091
|
if (shouldPersistProgress) {
|
|
@@ -1368,6 +1116,10 @@ export function initializeCreativeRowEditor() {
|
|
|
1368
1116
|
}
|
|
1369
1117
|
row.dataset.contentType = capturedContentType;
|
|
1370
1118
|
row.dataset.markdownSource = isMarkdownSave ? capturedMarkdownSource : '';
|
|
1119
|
+
// Persist which surface authored this save so a row re-opened from this
|
|
1120
|
+
// cached payload (before any full GET refresh) reopens in the right
|
|
1121
|
+
// editor — without it, rich-authored Markdown falls back to the textarea.
|
|
1122
|
+
row.dataset.markdownEditor = isMarkdownSave ? capturedMarkdownEditor : '';
|
|
1371
1123
|
if (currentParentId) {
|
|
1372
1124
|
tree.dataset.parentId = currentParentId;
|
|
1373
1125
|
row.parentId = currentParentId;
|
|
@@ -1443,7 +1195,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1443
1195
|
}
|
|
1444
1196
|
isDirty = false;
|
|
1445
1197
|
pendingSave = false;
|
|
1446
|
-
|
|
1198
|
+
saveQueue.cancelTimer();
|
|
1447
1199
|
}
|
|
1448
1200
|
|
|
1449
1201
|
async function move(delta) {
|
|
@@ -1588,7 +1340,10 @@ export function initializeCreativeRowEditor() {
|
|
|
1588
1340
|
insertBefore = normalizeRowNode(firstChild);
|
|
1589
1341
|
beforeId = insertBefore ? creativeIdFrom(insertBefore) : '';
|
|
1590
1342
|
} else {
|
|
1591
|
-
parentId
|
|
1343
|
+
// parentId convention: `data-parent-id` is always present; "" means
|
|
1344
|
+
// root. Normalize to '' so an absent attribute (legacy DOM) and a
|
|
1345
|
+
// real root are treated identically, and a real parent id is kept.
|
|
1346
|
+
parentId = prev.dataset.parentId || '';
|
|
1592
1347
|
container = treeContainerElement(prev);
|
|
1593
1348
|
afterId = prev.dataset.id;
|
|
1594
1349
|
insertBefore = nodeAfterTreeBlock(prev);
|
|
@@ -1894,6 +1649,10 @@ export function initializeCreativeRowEditor() {
|
|
|
1894
1649
|
originalProgress = 0;
|
|
1895
1650
|
if (unconvertBtn) unconvertBtn.style.display = 'none';
|
|
1896
1651
|
pendingSave = false;
|
|
1652
|
+
isDirty = false;
|
|
1653
|
+
// The status span is shared across rows; clear the previous row's
|
|
1654
|
+
// "saved"/"failed" label so a blank new row doesn't inherit it.
|
|
1655
|
+
setSaveStatus('');
|
|
1897
1656
|
lexicalEditor.focus();
|
|
1898
1657
|
updateActionButtonStates();
|
|
1899
1658
|
document.dispatchEvent(new CustomEvent('creative-editing:start', {
|
|
@@ -1925,14 +1684,29 @@ export function initializeCreativeRowEditor() {
|
|
|
1925
1684
|
if (creativeId && destroyedCreativeIds.has(String(creativeId))) return;
|
|
1926
1685
|
|
|
1927
1686
|
pendingSave = true;
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1687
|
+
// A prior "saved"/"failed" label would otherwise claim the freshly edited
|
|
1688
|
+
// buffer is persisted during the 5s debounce window, so reflect the pending
|
|
1689
|
+
// save the moment the buffer diverges from what was last stored. The request
|
|
1690
|
+
// hasn't started yet, so this is "pending" (waiting), not "saving". Gate on
|
|
1691
|
+
// pendingSave (just set true), not isDirty: structure-only changes (level
|
|
1692
|
+
// up/down, reorder) schedule a save without setting isDirty, and must not
|
|
1693
|
+
// keep showing the previous "saved" label.
|
|
1694
|
+
setSaveStatus('pending');
|
|
1695
|
+
saveQueue.schedule(function () { saveForm(); });
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function onLexicalChange(payload) {
|
|
1699
|
+
if (markdownMode) return; // Ignore Lexical changes when the textarea is active
|
|
1700
|
+
const html = (payload && payload.html) || '';
|
|
1701
|
+
const markdown = (payload && payload.markdown) || '';
|
|
1934
1702
|
descriptionInput.value = html;
|
|
1935
|
-
//
|
|
1703
|
+
// The rich editor is now a Markdown-canonical surface: persist the Markdown
|
|
1704
|
+
// projection (text color/background as normalized <span> fragments) and
|
|
1705
|
+
// record that the rich surface authored it, so it reopens in Lexical.
|
|
1706
|
+
if (contentTypeInput) contentTypeInput.value = 'markdown';
|
|
1707
|
+
if (markdownSourceInput) markdownSourceInput.value = markdown;
|
|
1708
|
+
if (markdownEditorInput) markdownEditorInput.value = 'rich';
|
|
1709
|
+
// Mark as dirty if the HTML projection changed from original
|
|
1936
1710
|
isDirty = (html !== originalContent);
|
|
1937
1711
|
scheduleSave();
|
|
1938
1712
|
}
|
|
@@ -1992,9 +1766,11 @@ export function initializeCreativeRowEditor() {
|
|
|
1992
1766
|
let atEnd = false;
|
|
1993
1767
|
editorInstance.getEditorState().read(() => {
|
|
1994
1768
|
const selection = $getSelection();
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1769
|
+
// atStart must reflect the start of the whole document, not just offset 0
|
|
1770
|
+
// of the current node — otherwise the start of a second paragraph (e.g.
|
|
1771
|
+
// right after pressing Enter) is mistaken for the top and ArrowUp jumps
|
|
1772
|
+
// to the row above instead of moving the cursor up. See isSelectionAtDocumentStart.
|
|
1773
|
+
atStart = isSelectionAtDocumentStart(selection);
|
|
1998
1774
|
atEnd = isSelectionAtDocumentEnd(selection);
|
|
1999
1775
|
});
|
|
2000
1776
|
|
|
@@ -2014,32 +1790,6 @@ export function initializeCreativeRowEditor() {
|
|
|
2014
1790
|
}
|
|
2015
1791
|
}
|
|
2016
1792
|
|
|
2017
|
-
function isSelectionAtDocumentEnd(selection) {
|
|
2018
|
-
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
|
|
2019
|
-
|
|
2020
|
-
const focus = selection.focus;
|
|
2021
|
-
let node = focus.getNode();
|
|
2022
|
-
if (!node) return false;
|
|
2023
|
-
|
|
2024
|
-
const offset = focus.offset;
|
|
2025
|
-
if ($isTextNode(node)) {
|
|
2026
|
-
if (offset !== node.getTextContentSize()) return false;
|
|
2027
|
-
} else if (typeof node.getChildrenSize === 'function') {
|
|
2028
|
-
if (offset !== node.getChildrenSize()) return false;
|
|
2029
|
-
} else {
|
|
2030
|
-
// Fallback for nodes without children size (e.g., line breaks)
|
|
2031
|
-
const textSize = node.getTextContentSize?.() ?? 0;
|
|
2032
|
-
if (offset !== textSize) return false;
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
while (node && !$isRootOrShadowRoot(node)) {
|
|
2036
|
-
if (node.getNextSibling()) return false;
|
|
2037
|
-
node = node.getParent();
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
return !!node && $isRootOrShadowRoot(node);
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
1793
|
if (progressInput) {
|
|
2044
1794
|
progressInput.addEventListener('change', function () {
|
|
2045
1795
|
if (progressValue) {
|
|
@@ -2052,14 +1802,14 @@ export function initializeCreativeRowEditor() {
|
|
|
2052
1802
|
completionCascadePending = true;
|
|
2053
1803
|
const alertMessage = progressInput.dataset.childrenAlertMessage;
|
|
2054
1804
|
if (alertMessage) {
|
|
2055
|
-
|
|
1805
|
+
alertDialog(alertMessage);
|
|
2056
1806
|
}
|
|
2057
1807
|
}
|
|
2058
1808
|
updateProgressInputAvailability(readProgressValue());
|
|
2059
1809
|
// Save immediately on checkbox change to prevent losing the last toggle
|
|
2060
1810
|
// when the user navigates away before the debounce timer fires.
|
|
2061
1811
|
pendingSave = true;
|
|
2062
|
-
|
|
1812
|
+
saveQueue.cancelTimer();
|
|
2063
1813
|
saveForm();
|
|
2064
1814
|
});
|
|
2065
1815
|
}
|
|
@@ -2138,14 +1888,14 @@ export function initializeCreativeRowEditor() {
|
|
|
2138
1888
|
}
|
|
2139
1889
|
|
|
2140
1890
|
if (archiveBtn) {
|
|
2141
|
-
archiveBtn.addEventListener('click', function () {
|
|
1891
|
+
archiveBtn.addEventListener('click', async function () {
|
|
2142
1892
|
const creativeId = form.dataset.creativeId;
|
|
2143
1893
|
if (!creativeId) return;
|
|
2144
1894
|
const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`);
|
|
2145
1895
|
const isArchived = row?.hasAttribute('archived');
|
|
2146
1896
|
const confirmMsg = isArchived ? archiveBtn.dataset.restoreConfirm : archiveBtn.dataset.confirm;
|
|
2147
1897
|
|
|
2148
|
-
if (
|
|
1898
|
+
if (await confirmDialog(confirmMsg)) {
|
|
2149
1899
|
const apiCall = isArchived ? creativesApi.unarchive(creativeId) : creativesApi.archive(creativeId);
|
|
2150
1900
|
apiCall.then(res => {
|
|
2151
1901
|
if (res.ok) {
|
|
@@ -2172,14 +1922,14 @@ export function initializeCreativeRowEditor() {
|
|
|
2172
1922
|
}
|
|
2173
1923
|
|
|
2174
1924
|
if (deleteBtn) {
|
|
2175
|
-
deleteBtn.addEventListener('click', function () {
|
|
2176
|
-
if (
|
|
1925
|
+
deleteBtn.addEventListener('click', async function () {
|
|
1926
|
+
if (await confirmDialog(deleteBtn.dataset.confirm, { danger: true })) deleteCurrent(false);
|
|
2177
1927
|
});
|
|
2178
1928
|
}
|
|
2179
1929
|
|
|
2180
1930
|
if (deleteWithChildrenBtn) {
|
|
2181
|
-
deleteWithChildrenBtn.addEventListener('click', function () {
|
|
2182
|
-
if (
|
|
1931
|
+
deleteWithChildrenBtn.addEventListener('click', async function () {
|
|
1932
|
+
if (await confirmDialog(deleteWithChildrenBtn.dataset.confirm, { danger: true })) deleteCurrent(true);
|
|
2183
1933
|
});
|
|
2184
1934
|
}
|
|
2185
1935
|
|
|
@@ -2215,17 +1965,17 @@ export function initializeCreativeRowEditor() {
|
|
|
2215
1965
|
}
|
|
2216
1966
|
|
|
2217
1967
|
if (unlinkBtn) {
|
|
2218
|
-
unlinkBtn.addEventListener('click', function () {
|
|
2219
|
-
if (
|
|
1968
|
+
unlinkBtn.addEventListener('click', async function () {
|
|
1969
|
+
if (await confirmDialog(unlinkBtn.dataset.confirm, { danger: true })) deleteCurrent(false);
|
|
2220
1970
|
});
|
|
2221
1971
|
}
|
|
2222
1972
|
|
|
2223
1973
|
if (unconvertBtn) {
|
|
2224
|
-
unconvertBtn.addEventListener('click', function () {
|
|
1974
|
+
unconvertBtn.addEventListener('click', async function () {
|
|
2225
1975
|
const creativeId = form.dataset.creativeId;
|
|
2226
1976
|
if (!creativeId) return;
|
|
2227
1977
|
const confirmText = unconvertBtn.dataset.confirm;
|
|
2228
|
-
if (confirmText && !
|
|
1978
|
+
if (confirmText && !(await confirmDialog(confirmText))) return;
|
|
2229
1979
|
const errorMessage = unconvertBtn.dataset.error || 'Failed to unconvert.';
|
|
2230
1980
|
unconvertBtn.disabled = true;
|
|
2231
1981
|
saveForm()
|
|
@@ -2235,7 +1985,7 @@ export function initializeCreativeRowEditor() {
|
|
|
2235
1985
|
.json()
|
|
2236
1986
|
.catch(function () { return {}; })
|
|
2237
1987
|
.then(function (data) {
|
|
2238
|
-
|
|
1988
|
+
alertDialog(data && data.error ? data.error : errorMessage);
|
|
2239
1989
|
const error = new Error('Save failed');
|
|
2240
1990
|
error._handled = true;
|
|
2241
1991
|
throw error;
|
|
@@ -2252,12 +2002,12 @@ export function initializeCreativeRowEditor() {
|
|
|
2252
2002
|
.json()
|
|
2253
2003
|
.catch(function () { return {}; })
|
|
2254
2004
|
.then(function (data) {
|
|
2255
|
-
|
|
2005
|
+
alertDialog(data && data.error ? data.error : errorMessage);
|
|
2256
2006
|
});
|
|
2257
2007
|
})
|
|
2258
2008
|
.catch(function (error) {
|
|
2259
2009
|
if (error && error._handled) return;
|
|
2260
|
-
|
|
2010
|
+
alertDialog(errorMessage);
|
|
2261
2011
|
})
|
|
2262
2012
|
.finally(function () {
|
|
2263
2013
|
unconvertBtn.disabled = false;
|
|
@@ -2276,7 +2026,7 @@ export function initializeCreativeRowEditor() {
|
|
|
2276
2026
|
})
|
|
2277
2027
|
.catch(function (error) {
|
|
2278
2028
|
console.error('Failed to load metadata:', error);
|
|
2279
|
-
|
|
2029
|
+
alertDialog('Failed to load metadata');
|
|
2280
2030
|
});
|
|
2281
2031
|
}
|
|
2282
2032
|
|
|
@@ -2321,17 +2071,17 @@ export function initializeCreativeRowEditor() {
|
|
|
2321
2071
|
metadataPopup.style.display = 'none';
|
|
2322
2072
|
} else {
|
|
2323
2073
|
return response.json().then(function (data) {
|
|
2324
|
-
|
|
2074
|
+
alertDialog('Failed to save metadata: ' + (data.error || 'Unknown error'));
|
|
2325
2075
|
});
|
|
2326
2076
|
}
|
|
2327
2077
|
})
|
|
2328
2078
|
.catch(function (error) {
|
|
2329
2079
|
console.error('Failed to save metadata:', error);
|
|
2330
|
-
|
|
2080
|
+
alertDialog('Failed to save metadata');
|
|
2331
2081
|
});
|
|
2332
2082
|
} catch (error) {
|
|
2333
2083
|
console.error('YAML parse error:', error);
|
|
2334
|
-
|
|
2084
|
+
alertDialog('Invalid YAML format: ' + error.message);
|
|
2335
2085
|
}
|
|
2336
2086
|
});
|
|
2337
2087
|
}
|
|
@@ -2339,27 +2089,37 @@ export function initializeCreativeRowEditor() {
|
|
|
2339
2089
|
|
|
2340
2090
|
// Markdown toggle button
|
|
2341
2091
|
if (toggleMarkdownBtn) {
|
|
2342
|
-
toggleMarkdownBtn.addEventListener('click', function () {
|
|
2092
|
+
toggleMarkdownBtn.addEventListener('click', async function () {
|
|
2343
2093
|
if (markdownMode) {
|
|
2344
|
-
// Switching from Markdown → Rich Text
|
|
2094
|
+
// Switching from Markdown (advanced textarea) → Rich Text. The Markdown
|
|
2095
|
+
// stays canonical; we only flip the authoring surface to rich so it
|
|
2096
|
+
// reopens in Lexical, and render it to HTML for the editor view.
|
|
2345
2097
|
const confirmMsg = toggleMarkdownBtn.dataset.confirmToRichtext;
|
|
2346
|
-
if (confirmMsg && !
|
|
2098
|
+
if (confirmMsg && !(await confirmDialog(confirmMsg))) return;
|
|
2347
2099
|
const md = markdownTextarea?.value || '';
|
|
2348
2100
|
const html = md ? renderMarkdown(md) : '';
|
|
2349
2101
|
deactivateMarkdownMode();
|
|
2350
2102
|
descriptionInput.value = html;
|
|
2103
|
+
if (md) {
|
|
2104
|
+
if (contentTypeInput) contentTypeInput.value = 'markdown';
|
|
2105
|
+
if (markdownSourceInput) markdownSourceInput.value = md;
|
|
2106
|
+
if (markdownEditorInput) markdownEditorInput.value = 'rich';
|
|
2107
|
+
}
|
|
2351
2108
|
lexicalEditor.load(html, `creative-switch-${Date.now()}`);
|
|
2352
2109
|
lexicalEditor.focus();
|
|
2353
2110
|
isDirty = true;
|
|
2354
2111
|
scheduleSave();
|
|
2355
2112
|
} else {
|
|
2356
|
-
// Switching from Rich Text → Markdown
|
|
2113
|
+
// Switching from Rich Text → Markdown (advanced textarea). Preserve the
|
|
2114
|
+
// content by seeding the textarea with the rich surface's current
|
|
2115
|
+
// Markdown projection instead of discarding it.
|
|
2357
2116
|
const currentHtml = descriptionInput.value || '';
|
|
2358
2117
|
if (!isHtmlEmpty(currentHtml)) {
|
|
2359
2118
|
const confirmMsg = toggleMarkdownBtn.dataset.confirmToMarkdown;
|
|
2360
|
-
if (confirmMsg && !
|
|
2119
|
+
if (confirmMsg && !(await confirmDialog(confirmMsg))) return;
|
|
2361
2120
|
}
|
|
2362
|
-
|
|
2121
|
+
const existingMarkdown = markdownSourceInput ? markdownSourceInput.value : '';
|
|
2122
|
+
activateMarkdownMode(existingMarkdown);
|
|
2363
2123
|
isDirty = true;
|
|
2364
2124
|
scheduleSave();
|
|
2365
2125
|
}
|